双重检查锁单例不加volatile会拿到半初始化对象,因JVM可能将new的三步重排序,导致其他线程看到未初始化完成的instance;volatile禁止写重排序并保证happens-before语义。

双重检查锁单例不加 volatile 会拿到半初始化对象
是的,不加 volatile 时,JVM 可能将 new Singleton() 的三步操作(分配内存、初始化对象、赋值给静态引用)重排序,导致其他线程看到未完成初始化的 instance。这不是理论风险,而是真实可复现的问题,在多核 CPU + JIT 优化后尤其明显。
关键在于:new 不是原子操作;JVM 允许把“赋值”提前到“初始化”之前——只要不改变单线程语义。但多线程下,这就让别的线程跳过 synchronized 块,直接返回一个 instance != null 却字段全为默认值(0、null、false)的对象。
volatile 如何阻止重排序
volatile 在这里起两个作用:禁止写操作重排序(防止 instance = new Singleton() 被拆成“先赋值后构造”),同时保证其他线程读取 instance 时能看到其所有被正确初始化的字段值(happens-before 语义)。
它不是锁,也不保证原子性,但它给 JVM 下了明确指令:这个变量的读写必须按源码顺序执行,且每次读都从主内存取,每次写都立即刷回主内存。
立即学习“Java免费学习笔记(深入)”;
-
volatile修饰的是引用本身,不是对象内部字段,所以对象构造必须在赋值前完成 - 没有
volatile,即使synchronized块内完成了初始化,块外的读仍可能看到旧值或中间态 - JDK 5+ 才正式支持
volatile的这种语义;JDK 4 及以前无法可靠实现双重检查锁
常见错误写法与现象
最典型错误就是只加 synchronized,漏掉 volatile。运行时往往不报错,但偶发 NullPointerException 或字段为默认值,尤其在高并发、对象有复杂初始化逻辑(如依赖注入、资源加载)时更容易暴露。
例如,假设 Singleton 有个 String config 字段在构造器中赋值:
public class Singleton {
private static Singleton instance;
private final String config = loadConfig(); // 可能耗时、可能抛异常
<pre class='brush:java;toolbar:false;'>private Singleton() { /* ... */ }
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // ← 这里可能被重排序
}
}
}
return instance;
}}
不加 volatile 时,另一个线程可能在 instance != null 后立刻读取 config,结果得到 null(因为构造器还没执行完)。
为什么不用 synchronized 整个方法或用静态内部类
双重检查锁本意是兼顾懒加载和性能,但一旦漏掉 volatile,就失去安全性,反而比其他方案更危险——因为它看起来“已经加锁了”,容易让人误以为万无一失。
- 直接
synchronized静态方法:安全但每次调用都同步,性能差 - 静态内部类方式(
Holder模式):天然线程安全、懒加载、无需volatile,推荐替代方案 - 枚举单例:最简、最安全,但无法延后初始化(类加载即实例化)
真正需要双重检查锁的场景极少,除非你确定要控制初始化时机,又对性能极度敏感,且愿意承担写对 volatile 的责任。
最容易被忽略的点是:哪怕只改一行代码(加 volatile),也必须确认目标 JDK 版本 ≥ 5,且不能把它当成“可选优化”。它是该模式成立的必要条件,不是锦上添花。










