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(instancenull){// 第一次检查无锁synchronized(Singleton.class){if(instancenull){// 第二次检查持锁instancenewSingleton();// 关键的一行}}}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加上volatilepublicclassSingleton{privatestaticvolatileSingletoninstance;// 加上 volatileprivateSingleton(){}publicstaticSingletongetInstance(){if(instancenull){synchronized(Singleton.class){if(instancenull){instancenewSingleton();}}}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{privatestaticfinalSingletonINSTANCEnewSingleton();}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 的重排隐患是更省心的写法。QDCL 是不是就没用了、被淘汰了也不是。理解 DCL 对理解并发和内存模型很有价值某些需要延迟初始化实例字段而非整个单例类的场景仍会用到 DCL。只是就实现单例这个具体需求而言静态内部类和枚举通常是更优解。Qnew 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别的线程就可能拿到半个对象。想省心直接用静态内部类或枚举。