1、双重检查锁定的由来
在 Java 程序中,有时候可能需要延迟一些对象的初始化操作,只有在使用这些对象的时候才进行初始化。但要正确实现线程安全延迟初始化需要一些技巧,否则很容易出现问题。
package com.yj.singleton;
/**
* @description: 不安全的单例代码
* @author: erlang
* @since: 2021-01-26 21:13
*/
public class SingletonUnsafe {
private static SingletonUnsafe singleton;
public static SingletonUnsafe getSingleton() {
if (singleton == null) { // 1、线程 A 执行
singleton = new SingletonUnsafe(); // 2、线程 B 执行
}
return singleton;
}
}
假设线程 A 执行代码 1 的同时,线程 B 执行代码 2。此时线程 A 可能会看到 singleton 引用的对象还没有完成初始化。对于 SingletonUnsafe 类,我们可以对 getSingleton 方法做同步处理来实现线程安全的延迟初始化,代码如下:
package com.yj.singleton;
/**
* @description: 使用同步代码代码块的单例代码
* @author: erlang
* @since: 2021-01-26 21:20
*/
public class SingletonSync {
private static SingletonUnsafe singleton;
public synchronized static SingletonUnsafe getSingleton() {
if (singleton == null) { // 1、线程 A 执行
singleton = new SingletonUnsafe(); // 2、线程 B 执行
}
return singleton;
}
}
这里对 getSingleton 方法做了同步处理,synchronized 将导致性能开销。如果 getSingleton 方法被多个线程频繁的调用,将会导致程序执行性能的下降。
在早期的 JVM 中,synchronized 存在巨大的性能开销。因此想出了双重检查锁定(Double-Checked Locking)。人们想通过双重检查锁定来降低同步的开销。代码如图:
package com.yj.singleton;
/**
* @description: 单例
* @author: erlang
* @since: 2021-01-26 22:43
*/
public class Singleton { // 1
public static Singleton singleton; // 2
public static Singleton getSingleton() { // 3
if (singleton == null) { // 4:第一次检查
synchronized (Singleton.class) { // 5:加锁
if (singleton == null) { // 6:第二次检查
singleton = new Singleton(); // 7
} // 8
} // 9
} // 10
return singleton; // 11
}
}
从上面的代码可知,如果第一次检查 singleton 不为 null,那么就不需要执行下面的加锁和初始化操作。
- 多个线程试图在同一时间创建对象时,会通过加锁来保证只有一个线程创建对象
- 在对象创建好之后,执行 getSingleton 方法将不需要获取锁,直接返回已创建好的对象
双重检查锁定看起来似乎很完美,但这是一个错误的优化。在线程执行到第 4 行时,代码读取到 singleton 不 null 时,singleton 引用的对象有可能还没有初始化。
2、问题的根源
上面的双重检查锁定实例代码的第 7 行(instance = new Singleton())创建了一个对象。第一行代码可以分解为如下的三行伪代码:
memory = allocate(); // 1:分配对象的内存空间
ctorSingleton(memory); // 2:初始化对象
singleton = memory; // 3:设置 singleton 指向刚分配的内存地址
上面 3 行伪代码中的 2 和 3 之间,可能会被重排序(在一些 JIT 编译器上,这种重排序时真实发生的)。2 和 3 之间重排序之后的执行时序如下
memory = allocate(); // 1:分配对象的内存空间
singleton = memory; // 3:设置 singleton 指向刚分配的内存地址
// 此时对象还没有初始化
ctorSingleton(memory); // 2:初始化对象
所有线程在执行 Java 程序时必须遵守 intra-thread semantics。intra-thread semantics 保证重排序不会改变单线程内的执行结果。换句话说,允许那些在单线程内,不会改变单线程程序执行结果的重排序。上面的 3 行伪代码中的第 2 行和第 3 行之间虽然被重排序了,但这个重排序不会违反 intra-thread semantics。这个重排序在没有改变单线程程序执行结果的前提下,可以提高程序的执行性能。
单线程执行时序图:
由于单线程内要遵守 intra-thread semantics,从而保证线程 A 的执行结果不会被改变。但是,当线程 A 和 B 如下图所示执行时,线程 B 将看到一个还没有被初始化的对象。
当 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 将会访问到一个还未初始化的对象。
针对上面的问题,我们可以通过下面如下的操作来实现线程安全的延迟初始化:
- 不允许 2 和 3 重排序
- 允许 2 和 3 重排序,但不允许其他线程看到这个重排序
3、基于 volatile 的解决方案
把 singleton 声明为 volatile 变量,就可以实现线程安全的延迟初始化了。实例代码如下:
package com.yj.singleton;
/**
* @description: 单例
* @author: erlang
* @since: 2021-01-26 22:43
*/
public class Singleton { // 1
public static volatile Singleton singleton; // 2
public static Singleton getSingleton() { // 3
if (singleton == null) { // 4:第一次检查
synchronized (Singleton.class) { // 5:加锁
if (singleton == null) { // 6:第二次检查
singleton = new Singleton(); // 7
} // 8
} // 9
} // 10
return singleton; // 11
}
}
当声明对象的引用为 volatile 后,对下面的伪代码中 2 和 3 的重排序,在多线程的情况下会被禁止。这个方案的本质是通过禁止 2 和 3 之间的重排序,来保证线程安全的延迟初始化。时序图如下所示:
memory = allocate(); // 1:分配对象的内存空间
ctorSingleton(memory); // 2:初始化对象
singleton = memory; // 3:设置 singleton 指向刚分配的内存地址
4、基于类初始化的解决方案
JVM 类的初始化阶段(即在 Class 被加载后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM 会去获取一个锁,这个锁可以同步多个线程对同一个类的初始化。
基于这个特性,可以实现另一种线程安全的延迟初始化方案,这个方案被称为 Initialization On Demand Holder idiom。
package com.yj.singleton;
/**
* @description: 单例工厂
* @author: erlang
* @since: 2021-01-26 22:51
*/
public class SingletonFactory {
private static class SingletonHolder {
public static Singleton singleton = new Singleton();
}
public static Singleton getSingleton() {
// 这里将导致 SingletonHolder 类被初始化
return SingletonHolder.singleton;
}
public static class Singleton {
}
}
假设两个线程并发执行 getSingleton 方法,执行顺序如图所示:
这个方案的实质是:允许下面三行伪代码中的 2 和 3 重排序,但不允许线程 B 看到这个重排序。
memory = allocate(); // 1:分配对象的内存空间
ctorSingleton(memory); // 2:初始化对象
singleton = memory; // 3:设置 singleton 指向刚分配的内存地址
初始化一个类,包括执行这个类的静态初始化和初始化这个类中声明的静态字段。根据 Java 语言规范,在首次发生下列任意一种情况时,一个类或接口类型 T 将被立即初始化。
- T 是一个类,且一个 T 类型的实例被创建
- T 类的静态方法被调用
- T 类的静态变量被赋值
- T 类的静态变量被使用,且这个变量不是常量
- 在顶层类 T 中执行断言语句
SingletonFactory 实例中,多个线程可能同时尝试去初始化同一个类或接口。因此,在 Java 中初始化一个类或者接口时,需要做细致的同步处理。
Java 语言规范规定,对于每一个类或接口 C,都有一个唯一的初始化锁 LC 与之对应。从 C 到 LC 的映射,由 JVM 的具体实现去自由实现。JVM 在类初始化期间会获取这个初始化锁,并且每个线程至少获取一次锁来确保这个类已经被初始化过了。
对于类或接口的初始化,Java 语言规范制定了精巧而复杂的类初始化处理过程。Java 初始化一个类或接口的处理过程这里分了 5 个阶段
4.1、第一阶段
通过在 Class 对象上同步(即获取 Class 对象的初始化锁),来控制类或接口的初始化。这个获取锁的线程会一直等待,直到当前线程能够获取到这个初始化锁。
假设 Class 对象当前还没有被初始化(初始化状态被标记为 state=noInitialization),此时有两个线程 A 和线程 B 试图同时初始化这个 Class 对象。第一阶段示意图如下:
第一阶段执行时序如表所示
时间 | 线程 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 中等待。第二阶段示意图如下
第二阶段执行时序如表所示
时间 | 线程 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 中等待的所有线程。第三阶段示意图如下
第三阶段执行时序如表所示
时间 | 线程 A |
---|---|
1 | A1:获取初始化锁 |
2 | A2:设置 state=initialized |
3 | A3:唤醒在 condition 中等待的所有线程 |
4 | A4:释放初始化锁 |
5 | A5:线程 A 的初始化处理过程完成 |
4.4、第四阶段
线程 B 结束类的初始化处理。第四阶段示意图如下
第四阶段执行时序如表所示
时间 | 线程 B |
---|---|
1 | B1:获取初始化锁 |
2 | B2:读取到 state=initialized |
3 | B3:释放初始化锁 |
4 | B4:线程 B 的初始化处理过程完成 |
线程 A 在第二阶段的 A1 执行类的初始化,并在第三阶段的 A4 释放初始化锁;线程 B 在第四阶段的 B1 获取同一个初始化锁,并在第四阶段的 B4 之后 才开始访问这个类。根据 Java 内存模型规范的锁规则,这里将存在如下的 Happens-Before 关系。
这个 Happens-Before 关系保证:线程 A 执行类的初始化时的写入操作(执行类的静态初始化和初始化类的静态变量),线程 B 一定能看到
4.5、第五阶段
线程 C 执行类的初始化的处理。第五阶段示意图如下
第五阶段执行时序如表所示
时间 | 线程 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 关系。
这个 Happens-Before 关系将保证:线程 A 执行类的初始化时的写入操作,线程 C 一定能看到。
5、两种实现的区别
基于类初始化方案的代码更加简洁,基于 volatile 的延迟初始化方案,除了可以对静态变量实现延迟初始化,还可以对实例变量实现延迟初始化。变量初始化降低了初始化类或创建实例的开销,但增加了访问被延迟初始化的字段的开销。
当需要对实例变量使用线程安全的延迟初始化,使用基于 volatile 的延迟初始化方案;当需要对静态变量使用线程安全的延迟初始化,使用基于类初始化的方案。