news 2026/7/31 18:14:39

DCL 单例为何要 `volatile`:一次半初始化对象引发的血案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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后访问它的字段,可能读到默认值(0null),甚至直接 NPE。

问题就出在那个"关键的一行":instance = new Singleton();。它看起来是一步,其实不是。


二、根因/底层:new一个对象根本不是原子操作

2.1instance = new Singleton()的三步

instance = new Singleton()这行代码,编译成字节码后,大致对应三个步骤

  1. 分配内存:给Singleton对象分配一块内存空间;
  2. 初始化对象:执行构造函数,把这块内存初始化成一个真正的Singleton(字段赋值等);
  3. 指向引用:把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 步,就可能被调换成:

  1. 分配内存;
  2. 指向引用(此时instance已经不为null,但对象还没初始化完!);
  3. 初始化对象。

在单线程里,这么排完全没问题——反正等你用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.INSTANCEHolder才被初始化。加载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,别的线程就可能拿到"半个对象"。想省心,直接用静态内部类或枚举。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/31 18:13:51

大语言模型选型与实战指南:从GPT到开源部署全解析

宝玉分享当前主力模型使用心得&#xff1a;从选型到实战的完整指南在AI技术快速发展的今天&#xff0c;选择合适的模型并高效应用已成为开发者必备技能。本文基于实际项目经验&#xff0c;系统梳理主流模型的特点、适用场景及实战技巧&#xff0c;涵盖从基础概念到生产环境部署…

作者头像 李华
网站建设 2026/7/31 18:08:07

代码洁癖的工程价值重估:规范、工具与文化如何驱动前端质量

代码洁癖的工程价值重估&#xff1a;规范、工具与文化如何驱动前端质量 一、"代码洁癖"不是性格&#xff0c;是工程选择 "代码洁癖"这个词在技术圈常被用来形容那些对代码格式、命名规范、文件结构极度敏感的开发者。它有时带有一点调侃意味——暗示对细…

作者头像 李华
网站建设 2026/7/31 18:07:55

算法与数据结构知识体系的完整拼图:7 月学习成果全景图

算法与数据结构知识体系的完整拼图&#xff1a;7 月学习成果全景图 一、深度引言与场景痛点&#xff1a;学了很多但不知道整体掌握了多少 7 月结束&#xff0c;我在 LeetCode 上完成了约 200 道题目的训练。但有个问题始终困扰着我&#xff1a;我不知道自己到底覆盖了多少算法…

作者头像 李华