不加volatile的dcl单例在高并发下可能返回半初始化对象,因jvm将instance = new singleton()拆解为分配内存、构造初始化、赋引用三步,且允许后两步重排为1→3→2,导致其他线程读到未完成初始化的引用。

不加 volatile 的双重检查锁(DCL)单例,在高并发下可能返回一个“半初始化”的对象——内存已分配、引用已发布,但构造函数还没执行完。这不是代码写错了,而是 JVM 和 CPU 在优化时绕过了我们的直觉,问题核心在于指令重排与内存可见性缺失。
为什么 new Singleton() 会出问题
看似简单的一行 instance = new Singleton(),底层实际分三步:
- 分配对象内存(
allocate()) - 调用构造函数初始化字段(
ctorInstance()) - 将引用赋值给静态变量(
instance = memory)
在单线程中,JVM 允许第 3 步提前到第 2 步之前(即 1→3→2),只要最终结果符合语义。但多线程下,这就成了隐患:线程 A 执行完第 1 步和第 3 步后,instance != null;线程 B 看到非空引用,直接返回,却拿到一个字段全为默认值(0、null、false)的对象。
synchronized 锁不住重排的原因
synchronized 能保证临界区的原子性和可见性,但它只约束加锁后的代码块边界,不干预锁内语句的内部执行顺序。也就是说,它无法阻止 new Singleton() 这一句里构造函数和引用赋值之间的重排。锁能挡住“谁进来”,挡不住“里面怎么跑”。
volatile 是怎么靠内存屏障修复的
volatile 不是魔法,它通过插入特定的内存屏障(Memory Barrier)起作用:
- 写
instance时,插入 StoreStore 和 StoreLoad 屏障,禁止构造函数(写字段)被重排到引用赋值之后 - 读
instance时,插入 LoadLoad 和 LoadStore 屏障,确保读到的是主内存最新值,且后续读操作不会被提前 - 这些屏障建立了“构造完成 → 引用发布”的 happens-before 关系,把两个动作牢牢绑定
换句话说,volatile 把程序员期望的逻辑顺序,变成了 JVM 和 CPU 必须遵守的执行契约。
事故反思:别把“看起来安全”当真
这个事故暴露了一个常见误区:认为加了锁就万事大吉。实际上,并发安全是分层的——synchronized 解决竞态,volatile 解决重排与可见性。两者职责不同,不可替代。真实系统中,类似场景还出现在状态标志位(如 isRunning)、资源初始化完成标记等地方,只要涉及“先设标志,再初始化”,就该警惕重排风险。











