1、双重检查锁定的由来

在 Java 程序中,有时候可能需要延迟一些对象的初始化操作,只有在使用这些对象的时候才进行初始化。但要正确实现线程安全延迟初始化需要一些技巧,否则很容易出现问题。

  1. package com.yj.singleton;
  2. /**
  3. * @description: 不安全的单例代码
  4. * @author: erlang
  5. * @since: 2021-01-26 21:13
  6. */
  7. public class SingletonUnsafe {
  8. private static SingletonUnsafe singleton;
  9. public static SingletonUnsafe getSingleton() {
  10. if (singleton == null) { // 1、线程 A 执行
  11. singleton = new SingletonUnsafe(); // 2、线程 B 执行
  12. }
  13. return singleton;
  14. }
  15. }

假设线程 A 执行代码 1 的同时,线程 B 执行代码 2。此时线程 A 可能会看到 singleton 引用的对象还没有完成初始化。对于 SingletonUnsafe 类,我们可以对 getSingleton 方法做同步处理来实现线程安全的延迟初始化,代码如下:

  1. package com.yj.singleton;
  2. /**
  3. * @description: 使用同步代码代码块的单例代码
  4. * @author: erlang
  5. * @since: 2021-01-26 21:20
  6. */
  7. public class SingletonSync {
  8. private static SingletonUnsafe singleton;
  9. public synchronized static SingletonUnsafe getSingleton() {
  10. if (singleton == null) { // 1、线程 A 执行
  11. singleton = new SingletonUnsafe(); // 2、线程 B 执行
  12. }
  13. return singleton;
  14. }
  15. }

这里对 getSingleton 方法做了同步处理,synchronized 将导致性能开销。如果 getSingleton 方法被多个线程频繁的调用,将会导致程序执行性能的下降。

在早期的 JVM 中,synchronized 存在巨大的性能开销。因此想出了双重检查锁定(Double-Checked Locking)。人们想通过双重检查锁定来降低同步的开销。代码如图:

  1. package com.yj.singleton;
  2. /**
  3. * @description: 单例
  4. * @author: erlang
  5. * @since: 2021-01-26 22:43
  6. */
  7. public class Singleton { // 1
  8. public static Singleton singleton; // 2
  9. public static Singleton getSingleton() { // 3
  10. if (singleton == null) { // 4:第一次检查
  11. synchronized (Singleton.class) { // 5:加锁
  12. if (singleton == null) { // 6:第二次检查
  13. singleton = new Singleton(); // 7
  14. } // 8
  15. } // 9
  16. } // 10
  17. return singleton; // 11
  18. }
  19. }

从上面的代码可知,如果第一次检查 singleton 不为 null,那么就不需要执行下面的加锁和初始化操作。

  1. 多个线程试图在同一时间创建对象时,会通过加锁来保证只有一个线程创建对象
  2. 在对象创建好之后,执行 getSingleton 方法将不需要获取锁,直接返回已创建好的对象

双重检查锁定看起来似乎很完美,但这是一个错误的优化。在线程执行到第 4 行时,代码读取到 singleton 不 null 时,singleton 引用的对象有可能还没有初始化。

2、问题的根源

上面的双重检查锁定实例代码的第 7 行(instance = new Singleton())创建了一个对象。第一行代码可以分解为如下的三行伪代码:

  1. memory = allocate(); // 1:分配对象的内存空间
  2. ctorSingleton(memory); // 2:初始化对象
  3. singleton = memory; // 3:设置 singleton 指向刚分配的内存地址

上面 3 行伪代码中的 2 和 3 之间,可能会被重排序(在一些 JIT 编译器上,这种重排序时真实发生的)。2 和 3 之间重排序之后的执行时序如下

  1. memory = allocate(); // 1:分配对象的内存空间
  2. singleton = memory; // 3:设置 singleton 指向刚分配的内存地址
  3. // 此时对象还没有初始化
  4. ctorSingleton(memory); // 2:初始化对象

所有线程在执行 Java 程序时必须遵守 intra-thread semantics。intra-thread semantics 保证重排序不会改变单线程内的执行结果。换句话说,允许那些在单线程内,不会改变单线程程序执行结果的重排序。上面的 3 行伪代码中的第 2 行和第 3 行之间虽然被重排序了,但这个重排序不会违反 intra-thread semantics。这个重排序在没有改变单线程程序执行结果的前提下,可以提高程序的执行性能。

单线程执行时序图:

七、DCL 单例模式 - 图1

由于单线程内要遵守 intra-thread semantics,从而保证线程 A 的执行结果不会被改变。但是,当线程 A 和 B 如下图所示执行时,线程 B 将看到一个还没有被初始化的对象。

七、DCL 单例模式 - 图2

当 Singleton 代码中第 7 行(singleton = new Singleton();)发生重排序时,另一个并发执行的线程 B 就有可能在第 4 行判断 singleton 不为 null。线程 B 接下来将访问 singleton 所引用的对象,但此时这个对象可能还没有被线程 A 初始化。执行时序如下

时间 线程 A 线程 B
1 A1:分配对象的内存空间
2 A3:设置 singleton 指向内存空间
3 B1:判断 singleton 是否为空
4 B2:由于 singleton 不为 null,线程 B 将访问 singleton 引用的对象
5 A2:线程 A 释放初始化锁
6 A4:访问 singleton 引用的对象

这里 A2 和 A3 虽然重排序,但 Java 内存模型的 intra-thread semantics 将确保 A2 一定会排在 A4 前面执行。因此,线程 A 的 intra-thread semantics 没有改变,但 A2 和 A3 的重排序,将导致线程 B 在 B1 处判断出 singleton 不为空,线程 B 接下来将访问 singleton 引用的对象。此时,线程 B 将会访问到一个还未初始化的对象。

针对上面的问题,我们可以通过下面如下的操作来实现线程安全的延迟初始化:

  1. 不允许 2 和 3 重排序
  2. 允许 2 和 3 重排序,但不允许其他线程看到这个重排序

3、基于 volatile 的解决方案

把 singleton 声明为 volatile 变量,就可以实现线程安全的延迟初始化了。实例代码如下:

  1. package com.yj.singleton;
  2. /**
  3. * @description: 单例
  4. * @author: erlang
  5. * @since: 2021-01-26 22:43
  6. */
  7. public class Singleton { // 1
  8. public static volatile Singleton singleton; // 2
  9. public static Singleton getSingleton() { // 3
  10. if (singleton == null) { // 4:第一次检查
  11. synchronized (Singleton.class) { // 5:加锁
  12. if (singleton == null) { // 6:第二次检查
  13. singleton = new Singleton(); // 7
  14. } // 8
  15. } // 9
  16. } // 10
  17. return singleton; // 11
  18. }
  19. }

当声明对象的引用为 volatile 后,对下面的伪代码中 2 和 3 的重排序,在多线程的情况下会被禁止。这个方案的本质是通过禁止 2 和 3 之间的重排序,来保证线程安全的延迟初始化。时序图如下所示:

  1. memory = allocate(); // 1:分配对象的内存空间
  2. ctorSingleton(memory); // 2:初始化对象
  3. singleton = memory; // 3:设置 singleton 指向刚分配的内存地址

七、DCL 单例模式 - 图3

4、基于类初始化的解决方案

JVM 类的初始化阶段(即在 Class 被加载后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM 会去获取一个锁,这个锁可以同步多个线程对同一个类的初始化。

基于这个特性,可以实现另一种线程安全的延迟初始化方案,这个方案被称为 Initialization On Demand Holder idiom。

  1. package com.yj.singleton;
  2. /**
  3. * @description: 单例工厂
  4. * @author: erlang
  5. * @since: 2021-01-26 22:51
  6. */
  7. public class SingletonFactory {
  8. private static class SingletonHolder {
  9. public static Singleton singleton = new Singleton();
  10. }
  11. public static Singleton getSingleton() {
  12. // 这里将导致 SingletonHolder 类被初始化
  13. return SingletonHolder.singleton;
  14. }
  15. public static class Singleton {
  16. }
  17. }

假设两个线程并发执行 getSingleton 方法,执行顺序如图所示:

七、DCL 单例模式 - 图4

这个方案的实质是:允许下面三行伪代码中的 2 和 3 重排序,但不允许线程 B 看到这个重排序。

  1. memory = allocate(); // 1:分配对象的内存空间
  2. ctorSingleton(memory); // 2:初始化对象
  3. singleton = memory; // 3:设置 singleton 指向刚分配的内存地址

初始化一个类,包括执行这个类的静态初始化和初始化这个类中声明的静态字段。根据 Java 语言规范,在首次发生下列任意一种情况时,一个类或接口类型 T 将被立即初始化。

  1. T 是一个类,且一个 T 类型的实例被创建
  2. T 类的静态方法被调用
  3. T 类的静态变量被赋值
  4. T 类的静态变量被使用,且这个变量不是常量
  5. 在顶层类 T 中执行断言语句

SingletonFactory 实例中,多个线程可能同时尝试去初始化同一个类或接口。因此,在 Java 中初始化一个类或者接口时,需要做细致的同步处理。

Java 语言规范规定,对于每一个类或接口 C,都有一个唯一的初始化锁 LC 与之对应。从 C 到 LC 的映射,由 JVM 的具体实现去自由实现。JVM 在类初始化期间会获取这个初始化锁,并且每个线程至少获取一次锁来确保这个类已经被初始化过了。

对于类或接口的初始化,Java 语言规范制定了精巧而复杂的类初始化处理过程。Java 初始化一个类或接口的处理过程这里分了 5 个阶段

4.1、第一阶段

通过在 Class 对象上同步(即获取 Class 对象的初始化锁),来控制类或接口的初始化。这个获取锁的线程会一直等待,直到当前线程能够获取到这个初始化锁。

假设 Class 对象当前还没有被初始化(初始化状态被标记为 state=noInitialization),此时有两个线程 A 和线程 B 试图同时初始化这个 Class 对象。第一阶段示意图如下:

七、DCL 单例模式 - 图5

第一阶段执行时序如表所示

时间 线程 A 线程 B
1 A1:尝试获取 Class 对象的初始化锁。这里假设线程 A 获取到了初始化锁 B1:尝试获取 Class 对象的初始化锁,由于线程 A 获取到了锁,线程 B 将一直等待获取初始化锁
2 A2:线程 A 看到对象的 state 为 noInitialization,即对象还未被初始化,线程 A 设置 state=initializing
3 A3:线程 A 释放初始化锁

4.2、第二阶段

线程 A 执行类的初始化,同时线程 B 在初始化锁对应的 condition 中等待。第二阶段示意图如下

七、DCL 单例模式 - 图6

第二阶段执行时序如表所示

时间 线程 A 线程 B
1 A1:执行类的静态初始化和初始化类的静态变量 B1:获取到初始化锁
2 B2:读取到 state=initializing
3 B3:释放初始化锁
4 B4:在初始化锁的 condition 中等待

这里的 condition 合 state 标记是本文虚构的。Java 语言规范并没有硬性规定一定要使用 condition 和 state 标记。JVM 的具体实现只要实现类似功能即可。

4.3、第三阶段

线程 A 设置 state=initialized,然后唤醒在 condition 中等待的所有线程。第三阶段示意图如下

七、DCL 单例模式 - 图7

第三阶段执行时序如表所示

时间 线程 A
1 A1:获取初始化锁
2 A2:设置 state=initialized
3 A3:唤醒在 condition 中等待的所有线程
4 A4:释放初始化锁
5 A5:线程 A 的初始化处理过程完成

4.4、第四阶段

线程 B 结束类的初始化处理。第四阶段示意图如下

七、DCL 单例模式 - 图8

第四阶段执行时序如表所示

时间 线程 B
1 B1:获取初始化锁
2 B2:读取到 state=initialized
3 B3:释放初始化锁
4 B4:线程 B 的初始化处理过程完成

线程 A 在第二阶段的 A1 执行类的初始化,并在第三阶段的 A4 释放初始化锁;线程 B 在第四阶段的 B1 获取同一个初始化锁,并在第四阶段的 B4 之后 才开始访问这个类。根据 Java 内存模型规范的锁规则,这里将存在如下的 Happens-Before 关系。

七、DCL 单例模式 - 图9

这个 Happens-Before 关系保证:线程 A 执行类的初始化时的写入操作(执行类的静态初始化和初始化类的静态变量),线程 B 一定能看到

4.5、第五阶段

线程 C 执行类的初始化的处理。第五阶段示意图如下

七、DCL 单例模式 - 图10

第五阶段执行时序如表所示

时间 线程 C
1 C1:获取初始化锁
2 C2:读取到 state=initialized
3 C3:释放初始化锁
4 C4:线程 C 的初始化处理过程完成

第三阶段之后,类已经完成了初始化。因此线程 C 在第五阶段的类初始化处理过程相对简单一些。前面的线程 A 和 B 的类初始化处理过程都经历了两次锁获取-锁释放,而线程 C 的类初始化处理只需要经历一次锁获取-锁释放。

线程 A 在第二阶段的 A1 执行类的初始化,并在第三阶段的 A4 释放锁;线程 C 在第五阶段的 C1 获取同一个锁,并在在第五阶段的 C4 之后才开始访问这个类。根据 Java 内存模型规范的锁规则,将存在如下的 Happens-Before 关系。

七、DCL 单例模式 - 图11

这个 Happens-Before 关系将保证:线程 A 执行类的初始化时的写入操作,线程 C 一定能看到。

5、两种实现的区别

基于类初始化方案的代码更加简洁,基于 volatile 的延迟初始化方案,除了可以对静态变量实现延迟初始化,还可以对实例变量实现延迟初始化。变量初始化降低了初始化类或创建实例的开销,但增加了访问被延迟初始化的字段的开销。

当需要对实例变量使用线程安全的延迟初始化,使用基于 volatile 的延迟初始化方案;当需要对静态变量使用线程安全的延迟初始化,使用基于类初始化的方案。