返回文章列表
JUC并发编程
JUC两阶段终止单例模式享元模式

17两阶段终止与单例模式

本篇整理模式篇最后四个主题:

  • 两阶段终止模式:如何「优雅」地终止一个线程,给它料理后事的机会
  • 线程安全单例:饿汉、枚举、懒汉、DCL、静态内部类五种写法
  • 不可变模式:对象一旦创建状态不再改变,天生线程安全
  • 享元模式:重用数量有限的同一类对象,如包装类缓存、数据库连接池

终止模式之两阶段终止模式

Two Phase Termination:在线程 T1 中如何「优雅」终止线程 T2?这里的【优雅】指的是给 T2 一个料理后事的机会,而不是强行杀死。

两个阶段:

  1. 阶段一(发出终止信号):T1 调用 T2 的 interrupt()(或修改停止标记)
  2. 阶段二(响应终止):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;
    }
}

五个问题的解答:

  1. 为什么类加 final:防止子类继承后扩展、改变其行为,保护单例语义不被破坏
  2. 实现序列化接口后:反序列化默认会新建对象,需要提供 readResolve() 直接返回 INSTANCE,替换掉反序列化出的新对象
  3. 构造器为什么私有:阻止外部 new 创建实例;但不能防止反射——反射通过 setAccessible(true) 仍能调用私有构造器
  4. 静态初始化是否线程安全:安全。类加载的初始化阶段(执行 <clinit>)由 JVM 在 Class 对象上加锁保证,多个线程同时首次使用该类也只会初始化一次
  5. 为什么用静态方法而不是 public static 字段:方法提供了更好的封装——以后可以改成懒汉式、加泛型、加日志或做访问控制,且方法能兼容序列化代理等机制,调用方代码无需修改

2. 枚举单例

enum Singleton {
    INSTANCE;
}

六个问题的解答:

  1. 如何限制实例个数:枚举类中声明几个枚举常量,就有几个实例;这里只有 INSTANCE 一个
  2. 创建时是否有并发问题:没有,枚举实例在类加载时由 JVM 初始化,与饿汉式一样由类加载机制保证线程安全
  3. 能否被反射破坏:不能。Constructor.newInstance() 对枚举类型会直接抛出异常
  4. 能否被反序列化破坏:不能。枚举的反序列化不是新建对象,而是根据 name 找到 JVM 中已存在的枚举常量
  5. 属于懒汉还是饿汉:饿汉式,类加载时即创建
  6. 希望加入初始化逻辑怎么办:在枚举类中编写构造方法、字段或初始化代码块,枚举常量声明时传入参数即可

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;
        }
    }
}
  1. 为什么要加 volatile:防止指令重排序。new Singleton() 不是原子操作,分为「分配内存 → 初始化对象 → 引用指向内存」三步,重排后可能变成「分配内存 → 引用指向内存 → 初始化对象」。另一线程在第一次检查时可能看到一个非 null 但尚未初始化完的对象。volatile 的写屏障禁止这种重排,并保证可见性
  2. 对比懒汉版的意义:把 synchronized 从方法上移到内部,实例创建后再调用 getInstance 不必加锁,兼顾了懒加载与性能
  3. 同步块内为什么还要判空:第一个线程创建完释放锁后,之前在锁上排队的其它线程进入同步块,如果不再次判空,就会重复 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;
    }
}
  1. 懒汉还是饿汉:懒汉式。外部类 Singleton 加载时并不会加载 LazyHolder,只有首次调用 getInstance 用到 LazyHolder.INSTANCE 时,才会触发内部类加载与实例初始化
  2. 创建时是否有并发问题:没有。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~127
  • Character 缓存的范围是 0~127
  • Integer 的默认范围是 -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 等待归还