Java双重检查锁单例为什么要加volatile
在Java单例模式的实现中,双重检查锁定是一种常见的写法。很多开发者都知道代码中要加 volatile 关键字,但往往不清楚其深层原因。如果忽略这个关键字,在高并发场景下,你的程序可能会返回一个未经完全初始化的对象,导致不可预料的错误。
1. 审视标准的双重检查锁定代码
首先,观察一段标准的双重检查锁定(DCL)代码示例。
public class Singleton {
// 必须使用 volatile 关键字修饰
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
// 第一次检查:避免不必要的同步
if (instance == null) {
synchronized (Singleton.class) {
// 第二次检查:确保只创建一个实例
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
这段代码的设计初衷是:减少锁的竞争。只有在 instance 为 null 时才进入同步块,后续调用直接返回实例。然而,这种写法在 Java 内存模型(JMM)下存在隐患,必须依靠 volatile 来修复。
2. 拆解对象创建的幕后步骤
问题的关键在于 instance = new Singleton(); 这行代码。在 Java 中,这看似简单的一行语句,在 CPU 和 JVM 层面其实被拆解为三个独立的步骤。
阅读下表,了解这三个步骤的具体含义。
| 步骤 | 操作名称 | 具体描述 |
|---|---|---|
| 1 | 分配内存 | 在堆内存中开辟一块空闲空间,用于存放 Singleton 对象。 |
| 2 | 初始化对象 | 调用构造函数 <init>,对对象成员变量进行初始化赋值。 |
| 3 | 引用赋值 | 将堆内存的地址赋值给 instance 引用变量,使其指向该内存空间。 |
3. 分析指令重排序引发的风险
为了优化性能,编译器和处理器在不改变单线程执行结果的前提下,会对指令的执行顺序进行调整,这被称为“指令重排序”。
在上述三个步骤中,步骤 2 和步骤 3 之间可能发生重排序。
正常顺序 (1 -> 2 -> 3)
- 系统分配内存。
- 系统初始化对象。
instance指向内存。此时对象已经准备好了,其他线程拿到的就是完整对象。
异常顺序 (1 -> 3 -> 2)
这是重排序可能产生的情况。
- 系统分配内存。
instance指向内存(引用变得不为 null)。- 系统初始化对象。
当执行 1 -> 3 -> 2 顺序时,instance 引用已经不为 null,但对象内部的成员变量可能还没有被初始化。此时如果另一个线程介入,就会发生严重错误。
查看以下时序图,了解没有 volatile 时线程间的交互细节。
但对象未初始化 T2->>Mem: 第一次检查 instance == null? Mem-->>T2: false (引用不为空) T2->>T2: 返回 instance 引用 Note over T2: 线程 B 拿到不完整的对象
程序可能报错或行为异常 T1->>Mem: 2. 初始化对象
如上图所示,如果线程 A 发生了指令重排(先执行了步骤 3),线程 B 恰好在步骤 3 之后、步骤 2 之前调用了 getInstance()。线程 B 发现 instance 不为 null,于是直接返回了引用。但此时线程 B 拿到的是一个“半成品”对象,它未被正确初始化。
4. 解决可见性与原子性隐患
除了防止指令重排序,volatile 还解决了另一个问题:可见性。
可见性问题
在 Java 内存模型中,每个线程都有自己的工作内存(缓存),而不是直接操作主内存。
- 如果没有
volatile,线程 A 修改了instance的值,可能只是更新了自己工作内存中的副本。 - 线程 B 随后去读取
instance时,可能读取的依然是主内存中的旧值(即 null)。 - 这会导致线程 B 以为实例还没创建,从而再次进入同步块创建实例,最终产生多个实例,破坏单例模式。
volatile 的作用
volatile 关键字通过插入内存屏障来强制执行两个规则:
- 禁止指令重排序:确保“初始化对象”步骤发生在“引用赋值”步骤之前,即保证 1 -> 2 -> 3 的顺序。
- 保证可见性:一旦线程 A 修改了
volatile变量,该值会立即刷新到主内存。当其他线程读取该变量时,会强制从主内存重新读取,确保拿到最新的值。
5. 确认最终实现方案
为了彻底规避上述风险,编写代码时必须严格遵循以下两点:
- 声明单例引用变量时,添加
volatile修饰符。 - 保留双重检查锁结构,以兼顾线程安全与性能。
最终的正确代码如下:
public class Singleton {
// 关键点:使用 volatile 修饰
private static volatile Singleton instance;
private Singleton() {
// 私有构造函数
}
public static Singleton getInstance() {
// 第1次检查
if (instance == null) {
synchronized (Singleton.class) {
// 第2次检查
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
只有加上了 volatile,这段代码才能在多线程高并发环境下,既保证线程安全,又保证高性能,同时确保获取到的对象一定是完整初始化过的。

暂无评论,快来抢沙发吧!