instance = new Singleton()不是原子操作,实际分为分配内存、初始化对象、赋值引用三步,其中后两步可能被重排序为先赋值后初始化,导致其他线程获取到未完全初始化的对象;volatile通过内存屏障禁止该重排序,确保引用赋值严格发生在初始化完成之后。

Double-Checked Locking 中的 instance = new Singleton() 不是原子操作
很多人误以为 instance = new Singleton() 是一条不可分割的指令,其实它在 JVM 层面至少拆成三步:分配内存(allocate)、调用构造器初始化对象()、将引用赋值给静态变量(instance = memory)。这三步之间没有数据依赖,编译器或 CPU 可能重排为「先赋值、后初始化」——即 instance 已非 null,但对象内部字段仍是默认值(如 int 为 0、Object 为 null)。
此时另一个线程执行第一次判空 if (instance == null) 就会跳过同步块,直接返回这个「半初始化」的对象,后续调用其方法大概率触发 NullPointerException 或逻辑错乱。
volatile 如何堵住重排漏洞
volatile 的关键作用不是让读写变“原子”,而是插入内存屏障(Memory Barrier),强制约束编译器和处理器对 volatile 变量前后指令的重排范围:
- 写 volatile 变量前的所有普通读写,不能被重排到该写之后
- 读 volatile 变量后的所有普通读写,不能被重排到该读之前
- 对
instance加volatile后,JVM 保证instance = new Singleton()的三步中,赋值(第 3 步)必须严格发生在初始化(第 2 步)完成之后
换句话说:volatile 不是让构造过程变原子,而是让「引用可见」这件事,与「对象真正就绪」这件事,在多线程视角下保持因果顺序。
为什么 synchronized 本身拦不住这个重排
synchronized 能保证临界区互斥和内存可见性(退出时刷新主存),但它不禁止临界区内的指令重排。也就是说,即使 instance = new Singleton() 写在 synchronized 块里,JVM 仍可能把「赋值」提到「构造完成」前——只要这个重排不破坏单线程语义。
而 DCL 的问题恰恰出在「同步块外的第一次判空」:那个读取 instance 的线程根本没进锁,也就享受不到 synchronized 的内存语义保障。只有 volatile 才能让这个外部读操作,也能看到符合预期的写入顺序。
localResource 模式为何更安全(但非必需)
有些实现(如 SonarQube 推荐的)会引入局部变量做二次缓存:
Resource localResource = resource;
if (localResource == null) {
synchronized (this) {
localResource = resource;
if (localResource == null) {
resource = localResource = new Resource();
}
}
}
return localResource;
这种写法本质是减少一次 volatile 读(第二次读用的是局部变量),但核心安全仍依赖 resource 的 volatile 修饰。不加 volatile,哪怕加了 localResource,照样可能读到未初始化对象。
真正容易被忽略的点是:volatile 修饰的是引用本身,不是对象内容;它只约束该变量的读写顺序,不递归保护对象内部字段的可见性——如果单例内部有非 volatile 字段且依赖延迟初始化,仍需额外同步。










