☰
双重检查锁失效真相:volatile如何避免单例半初始化
2026/10/5 4:09:44 网站建设 项目流程

写多线程代码的人,迟早都会遇到单例模式。尤其是懒汉式单例配合双重检查锁(Double-Checked Locking,简称 DCL)的写法,几乎每次 Java 并发面试都会被翻出来拷问一遍。可说实话,真正能在生产环境里一次写对的人,我遇到过的不多。很多人不是记不住那几行代码,而是始终没有想明白一件事:为什么一个看似有 synchronized 把关的线程安全单例模式,在某些并发场景下还是会出现“半初始化”对象。答案就藏在标题里的四个字:指令重排。

这篇文章不打算从教科书定义开始念,而是直接拆一条完整的思考链:从单例模式为什么会线程不安全,到双重检查锁失效的现场还原,再到 volatile 如何破解指令重排,最后补上 AtomicInteger、Fragment 单例和 onCreateView binding 这些容易踩坑的实际场景。无论你是在准备面试,还是正在维护一个老项目里的单例代码,按这条线走完,基本能把“线程安全单例”这件事彻底吃透。

1. 从一个“看似正确”的单例开始

1.1 懒汉式单例的第一版:并发一上来就崩

先看最原始的问题起点。绝大多数人第一次写单例,写的都是饿汉式:类加载的时候就把实例 new 出来,反正只有一个类加载过程,天然线程安全。这种写法简单可靠,但有个不算缺点的缺点——如果你的项目里有些单例对象创建成本很高,而且可能整个进程跑完都用不上,饿汉式就会白付这份初始化开销。

于是懒汉式就来了:需要的时候再创建,代码长这样:

public class Singleton { private static Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; } }

单线程环境下这段代码没有任何问题,但放到多线程环境里,第一轮并发测试就能让你看到事故现场。线程 A 和线程 B 同时进入 getInstance,两个线程都读到 instance 为 null,然后各自执行 new Singleton(),返回给调用方的却是两个不同的对象。单例的含义就是“整个进程里只有一个实例”,这一崩,语义直接没了。

更隐蔽的情况是:两个线程同时读 null,但 new 的过程需要时间,线程 A 还没把对象构造完,线程 B 又进来 new 了一次,最后后写入的那个实例覆盖了先写入的。看起来只是多建了一个对象,但如果你在这个单例里挂了一些全局状态、数据库连接池、缓存容器,多出来的那个实例就会带来状态不一致、资源没释放、甚至死锁之类的问题。

1.2 给 getInstance 加一把大锁:安全但“贵”

第一反应很简单:给整个方法加 synchronized,直接让 getInstance 变成串行方法。

public static synchronized Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; }

这次线程安全是没问题了,可我几乎每次都要跟人强调:synchronized 修饰在方法上,锁的是整个 Singleton 类对象,所有调用 getInstance 的线程都得排队。如果你的单例被高频访问,比如在 Web 服务里每次请求都要拿一次配置管理器,这个锁就成了实实在在的热点。

有人会说,现在 JVM 对无竞争锁做了很多优化,偏向锁、轻量级锁,性能没那么差。这话在低竞争场景下是成立的,可一旦并发线程稍微多起来,锁竞争一激烈,偏向锁会被撤销,轻量级锁会膨胀成重量级锁,线程阻塞唤醒的开销立刻体现出来。更麻烦的是,很多单例对象在创建完之后根本没有修改需求,后面的每次调用都只是读,但 synchronized 方法不管你读还是写,一律互斥。用一把大锁去保护“只在第一次需要保护的初始化”,从设计上看就是浪费。

1.3 先判空再锁:双重检查锁的雏形

既然锁的真正价值只在第一次初始化,那理性的做法就是把“判空”放在锁外面,能不进临界区就不进。于是双重检查锁的雏形出现了:

public static Singleton getInstance() { if (instance == null) { // 第一次检查,不加锁 synchronized (Singleton.class) { if (instance == null) { // 第二次检查,加锁 instance = new Singleton(); } } } return instance; }

第一次检查用来过滤掉“对象已经创建好了”的大多数请求,第二次检查用来处理“两个线程同时通过第一次检查”的竞争情况。代码写到这里,逻辑上是自洽的,很多人的并发理解也就停在了这一层:有锁、有判空、看起来该堵的洞都堵上了。

但真正的坑恰恰藏在这个看似完美的版本里。如果这段代码运行在 JDK 1.5 之前的 JVM 上,或者你忘记用 volatile 修饰 instance,那你很快就会在某个奇怪的时间点发现:明明 instance 不是 null,拿回来用的时候,对象里的某些字段却是默认值,甚至直接抛 NullPointerException。这不是玄学,而是指令重排在背后捣鬼。

2. 双重检查锁失效的根源:指令重排

2.1 标准双重检查锁代码与它的“完美假象”

我先把最标准的双重检查锁代码原样摆出来,方便大家对号入座:

public class Singleton { private static Singleton instance; // 注意:没有 volatile private Singleton() {} public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }

这段代码在大多数情况下跑起来是正常的,所以它特别有迷惑性。低并发、小流量、测试环境,你可能压测很久都压不出问题。可一旦并发线程数上去,或者恰好在某个多核 CPU 的特定缓存拓扑下,你就会开始看到诡异的现象:某个线程拿到的 instance 不是 null,但它的成员变量还没有被正确初始化。

要理解这件事,先得放下“代码是按顺序一行一行执行”的直觉。现代处理器和 JIT 编译器为了提高指令流水线效率,会在不改变单线程语义的前提下,对指令的执行顺序进行调整,这就是指令重排。它不是一个 bug,而是一种性能手段,只是这种手段在多线程环境下会产生一个语言规范层面上的漏洞。

2.2 new Singleton() 背后的三步操作

在 Java 里写一行instance = new Singleton(),看着是一句话,实际上 JVM 要做三件事:

  1. 在堆上分配一块足够的内存空间;
  2. 调用构造方法,把这块内存里的对象完整初始化,包括给成员变量赋初值;
  3. 把这个对象的引用赋值给 instance 变量。

关键点在于,步骤 2 和步骤 3 之间没有天然的依赖关系。从 CPU 的角度看,它们操作的是不同内存区域——一个在对象内部,一个在栈上的引用变量里。硬件和编译器在不改变当前线程结果的前提下,完全可能把步骤 3 提前到步骤 2 之前执行。

用生活里的例子类比:你在组装一台电脑,先买好机箱(分配内存),然后把主板、CPU、内存条装进去(初始化),最后贴上标签放到发货架上(赋值引用)。如果店员图省事,先贴标签再装零件,外部顾客看到标签以为整机已经好了,结果打开箱子发现里面是空的。指令重排后的new Singleton()就是这个被提前贴上标签的机箱。

2.3 失效现场还原:一个“半初始化”的对象

现在把线程 A 和线程 B 放进同一个场景。

线程 A 第一个进入 getInstance,通过两次判空,进入 synchronized 块,开始执行instance = new Singleton()。假设执行到分配内存之后,指令重排把“赋值引用”提前了,instance 现在指向一块已经分配好、但还没有执行构造方法的内存地址。

此时线程 B 调用 getInstance。它不走锁里的逻辑,先执行第一次判空:instance 已经不是 null 了,于是直接return instance。线程 B 拿着这个“半初始化”的对象开始调用业务方法,而这个对象内部的字段还停留在默认值阶段,比如一个本应在构造方法里赋值的private int port = 8080;,现在读出来是 0,或者一个引用类型字段还是 null。运气差一点,B 线程直接抛 NullPointerException,这还算是有明显报错;运气不好,B 线程读到的是错误但非法的数据,带崩一整个模块,排查起来才真正让人崩溃。

这就是双重检查锁的“失效”——它不是锁失效,而是“不加锁的读路径”没有被内存屏障保护,导致读线程可以进行重排后的早期发布访问。

2.4 为什么 synchronized 都拦不住这次事故

很多人到这里会问:明明 synchronized 块里有锁,A 线程不也拿到了锁吗?锁不是有 happens-before 语义吗?说得对,synchronized 确实有 happens-before 规则:同一个锁对象,unlock 之前的写操作对后续 lock 的线程可见。但问题是,线程 B 走的是“第一次判空 → return”这条路径,它根本没有进入 synchronized 块。

没有进临界区,就没有获得锁,也就没有和线程 A 建立 happens-before 关系。在 JMM(Java 内存模型)的法律层面上,B 线程看到的 instance 值没有保证一定是 A 线程完全初始化之后的结果。

换一种更直白的说法:synchronized 只保护临界区内部的有序性和可见性,保护不了外面那个if (instance == null)的读操作。双重检查锁的整个思路建立在“先快速读,判断是否需要锁”之上,这个快速读如果没有额外的内存屏障约束,就会成为整个方案的破口。

3. 破解方案:volatile、类加载与并发原语

3.1 volatile 如何通过内存屏障破解重排

要堵住指令重排这个洞,Java 给出的标准答案是 volatile。在 JDK 1.5 之后,JMM 对 volatile 语义做了非常硬性的规定:对一个 volatile 变量的写操作,会在写之前插入 StoreStore 屏障,在写之后插入 StoreLoad 屏障;对 volatile 变量的读操作,会在读之后插入 LoadLoad 和 LoadStore 屏障。简单理解,就是 JVM 保证 volatile 变量的读写操作不会被重排到屏障的另一侧。

回到单例场景,把 instance 声明为 volatile 之后,instance = new Singleton()里的三步操作被强行约束成:分配内存 → 初始化对象 → 赋值给 instance。JVM 不允许把赋值动作提前到初始化完成之前,于是线程 B 再读 instance 时,看到的不再是“半初始化”的引用,它要么读到 null,要么读到完整构造完成的对象,不存在中间状态。

同时,volatile 还带一个附带收益:可见性。普通变量在多核 CPU 下可能只停留在各核心的本地缓存里,而 volatile 变量的写操作会立即刷新到主内存,读操作也会直接从主内存拉取最新值。在双重检查锁这个场景里,instance 的可见性一旦保证,“线程 B 看到旧值 null,于是进入锁重新创建一次”的问题也被一并解决了。

3.2 一份可直接落地的双重检查锁单例模板

踩过无数坑之后,我现在写双重检查锁几乎都是用固定模板:

public class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }

和失效版本只有一个单词的差别:在private static Singleton instance;前面加 volatile。这个单词就是整道防线。

还有个小的优化技巧,先讲清楚:如果你非常在意第一次判空时的性能,可以先把 instance 读到一个局部变量里,再用局部变量判空。比如:

public static Singleton getInstance() { Singleton result = instance; if (result == null) { synchronized (Singleton.class) { result = instance; if (result == null) { result = new Singleton(); instance = result; } } } return result; }

这个写法的好处是减少了一次直接访问 volatile 变量的操作。volatile 的读本身有一定的内存屏障成本,虽然不高,但在极端高并发的场景下,用局部变量接一下确实能降低主内存访问频率。这个优化属于锦上添花,不要把注意力全放在这上面,把 volatile 加对才是重点。

3.3 静态内部类与枚举:更优雅的线程安全单例

纠结完双重检查锁之后,很多人会问:为什么不用静态内部类?这确实是一种更干净的方案。利用 JVM 的类加载机制,把实例放在静态内部类的静态字段里:

public class Singleton { private Singleton() {} private static class Holder { private static final Singleton INSTANCE = new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; } }

JVM 在初始化 Holder 类时会加锁,并且这个锁只会在第一次初始化时存在,既能保证线程安全,又没有显式同步代码,性能非常好。它唯一的限制是构造函数里如果抛了异常,这个类就无法再初始化了,但绝大多数单例不关心这种极端情况。

枚举单例则更进一步:public enum Singleton { INSTANCE },JVM 从语言层面保证枚举实例只会被创建一次,还能天然抵御反射和序列化的破坏。可移动开发者经常听到“不要用枚举,包体积变大”这种说法,其实在 Android 上用枚举单例也不是不行,只是要清楚自己在做什么。

我的建议是:如果项目代码风格偏传统,用 volatile 双重检查锁完全没问题;如果追求简洁和防破坏,枚举单例更省心。两者没有绝对的对错,关键在于整个团队对并发模型的理解在同一条水平线上。

3.4 顺带说透:AtomicInteger 线程安全吗

聊到线程安全的并发原语,很多看过八股文的人会把 AtomicInteger 和“线程安全”直接画等号。这个判断太粗糙了。AtomicInteger 内部用 CAS + volatile,所以 incrementAndGet、decrementAndGet、compareAndSet 这些单个方法确实是原子的,线程安全。但如果你用 AtomicInteger 去组合一个“先检查后操作”的业务逻辑,比如:

if (counter.get() < 10) { counter.incrementAndGet(); }

这段复合操作就不是线程安全的。两个线程可能同时通过get() < 10的检查,然后各加一次,最终 counter 变成 11,而不是你期望的 10。这不是 AtomicInteger 不安全,而是复合操作的原子性需要更高的抽象层次来保证,比如使用synchronized、StampedLock,或者把逻辑封装到compareAndSet的自旋循环里。

搞懂这个区别很重要,因为它和双重检查锁是同一个方法论:并发安全不是靠某一两行代码的“灵光一现”,而是要看读、写、检查、更新这几步之间有没有建立正确的同步边界。

4. 实战避坑:从 Fragment 单例到二进制破坏

4.1 Fragment 单例模式里滥用 onCreateView binding 的坑

把话题拉回 Android 开发。很多人写 Fragment 时喜欢把 Fragment 实例保存成单例,理由是想在 Activity 重建后复用同一个 Fragment 实例,避免重复创建导致的状态丢失。这个想法本身没错,但一旦和 onCreateView、ViewBinding 混在一起,坑就来了。

最常见的问题是把 ViewBinding 对象存进了单例或者静态字段。ViewBinding 的生命周期和 View 的 attach/detach 紧密相关,一个 binding 对象里持有了 rootView 的引用。如果把 binding 塞进单例,相当于在单例里永久持有了一个已经 detach 的 View 引用,Activity 重建后旧 View 无法被回收,内存泄漏随之而来。

我在项目里见过类似的代码:在 onCreateView 里把 binding 赋给某个全局静态变量,或者在自定义的单例管理类里存 binding,理由是“下次直接用,省得重新 inflate”。听着省事,实则是在拿内存换性能,而且换来的性能收益极其有限。正确的做法是:binding 只在 Fragment 的 onCreateView 到 onDestroyView 的生命周期里使用,在 onDestroyView 里置空,绝不放进单例或静态容器。

那 Fragment 单例到底能不能用?能用,但要控制粒度。比如你的单例只是用来保存 Fragment 的业务状态、列表数据,而不是保存 View、Activity 或 binding,那完全没问题。核心原则一句话:单例里可以放数据,不该放视图。

4.2 序列化、反射、类加载器对单例的破坏与防御

除了线程安全,单例还有三个“非并发破坏者”容易被人忽略。

第一个是反射。Java 的反射可以通过setAccessible(true)调用私有构造函数,哪怕你的构造函数是 private,也能强行创建第二个实例。防御手段是在构造函数里加一个标志位:

private static boolean created = false; private Singleton() { synchronized (Singleton.class) { if (created) { throw new IllegalStateException("单例已被创建"); } created = true; } }

这样反射再调用时就会直接抛异常,从根上杜绝了第二实例的诞生。

第二个是序列化。如果一个单例类实现了 Serializable,通过 ObjectInputStream 反序列化时会绕过构造函数创建新实例。解决办法是补一个 readResolve 方法,让 JVM 反序列化时直接返回现有实例:

private Object readResolve() { return getInstance(); }

第三个是类加载器。同一个类被不同的 ClassLoader 加载后,在 JVM 里是两个完全不同的类,对应的静态字段也是两份,所以“全局唯一”只针对同一个类加载器成立。这个问题通常出现在复杂的组件化、插件化系统里,建议在架构层面统一类加载器,别在单例类里做文章。

4.3 并发环境下的常见问题排查速查表

最后整理一张排查表,都是我实际处理过的线上问题归类,按症状反推原因会快很多。

现象可能原因排查思路解决方案
并发下拿到 null懒汉式未加锁、实例尚未创建检查 getInstance 是否有同步或 volatile使用 volatile DCL 或静态内部类
拿到对象但字段是默认值指令重排导致半初始化对象发布检查 instance 是否加了 volatile加上 volatile 并验证重排被禁止
单例被创建了两次双重检查锁只做一次判空、反射破坏打印构造日志,检查反射调用补齐双重判空,构造函数加标志位
内存持续上涨Fragment/Activity 被单例持有用 Memory Profiler 检查引用链单例不存 View/binding
反序列化后对象不一致缺少 readResolve检查是否实现 Serializable补 readResolve 返回现有实例

这张表覆盖了我在实战中遇到过的绝大多数单例问题。你对照症状看,定位起来会非常快。

最后再分享一点个人经验

我处理过不少因为单例模式翻车的线上事故,坦白说,最终根因都不是“不会写单例”,而是对底层内存模型缺乏敬畏。很多人觉得 volatile 是“锦上添花”,但实际上在双重检查锁里,它是“雪中送炭”的必需品。

我的习惯是这样:写完一个单例,先问自己三个问题——这个单例真的需要懒加载吗?真的需要自己手写双重检查锁吗?有没有更简单的机制可以实现同样的效果?大多数场景下,静态内部类和枚举已经足够,双重检查锁更适合那种初始化耗时较长、想尽量减少同步开销的少数情况。

如果哪天你发现自己又在纠结“AtomicInteger 线程安全吗”或者“binding 能不能放单例里”,不妨退一步:并发安全的本质是搞清楚共享变量在什么时候、被哪些线程、以什么顺序访问,并且确保这些访问之间有清晰的同步规则。代码怎么写反而是次要问题,想通了这一层,单例模式基本不会再坑你。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询