Double-Checked Locking 中 INSTANCE = new Singleton() 实际分三步:①分配内存;②调用构造函数初始化;③赋值引用;无 volatile 时②③可能重排序,导致其他线程获取半初始化对象而崩溃;volatile 通过禁止重排序和保证可见性修复该问题,且仅 JDK 5+ 有效。

Double-Checked Locking 中 INSTANCE = new Singleton() 实际做了三件事
这行代码看似原子,其实 JVM 会拆成:①分配内存;②调用构造函数初始化对象;③把对象引用赋值给 INSTANCE。在没有 volatile 修饰时,JVM 和 CPU 可能对②③重排序——先完成③(INSTANCE 指向一块已分配但未初始化的内存),再执行②。此时另一个线程刚好进入外层 if(INSTANCE == null) 判断,发现不为 null,直接返回这个“半初始化”对象,后续调用其方法大概率触发 NullPointerException 或逻辑错乱。
为什么加 volatile 就能破指令重排
volatile 对 INSTANCE 字段施加两个关键约束:一是写操作后立即刷回主内存(可见性),二是禁止该变量的读写与前后普通读写发生重排序(有序性)。重点在后者——它强制把“构造函数执行完毕”作为“赋值给 INSTANCE”的先行发生(happens-before)条件,从而堵住②③重排的漏洞。
- JDK 5+ 才真正支持
volatile的禁止重排语义,JDK 4 及以前无效 - 仅对引用本身加
volatile即可,不需要对整个类或构造函数做额外处理 - 不能只靠
synchronized解决:锁只保证临界区互斥,不控制构造过程内部的指令顺序
对比饿汉式和 DCL 的线程安全根源差异
饿汉式靠的是 JVM 类初始化机制:类第一次被主动使用时,由 ClassLoader 线程同步地执行 static 字段初始化,天然串行且不可重排。DCL 则是手动在运行期构建同步边界,必须自己补足可见性(volatile)和有序性(禁止重排)两块拼图,缺一不可。
- 饿汉式无延迟加载,实例在类加载阶段就创建,哪怕永远不调用
getInstance() - DCL 延迟到首次调用才初始化,但必须同时写对
volatile+ 双重检查 +synchronized三要素 - 漏掉
volatile是最常见错误,现象往往是偶发性崩溃,压测才暴露
一个典型误写和修正对照
错误写法(看似有锁,实则危险):
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
if (INSTANCE == null) {
synchronized (Singleton.class) {
if (INSTANCE == null) {
INSTANCE = new Singleton(); // 缺少 volatile,重排风险仍在
}
}
}
return INSTANCE;
}
正确写法(仅改一行):
private static volatile Singleton INSTANCE = null; // 关键:volatile 不能少
public static Singleton getInstance() {
if (INSTANCE == null) {
synchronized (Singleton.class) {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
真正难的不是写出来,而是理解为什么必须是这三者共存——synchronized 控制竞态入口,volatile 锁定赋值动作的语义边界,双重检查则是性能妥协下的必要判断。少任何一个,都可能在高并发下悄然失效。










