DCL 单例为何要 `volatile`:一次半初始化对象引发的血案
前言
双重检查锁定(Double-Checked Locking,简称 DCL)是最经典的单例写法之一。很多人背得滚瓜烂熟,却对其中一个细节含糊其辞:
privatestaticvolatileSingletoninstance;// ^^^^^^^^ 这个 volatile 到底能不能省?网上一搜,答案清一色是"必须加"。可要是追问一句"为什么必须加、不加会怎样",能说清楚的人就不多了。更麻烦的是——不加volatile的 DCL,绝大多数时候跑起来一点问题没有,测试也过,于是很多人真就把它省了,直到某天线上偶发一个诡异的 NPE 或对象状态异常,查到天亮也复现不出来。
这篇文章讲清楚:DCL 里那个volatile到底在防什么,不加它会发生什么,以及为什么这个 bug 极难复现。
环境说明:本文基于 JDK 8。相关的内存可见性、指令重排、happens-before 概念,可参考上一篇《volatile 到底保证了什么》。
一、先复现:一个"看起来完全正确"的 DCL
先看这段几乎人人都写过的单例:
publicclassSingleton{// 注意:这里故意没加 volatileprivatestaticSingletoninstance;privateSingleton(){// 假设构造函数里要做一些初始化工作}publicstaticSingletongetInstance(){if(instance==null){// 第一次检查(无锁)synchronized(Singleton.class){if(instance==null){// 第二次检查(持锁)instance=newSingleton();// 关键的一行}}}returninstance;}}这段代码的逻辑看着无懈可击:
- 第一次
if判空,避免每次都加锁(性能); - 加锁后再判空一次,防止多个线程都通过了第一次检查、重复创建对象(正确性)。
单线程、低并发下,它跑一万次都不会出错。但在高并发下,getInstance()有极小的概率返回一个**"还没构造完"的对象**——调用方拿到instance后访问它的字段,可能读到默认值(0、null),甚至直接 NPE。
问题就出在那个"关键的一行":instance = new Singleton();。它看起来是一步,其实不是。
二、根因/底层:new一个对象根本不是原子操作
2.1instance = new Singleton()的三步
instance = new Singleton()这行代码,编译成字节码后,大致对应三个步骤:
- 分配内存:给
Singleton对象分配一块内存空间; - 初始化对象:执行构造函数,把这块内存初始化成一个真正的
Singleton(字段赋值等); - 指向引用:把
instance引用指向这块内存地址。
在单线程里,这三步无论怎么排,结果都一样,没人看得出区别。
不信可以看字节码。把instance = new Singleton()用javap -c反编译,核心是这几条指令:
0: new #2 // ① 分配内存,得到一个未初始化的对象引用 3: dup // 复制引用(留一份给构造函数调用) 4: invokespecial #3 // ② 调用 <init> 构造函数,真正初始化对象 7: putstatic #4 // ③ 把引用赋值给静态字段 instance清清楚楚三步:new(分配内存)、invokespecial(执行构造函数)、putstatic(赋值给引用)。它们是三条独立的字节码指令,而不是一条原子操作——这就是"重排"有机可乘的物理基础。JVM 只要保证单线程下最终结果正确,就允许调整invokespecial(②)和putstatic(③)的先后。
2.2 指令重排:2 和 3 可能被调换
问题来了:为了优化性能,编译器和 CPU 允许在不影响单线程结果的前提下对指令重排序。上面的第 2 步和第 3 步,就可能被调换成:
- 分配内存;
- 指向引用(此时
instance已经不为null,但对象还没初始化完!); - 初始化对象。
在单线程里,这么排完全没问题——反正等你用instance的时候,三步早就都做完了。但在多线程下,这个"中间状态"会被别的线程看见。
2.3 血案发生:另一个线程读到"半初始化对象"
设想这样的时序,instance未加volatile,且发生了 2、3 重排:
- 线程 A进入
synchronized,执行instance = new Singleton()。由于重排,它先做了「分配内存 + 指向引用」,此刻instance != null,但构造函数还没执行完; - 就在这个空档,线程 B调用
getInstance(),走到第一次检查if (instance == null)。因为 A 已经让instance指向了内存,B 看到instance != null,于是跳过加锁,直接return instance; - 线程 B 拿到的,是一个还没初始化完的半成品对象。它去访问对象的字段,读到的是默认值,或者触发 NPE。
这就是所谓的**“半初始化对象”(partially constructed object)**问题。注意关键点:线程 B 是在第一次检查那里翻的车,它根本没进synchronized,锁救不了它。
2.4 为什么synchronized挡不住
有人会问:不是加了synchronized吗,锁不是能保证可见性和有序性吗?
能,但只对进入了同步块的线程有效。线程 B 在第一次检查(synchronized外面)就读到了instance != null并直接返回,它压根没参与竞争这把锁,synchronized的有序性保证对它不生效。
换句话说,DCL 的性能优势(第一次检查无锁)恰恰是它的隐患来源:有一条读取路径是绕过锁的,而这条路径上,指令重排产生的中间状态毫无防护。
严谨一点说,synchronized提供的有序性来自这条 happens-before 规则:对一个锁的解锁,happens-before 于后续对同一个锁的加锁。也就是说,只有当线程 B也去竞争同一把锁时,它才能"继承"到线程 A 释放锁之前的所有写操作(包括构造完成的对象)。可线程 B 在第一次检查根本没加锁,这条 happens-before 链就断了——A 的构造动作和 B 的读取之间不存在任何顺序保证,B 自然可能看到重排后的中间态。这正是"锁救不了它"的本质。
三、正解:volatile禁止重排,堵住中间状态
解决办法就是给instance加上volatile:
publicclassSingleton{privatestaticvolatileSingletoninstance;// 加上 volatileprivateSingleton(){}publicstaticSingletongetInstance(){if(instance==null){synchronized(Singleton.class){if(instance==null){instance=newSingleton();}}}returninstance;}}volatile在这里起的作用不是可见性,而是有序性:
- 它通过内存屏障禁止「初始化对象」和「指向引用」这两步被重排。也就是保证——只有当对象完全构造好之后,
instance才会指向它; - 这样一来,任何线程只要看到
instance != null,就说明对象一定已经初始化完毕,不可能再读到半成品。
顺带地,volatile的可见性也保证了 A 线程构造好的对象能立即对 B 线程可见。但核心是禁止重排——这正是上一篇里说的"volatile保证有序性"在实战中最重要的应用场景。
一段历史:JDK 5 之前,加了volatile也没用
这里有个容易被忽略的冷知识:DCL 是在 JDK 5 之后,加volatile才真正有效的。
JDK 5 之前(JDK 1.4 及更早),旧的 Java 内存模型对volatile的定义有缺陷:它虽然保证了volatile变量本身的可见性,但并不禁止volatile写操作与其前面的普通写操作之间的重排。换句话说,即使给instance加了volatile,“初始化对象”(普通写)仍可能被重排到"instance赋值"(volatile 写)之后,半初始化问题照样存在。所以那个年代流传着"DCL 是坏的、根本修不好"的说法(著名的“The Double-Checked Locking is Broken” Declaration)。
JDK 5 引入了新的内存模型(JSR-133),强化了volatile的语义:禁止volatile写与其前面的读写重排、禁止volatile读与其后面的读写重排(通过 StoreStore、StoreLoad 等内存屏障实现)。从此,volatile写之前的所有操作(包括对象初始化)都不能被排到写之后,DCL 才终于被"修好"。
所以完整的结论是:在 JDK 5+ 上,加了volatile的 DCL 是正确且安全的;而在 JDK 5 之前,DCL 无论加不加volatile都有隐患。我们今天能放心用,靠的是 JSR-133 对volatile的加强。
更推荐的两种写法
DCL 能用,但它心智负担重、容易写错(漏掉volatile)。实际开发中,更推荐下面两种更简洁、天然线程安全的单例:
静态内部类(推荐,懒加载 + 无锁)
publicclassSingleton{privateSingleton(){}privatestaticclassHolder{privatestaticfinalSingletonINSTANCE=newSingleton();}publicstaticSingletongetInstance(){returnHolder.INSTANCE;}}利用 JVM 的类加载机制:Holder类只有在第一次调用getInstance()时才被加载,而类的初始化过程由 JVM 保证线程安全,既实现了懒加载,又完全不用自己操心重排和可见性。
它为什么天然安全,值得多说一句。JVM 规范规定:一个类的初始化(执行<clinit>,即静态变量赋值和静态块)只会执行一次,且这个过程由 JVM 用一把"初始化锁"保证同步。多个线程同时首次访问Holder.INSTANCE时,只有一个线程能执行初始化,其余线程会阻塞等待,直到初始化完成——这套机制是 JVM 底层实现的,比我们手写 DCL 更可靠,也没有半初始化的窗口。
同时它又是懒加载的:Holder是内部类,类加载是按需触发的,只有真正用到Holder.INSTANCE时Holder才被初始化。加载Singleton外部类并不会连带加载Holder,所以实例不会在类加载时就被创建。一句话:用 JVM 的类初始化锁,替我们做了 DCL 想做的事,还做得更好。
枚举(最简洁,天然防反射和序列化破坏)
publicenumSingleton{INSTANCE;publicvoiddoSomething(){/* ... */}}《Effective Java》推荐的写法,枚举实例由 JVM 保证全局唯一,还能天然抵御反射和反序列化攻击。
四、常见误区与面试高频问答
Q:不加volatile的 DCL,一定会出错吗?
不一定,而且大多数时候不出错——这正是它最坑的地方。指令重排是否发生、半初始化窗口是否恰好被另一个线程撞上,都是概率事件,取决于 JIT 编译、CPU 架构、并发压力。低并发下你可能永远碰不到,一旦上线高并发就偶发。这种"测不出、偶现、难复现"的 bug 最要命,所以规范里直接要求必须加。
Q:这里的volatile是为了可见性还是有序性?
主要是有序性(禁止 2、3 步重排,杜绝半初始化对象)。可见性是附带的保证。很多人答成"为了可见性",不算全对。
Q:为什么加了synchronized还不够?
因为 DCL 的第一次检查在锁外面。绕过锁的读取路径读到了重排产生的中间状态,而synchronized的有序性只对进入同步块的线程有效,管不到这条无锁路径。
Q:静态内部类为什么线程安全,还能懒加载?
JVM 保证一个类的初始化(<clinit>)只会被执行一次,且是线程安全的(虚拟机内部加锁)。Holder类直到第一次getInstance()被调用才加载初始化,所以既懒加载又线程安全,且没有 DCL 的重排隐患,是更省心的写法。
Q:DCL 是不是就没用了、被淘汰了?
也不是。理解 DCL 对理解并发和内存模型很有价值,某些需要"延迟初始化实例字段"(而非整个单例类)的场景仍会用到 DCL。只是就"实现单例"这个具体需求而言,静态内部类和枚举通常是更优解。
Q:new Singleton()到底是哪两步被重排了?
字节码上是invokespecial(执行构造函数,②)和putstatic(把引用赋给instance,③)。JVM 允许把③排到②前面,于是出现"引用已赋值、对象没构造完"的中间态。volatile通过内存屏障禁止这个重排,保证②一定先于③。
Q:为什么说 JDK 5 是 DCL 的分水岭?
JDK 5 之前的旧内存模型里,volatile不禁止它前面的普通写与 volatile 写重排,所以对象初始化仍可能被排到引用赋值之后,加了volatile也修不好 DCL。JDK 5 的 JSR-133 强化了volatile语义(禁止这类重排),DCL 才真正可用。所以"DCL 必须加 volatile"这个结论,只在 JDK 5+ 成立。
Q:单例还要考虑什么?反射和序列化会破坏单例吗?
会。反射能通过setAccessible(true)调用私有构造函数造出第二个实例;反序列化默认也会new一个新对象。DCL 和静态内部类都需要额外防护(构造函数里判空抛异常、实现readResolve())。而枚举天生免疫这两种攻击——这也是《Effective Java》推崇枚举单例的重要原因。
总结
DCL 单例里的volatile,不是可有可无的装饰:
instance = new Singleton()不是原子操作,它分「分配内存 → 初始化对象 → 指向引用」三步,其中后两步可能被指令重排。- 重排后,
instance可能先指向了一块还没初始化完的内存。此时另一个线程在 DCL 的第一次检查(锁外)看到instance != null,直接返回了这个半初始化对象,导致读到默认值或 NPE。 volatile通过内存屏障禁止这两步重排,保证"对象构造完成"先于"引用赋值",堵住中间状态。这是它的有序性保证在实战中的关键应用。- 注意历史背景:这个结论只在 JDK 5+ 成立。JDK 5 之前旧内存模型下的
volatile语义太弱,DCL 加不加volatile都有隐患,是 JSR-133 强化volatile后才真正修好的。 - 更省心的替代方案是静态内部类(借 JVM 的类初始化锁,懒加载 + 线程安全)和枚举(最简洁,还天然防反射和反序列化破坏)。
一句话记忆:new对象不是一步,DCL 的第一次检查又在锁外——不加volatile,别的线程就可能拿到"半个对象"。想省心,直接用静态内部类或枚举。
