dcl需两次判空:第一次在锁外提升性能,避免重复加锁;第二次在锁内防止多线程重复初始化;volatile则禁止指令重排,确保对象完全初始化后才对其他线程可见。

Double-Checked Locking 为什么需要两次判空
因为 getInstance() 要兼顾性能和线程安全:第一次判空在锁外,避免每次调用都进同步块;第二次判空在锁内,防止多个线程同时通过第一层检查后重复初始化。
但问题出在「对象构造本身不是原子操作」——new Singleton() 实际包含三步:
1. 分配内存空间
2. 调用构造函数初始化字段
3. 将引用赋值给静态变量 uniqueSingleton
JVM 和 CPU 可能对 2 和 3 步重排序(只要不影响单线程语义),导致第 3 步先执行、uniqueSingleton 非 null,但对象尚未完成初始化。此时另一个线程跳过锁、直接返回这个半初始化对象,后续调用就可能触发 NullPointerException 或字段为默认值。
volatile 如何堵住指令重排漏洞
把 uniqueSingleton 声明为 volatile,核心作用不是“让读写变慢”,而是插入内存屏障:
-
volatile写操作(即uniqueSingleton = new Singleton()的赋值)后插入写屏障(sfence):确保构造函数内所有字段初始化(步骤 2)一定在赋值(步骤 3)之前完成,禁止重排 -
volatile读操作(即if (uniqueSingleton == null))前插入读屏障(lfence):保证线程看到的uniqueSingleton是主内存最新值,且之后读到的字段也都是已初始化后的值
注意:这里 volatile 不提供原子性(比如 i++ 还是不行),它只约束「对该变量的读写」与「其前后其他内存操作」的顺序关系。
为什么 synchronized 不能替代 volatile 在 DCL 中
synchronized 确实能保证临界区内的原子性和可见性,但它无法约束锁外的指令重排。
假设去掉 volatile,仅靠 synchronized 包裹第二次判空和初始化:
- 线程 A 进入同步块,执行
uniqueSingleton = new Singleton() - 由于没有
volatile,JVM 仍可能把「构造函数初始化字段」和「引用赋值」重排序 - 线程 B 在锁外第一次判空时,恰好看到非 null 的
uniqueSingleton,就直接返回——而此时对象可能还没初始化完
也就是说,synchronized 只保护了「进入同步块那一刻起」的可见性,不保护「同步块外对共享变量的读」所依赖的初始化完整性。
实际编码中容易漏掉的关键点
很多人写了 DCL 却忘了加 volatile,或者加错位置:
- 必须修饰静态引用本身,如
private static volatile Singleton uniqueSingleton;修饰构造函数或方法没用 - 不能只靠
volatile去掉synchronized——没有锁,第二次判空就失去互斥保障,仍会创建多个实例 - Java 5+ 才完全支持
volatile的内存屏障语义;早期 JDK(如 1.4)下 DCL 即使加volatile也无效
最常被忽略的是:DCL 的安全性是 volatile 和 synchronized 协同的结果,缺一不可;而 volatile 在其中专责封住那个「看似无害却致命」的指令重排缝隙。










