17两阶段终止与单例模式
本篇整理模式篇最后四个主题:
- 两阶段终止模式:如何「优雅」地终止一个线程,给它料理后事的机会
- 线程安全单例:饿汉、枚举、懒汉、DCL、静态内部类五种写法
- 不可变模式:对象一旦创建状态不再改变,天生线程安全
- 享元模式:重用数量有限的同一类对象,如包装类缓存、数据库连接池
终止模式之两阶段终止模式
Two Phase Termination:在线程 T1 中如何「优雅」终止线程 T2?这里的【优雅】指的是给 T2 一个料理后事的机会,而不是强行杀死。
两个阶段:
- 阶段一(发出终止信号):T1 调用 T2 的
interrupt()(或修改停止标记) - 阶段二(响应终止):T2 在循环中检查是否被打断,发现后执行善后逻辑(保存结果、释放资源),再退出循环结束运行
错误思路
- 使用线程对象的
stop()方法停止线程:stop会真正杀死线程。如果这时线程锁住了共享资源,它被杀死后就再也没有机会释放锁,其它线程将永远无法获取锁 - 使用
System.exit(int)方法停止线程:目的仅是停止一个线程,但这种做法会让整个程序都停止
利用 isInterrupted
interrupt 可以打断正在执行的线程,无论这个线程是在 sleep、wait,还是正常运行。关键点是:当线程在 sleep 中被打断,捕获 InterruptedException 后打断标记会被清空,所以要在 catch 中再次调用 interrupt(),把标记重新设置上。
class TPTInterrupt {
private Thread thread;
public void start() {
thread = new Thread(() -> {
while (true) {
Thread current = Thread.currentThread();
if (current.isInterrupted()) {
log.debug("料理后事");
break;
}
try {
Thread.sleep(1000);
log.debug("将结果保存");
} catch (InterruptedException e) {
current.interrupt();
}
// 执行监控操作
}
}, "监控线程");
thread.start();
}
public void stop() {
thread.interrupt();
}
}调用:
TPTInterrupt t = new TPTInterrupt();
t.start();
Thread.sleep(3500);
log.debug("stop");
t.stop();结果:
11:49:42.915 c.TwoPhaseTermination [监控线程] - 将结果保存
11:49:43.919 c.TwoPhaseTermination [监控线程] - 将结果保存
11:49:44.919 c.TwoPhaseTermination [监控线程] - 将结果保存
11:49:45.413 c.TestTwoPhaseTermination [main] - stop
11:49:45.413 c.TwoPhaseTermination [监控线程] - 料理后事利用停止标记
也可以自己维护一个 volatile boolean 停止标记。volatile 保证该变量在多个线程之间的可见性——主线程把它修改为 true 后,监控线程立刻能看到。同时 stop 中仍然调用 interrupt(),目的是让正在 sleep 的线程立刻醒来响应停止,而不是等睡眠结束。
class TPTVolatile {
private Thread thread;
private volatile boolean stop = false;
public void start() {
thread = new Thread(() -> {
while (true) {
Thread current = Thread.currentThread();
if (stop) {
log.debug("料理后事");
break;
}
try {
Thread.sleep(1000);
log.debug("将结果保存");
} catch (InterruptedException e) {
}
// 执行监控操作
}
}, "监控线程");
thread.start();
}
public void stop() {
stop = true;
thread.interrupt();
}
}调用:
TPTVolatile t = new TPTVolatile();
t.start();
Thread.sleep(3500);
log.debug("stop");
t.stop();11:54:52.003 c.TPTVolatile [监控线程] - 将结果保存
11:54:53.006 c.TPTVolatile [监控线程] - 将结果保存
11:54:54.007 c.TPTVolatile [监控线程] - 将结果保存
11:54:54.502 c.TestTwoPhaseTermination [main] - stop
11:54:54.502 c.TPTVolatile [监控线程] - 料理后事睡眠期间被打断的处理
两种实现对比着看,睡眠期间被打断时:
sleep、wait抛InterruptedException时,JVM 会清除打断标记,所以 catch 里必须再次current.interrupt(),否则下一轮循环检查不到终止信号- 用停止标记的版本,即使 catch 中什么也不做,
while条件处也会读到stop == true;但仍需thread.interrupt()把睡眠中的线程唤醒,停止才及时
案例:JVM 内存监控。监控线程每隔 1 秒采样一次内存使用情况并记录结果,主线程在适当时机调用
stop(),监控线程捕获终止信号后把最后一次结果保存、输出汇总信息再退出——这就是两阶段终止的典型应用。
线程安全单例
单例模式有很多实现方法:饿汉、懒汉、静态内部类、枚举类。分析每种实现下获取单例对象(即调用 getInstance)时的线程安全,并思考注释中的问题。
- 饿汉式:类加载就会导致该单实例对象被创建
- 懒汉式:类加载不会导致该单实例对象被创建,而是首次使用该对象时才会创建
1. 饿汉单例
public final class Singleton implements Serializable {
private Singleton() {}
// 问题4:这样初始化是否能保证单例对象创建时的线程安全?
private static final Singleton INSTANCE = new Singleton();
// 问题5:为什么提供静态方法而不是直接将 INSTANCE 设置为 public
public static Singleton getInstance() {
return INSTANCE;
}
// 问题2:如果实现了序列化接口,还要做什么来防止反序列化破坏单例
public Object readResolve() {
return INSTANCE;
}
}五个问题的解答:
- 为什么类加
final:防止子类继承后扩展、改变其行为,保护单例语义不被破坏 - 实现序列化接口后:反序列化默认会新建对象,需要提供
readResolve()直接返回INSTANCE,替换掉反序列化出的新对象 - 构造器为什么私有:阻止外部
new创建实例;但不能防止反射——反射通过setAccessible(true)仍能调用私有构造器 - 静态初始化是否线程安全:安全。类加载的初始化阶段(执行
<clinit>)由 JVM 在Class对象上加锁保证,多个线程同时首次使用该类也只会初始化一次 - 为什么用静态方法而不是
public static字段:方法提供了更好的封装——以后可以改成懒汉式、加泛型、加日志或做访问控制,且方法能兼容序列化代理等机制,调用方代码无需修改
2. 枚举单例
enum Singleton {
INSTANCE;
}六个问题的解答:
- 如何限制实例个数:枚举类中声明几个枚举常量,就有几个实例;这里只有
INSTANCE一个 - 创建时是否有并发问题:没有,枚举实例在类加载时由 JVM 初始化,与饿汉式一样由类加载机制保证线程安全
- 能否被反射破坏:不能。
Constructor.newInstance()对枚举类型会直接抛出异常 - 能否被反序列化破坏:不能。枚举的反序列化不是新建对象,而是根据
name找到 JVM 中已存在的枚举常量 - 属于懒汉还是饿汉:饿汉式,类加载时即创建
- 希望加入初始化逻辑怎么办:在枚举类中编写构造方法、字段或初始化代码块,枚举常量声明时传入参数即可
3. 懒汉单例
public final class Singleton {
private Singleton() { }
private static Singleton INSTANCE = null;
// 分析这里的线程安全,并说明有什么缺点
public static synchronized Singleton getInstance() {
if (INSTANCE != null) {
return INSTANCE;
}
INSTANCE = new Singleton();
return INSTANCE;
}
}线程安全由 synchronized 静态方法保证(锁的是 Singleton.class),首次创建不会并发。缺点也很明显:每次调用 getInstance 都要进入同步块,即使实例早已创建,存在不必要的加锁开销。
4. DCL 懒汉单例
DCL 即 Double-Checked Locking(双重检查锁定):第一次检查不加锁,实例已存在时直接返回;只有实例为 null 时才进入同步块,块内再检查一次,防止多个线程排队后重复创建。
public final class Singleton {
private Singleton() { }
// 问题1:解释为什么要加 volatile ?
private static volatile Singleton INSTANCE = null;
// 问题2:对比实现3,说出这样做的意义
public static Singleton getInstance() {
if (INSTANCE != null) {
return INSTANCE;
}
synchronized (Singleton.class) {
// 问题3:为什么还要在这里加为空判断,之前不是判断过了吗
if (INSTANCE != null) {
return INSTANCE;
}
INSTANCE = new Singleton();
return INSTANCE;
}
}
}- 为什么要加
volatile:防止指令重排序。new Singleton()不是原子操作,分为「分配内存 → 初始化对象 → 引用指向内存」三步,重排后可能变成「分配内存 → 引用指向内存 → 初始化对象」。另一线程在第一次检查时可能看到一个非 null 但尚未初始化完的对象。volatile的写屏障禁止这种重排,并保证可见性 - 对比懒汉版的意义:把
synchronized从方法上移到内部,实例创建后再调用getInstance不必加锁,兼顾了懒加载与性能 - 同步块内为什么还要判空:第一个线程创建完释放锁后,之前在锁上排队的其它线程进入同步块,如果不再次判空,就会重复
new出第二个实例
5. 静态内部类懒汉单例
public final class Singleton {
private Singleton() { }
// 问题1:属于懒汉式还是饿汉式
private static class LazyHolder {
static final Singleton INSTANCE = new Singleton();
}
// 问题2:在创建时是否有并发问题
public static Singleton getInstance() {
return LazyHolder.INSTANCE;
}
}- 懒汉还是饿汉:懒汉式。外部类
Singleton加载时并不会加载LazyHolder,只有首次调用getInstance用到LazyHolder.INSTANCE时,才会触发内部类加载与实例初始化 - 创建时是否有并发问题:没有。
INSTANCE的初始化仍由 JVM 类加载机制保证线程安全,无需开发者加锁,是兼顾懒加载、线程安全、简洁性的推荐写法
不可变模式
简介
如果一个对象在创建之后,其状态(成员变量的值)再也不能被改变,那么它就是不可变对象。不可变对象天生线程安全——任何线程读到的状态都一样,不需要加锁、也不会有可见性问题。JDK 中大量类被设计成不可变的,String、八种包装类、BigDecimal、BigInteger 等都是。
体现
- 包装类:
Integer、Long等内部保存基本类型值的字段是private final的,没有任何修改入口,i++这类运算实际上是新建了一个包装类对象替换引用,并没有改变原对象 - String 串池:
String内部字符数组不可变,substring、toUpperCase、字符串拼接等操作返回的都是新字符串。JVM 还维护字符串常量池(String Table),字面量相同的字符串共享同一个对象,进一步节省内存 - BigDecimal / BigInteger:高精度数值同样不可变,
add、multiply等运算都返回新对象。涉及金额计算时应使用它们,且比较相等用compareTo而非equals(1.0与1.00的 scale 不同)
DIY
手写一个不可变类,要点:类声明为 final、字段 private final、不提供任何修改方法,对可变类型的字段(如数组、Date)在构造和返回时做防御性拷贝:
public final class ImmutableOrder {
private final long orderId;
private final String customer;
// 可变类型字段必须防御性拷贝,否则外部持有引用仍能修改内部状态
private final Date createTime;
private final List<String> items;
public ImmutableOrder(long orderId, String customer,
Date createTime, List<String> items) {
this.orderId = orderId;
this.customer = customer;
this.createTime = new Date(createTime.getTime());
this.items = new ArrayList<>(items);
}
public long getOrderId() {
return orderId;
}
public String getCustomer() {
return customer;
}
public Date getCreateTime() {
// 返回可变对象时同样拷贝一份,不泄露内部引用
return new Date(createTime.getTime());
}
public List<String> getItems() {
return Collections.unmodifiableList(items);
}
// 没有 setter;需要"修改"时返回一个新对象(类似 String.concat 的做法)
}如果不可变对象内部引用了可变对象却不做防御性拷贝,"不可变"就只是表面现象,外部仍能通过原引用改掉它的内部状态。
享元模式
简介
定义(Flyweight pattern):当需要重用数量有限的同一类对象时使用。维基百科的描述是:A flyweight is an object that minimizes memory usage by sharing as much data as possible with other similar objects。它出自 "Gang of Four" 设计模式,归类为 Structural patterns(结构型模式)。
JDK 中的体现:Boolean、Byte、Short、Integer、Long、Character 等包装类的 valueOf 方法会缓存常用对象。例如 Long 缓存了 -128~127 之间的对象,范围内直接重用、超出范围才新建:
public static Long valueOf(long l) {
final int offset = 128;
if (l >= -128 && l <= 127) { // will cache
return LongCache.cache[(int) l + offset];
}
return new Long(l);
}注意:
Byte、Short、Long缓存的范围都是 -128~127Character缓存的范围是 0~127Integer的默认范围是 -128~127,最小值不能变,但最大值可以通过虚拟机参数-Djava.lang.Integer.IntegerCache.high调整Boolean缓存了TRUE和FALSE
因此包装类比较大小应该用 equals,不能用 ==(== 只在缓存范围内恰好成立)。
自定义连接池
一个线上商城应用 QPS 达到数千,如果每次都重新创建和关闭数据库连接,性能会受到极大影响。这时预先创建好一批连接放入连接池:请求到达后从池中获取连接,使用完毕再还回池中——既节约了连接创建和关闭的时间,也实现了连接的重用,不至于让庞大的连接数压垮数据库。
连接池用 AtomicIntegerArray 记录每个连接的状态(0 空闲 / 1 繁忙),借连接时通过 CAS 抢占空闲连接,没有空闲连接时当前线程 wait;归还连接时改回状态并 notifyAll 唤醒等待者。
class Pool {
// 1. 连接池大小
private final int poolSize;
// 2. 连接对象数组
private Connection[] connections;
// 3. 连接状态数组 0 表示空闲,1 表示繁忙
private AtomicIntegerArray states;
// 4. 构造方法初始化
public Pool(int poolSize) {
this.poolSize = poolSize;
this.connections = new Connection[poolSize];
this.states = new AtomicIntegerArray(new int[poolSize]);
for (int i = 0; i < poolSize; i++) {
connections[i] = new MockConnection("连接" + (i + 1));
}
}
// 5. 借连接
public Connection borrow() {
while (true) {
for (int i = 0; i < poolSize; i++) {
// 获取空闲连接
if (states.get(i) == 0) {
if (states.compareAndSet(i, 0, 1)) {
log.debug("borrow {}", connections[i]);
return connections[i];
}
}
}
// 如果没有空闲连接,当前线程进入等待
synchronized (this) {
try {
log.debug("wait...");
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
// 6. 归还连接
public void free(Connection conn) {
for (int i = 0; i < poolSize; i++) {
if (connections[i] == conn) {
states.set(i, 0);
synchronized (this) {
log.debug("free {}", conn);
this.notifyAll();
}
break;
}
}
}
}
class MockConnection implements Connection {
// 实现略
}使用连接池:5 个线程争抢大小为 2 的连接池,用完即归还:
Pool pool = new Pool(2);
for (int i = 0; i < 5; i++) {
new Thread(() -> {
Connection conn = pool.borrow();
try {
Thread.sleep(new Random().nextInt(1000));
} catch (InterruptedException e) {
e.printStackTrace();
}
pool.free(conn);
}).start();
}使用连接池的注意点与未考虑点
以上教学实现没有考虑:
- 连接的动态增长与收缩
- 连接保活(可用性检测),防止借到已经失效的连接
- 等待超时处理(
borrow目前是无限等待) - 分布式 hash(分布式场景下多节点连接的分配)
对于关系型数据库,有比较成熟的连接池实现,例如 c3p0、Druid 等;对于更通用的对象池,可以考虑使用 Apache Commons Pool;Redis 连接池可以参考 Jedis 中关于连接池的实现。生产环境直接使用这些成熟实现即可,本例的目的是理解享元思想与池化结构。
小结
- 两阶段终止分「发信号」和「料理后事后退出」两步,不能用
stop()(锁无法释放)和System.exit()(整个程序停止) - 实现方式有两种:基于
isInterrupted()(睡眠中被打断要在 catch 里重新设置标记),或基于volatile停止标记(配合interrupt()及时唤醒睡眠) - 五种单例:饿汉(类加载保证安全)、枚举(最安全,反射/反序列化都无法破坏)、同步方法懒汉(安全但每次加锁)、DCL(
volatile+ 双重检查)、静态内部类(懒加载 + JVM 保证安全,推荐) - 不可变模式靠
final类、final字段、无修改方法和防御性拷贝实现,对象状态不变即天生线程安全,String、包装类、BigDecimal都是代表 - 享元模式重用有限对象:包装类的缓存、数据库连接池都是典型;手写连接池的关键是连接状态数组 + CAS 抢占 + wait/notify 等待归还