拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

双重校验锁DCL与volatile:从单例懒加载到缓存防击穿的并发实践

双重校验锁DCL与volatile:从单例懒加载到缓存防击穿的并发实践

1. 从单例懒加载说起:DCL到底解决了什么问题

DCL(double-checked locking,双检锁/双重校验锁)在Java并发编程圈子里属于"入门必踩、进阶必懂"的经典话题。它最初被提出来解决一个问题:如何在多线程环境下,既保证单例对象的唯一性,又保证创建时机足够“懒”。

先看一个最朴素的懒加载单例写法:

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

这段代码在单线程环境下完全没问题:第一次调用时实例化,之后直接返回。但放在多线程环境里就是一颗定时炸弹。两个线程可能同时走进if (instance == null)的判断,然后各自new出一个对象,单例就变成了"双例"。更麻烦的是,即便只差几个时钟周期,一个线程看到了非空的instance,也不能保证它已经完整初始化。

解决并发问题最直接的办法是加锁。把getInstance()方法整体用synchronized修饰,安全倒是安全了,但代价是每次调用都要经过锁的竞争。而实际业务中getInstance()的调用频率往往极高,拿一个已经初始化好的对象根本不需要同步,这种"每次都锁"的方案在高并发场景下性能损耗非常明显。

于是有人想了一个折中的办法:先无锁检查一次,如果对象已经存在就直接返回;只有发现对象确实还没创建时,才进入同步块再检查一次并完成创建。这个思路就是 DCL 的核心——外面那层检查用来挡掉绝大多数“已经初始化”的调用,里层那层检查用来防止多个线程同时进入同步块后重复创建。这也是“双重校验锁”这个名字的由来。

这一段历史放在今天看,很多同学可能觉得有点“远古”。但DCL的思想并没有过时,它代表的是一类典型的并发优化模式:用无锁快速路径(fast path)挡住大部分请求,只在少数真正需要竞争的情况下才付出锁的代价。这种“先判断、再同步、再判断”的节奏,后来被广泛用在缓存加载、连接池初始化、配置中心拉取等场景里。理解了DCL,你其实就理解了这类并发控制的通用方法论。

2. 双重校验到底在检查什么:核心代码逐行拆解

先给出一段经典的、正确的 DCL 写法,我们基于它逐行分析。

public class Singleton { // volatile 是关键,后面单独讲 private static volatile Singleton instance; private Singleton() { // 私有构造,防止外部 new } public static Singleton getInstance() { // 第一重检查:无锁快速路径 if (instance == null) { // 只有第一次进入时才会走到这里 synchronized (Singleton.class) { // 第二重检查:进入临界区后重新确认 if (instance == null) { instance = new Singleton(); } } } return instance; } }

2.1 第一重检查(无锁检查)的作用

第一重if (instance == null)没有任何锁保护,它的唯一目的是过滤。绝大多数情况下,单例对象在程序启动早期就已经被创建好了,后续成千上万次调用只需要return instance;这一行。无锁读在JVM层面几乎不耗费额外成本,比每次进入synchronized块要快得多。

这里需要纠正一个直觉:第一重检查不保证“看到的是最新值”。就算某个线程刚刚在同步块里完成了赋值,另—个线程的第一重检查也未必马上感知到。不过这个“不保证”其实不影响正确性,因为最坏的情况只是:这个线程看到null,然后走进同步块,在第二重检查时拿到最新状态。它能产生的最严重后果是“多走几步路”,而不会破坏单例的唯一性。

2.2 第二重检查(锁内检查)的意义

第二重检查是真正的"保险丝"。

假设线程A和线程B同时通过了第一重检查,都进入了synchronized块。但同步块是互斥的,线程A先拿到锁,创建了对象并赋值给instance,然后释放锁。线程B拿到锁之后进入临界区,如果这里没有第二重检查,线程B会再new一个对象,把线程A刚赋好的引用覆盖掉,单例被破坏。有了第二重检查,线程B会发现instance != null,直接放弃创建。

所以第一重检查优化的是“绝大多数调用”的性能,第二重检查保证的是“并发进入临界区时”的正确性。这两层分工完全不同,谁也替代不了谁。

2.3 为什么同步块要锁住 Singleton.class

这里锁的是类对象,也就是这个类对应的Class对象。在JVM里每个类有且只有一个Class实例,天然具备全局唯一的特性,拿它当锁对象能保证所有线程在进入同步块时竞争的是同一把锁。很多人问:“能不能用this或者字符串?”不行——getInstance()是静态方法,根本没有this;字符串常量虽然可能相等,但依赖字符串字面量做锁本质上是对锁语义的滥用,很容易在后续维护中埋坑。

3. volatile这条防线:指令重排是怎么毁掉DCL的

如果你在搜索引擎里翻DCL的历史,会发现一个很惊人的事实:DCL在早期是被广泛批判的,甚至被一些大牛称为“broken pattern”。问题不是出在双重检查的逻辑上,而是出在instance = new Singleton()这一行。

3.1 new 一个对象到底经历了什么

Java中new一个对象,在JVM层面大致分为三步:

  1. 分配一块内存空间
  2. 在内存上执行构造函数,初始化成员变量
  3. 把对象引用赋值给instance

问题在于,CPU和编译器为了提升执行效率,可能对这三步进行重排序。在单线程环境里,重排序不影响最终结果,因为不管先执行第2步还是第3步,最终这个线程自己看到的效果是一致的——这就是所谓as-if-serial语义。但在多线程共享内存的情况下,问题就出现了。

3.2 没有 volatile 时会发生什么

假设线程A进入同步块,执行instance = new Singleton(),时第3步先于第2步完成,内存里有了一个非空但尚未完成构造的instance。此时线程B恰好走到第一重检查,发现instance != null,直接返回了这个半成品对象。线程B接下来访问对象内部字段时,读到的可能是默认值(0、false、null),甚至直接抛NullPointerException。

这就是著名的“半初始化对象”问题。它非常隐蔽,因为重排序不一定每次都发生,可能跑几万次才出现一次,而且出现的位置完全不固定,排查起来极其痛苦。我之前帮人看过一个偶发性空指针问题,就是这种场景:服务启动很慢,多线程刷页面偶发报错,最后定位到单例对象内部的一个Map还没初始化就被别的地方拿走了。

3.3 volatile 在这里干了什么

被volatile修饰的变量具有两层语义:

  • 可见性:一个线程对volatile变量的写入,会立即对其他线程可见
  • 有序性(禁止重排序):volatile变量的读写操作前后,编译器、JIT、CPU都不会跨越读写顺序做危险的重排序

具体到DCL场景,volatile让new Singleton()的构造过程不会“跨界”到引用赋值之后。线程B即使恰好在赋值后检查引用,看到的也一定是一个已经完成构造的对象,不会拿到半成品。

3.4 JDK 5之前为什么 DCL 是坏的

这里要说一个历史背景:volatile在JDK 5之前的语义是不完整的。旧的内存模型下,volatile只保证可见性,但不严格禁止重排序。换句话说,在JDK 5之前,就算你写了volatile,也有可能拿到半初始化对象。这也是当年DCL被群嘲的根本原因。

JDK 5之后,Java内存模型(JSR-133)重定义了volatile,加入了“禁止重排序”的语义,同时强化了synchronized的可见性保证。从那时起,上面那段写法才成为真正可靠的标准答案。所以现在的DCL是够用的,但如果你在看老古董代码或者老技术文章,发现有人吐槽DCL有坑,一定要先确认年代,别被误导。

一句话总结:DCL正确性的关键不在双检,而在 volatile。少了这层防线,双检只解决了“重复创建”的问题,却解决不了“拿到半成品”的问题。

4. 现代Java里DCL的新活法:从单例到懒加载机制

很多人觉得DCL只跟“单例”绑定,其实它的思想已经被JVM和各大框架消化吸收,演化成了更底层的机制。这部分内容,能帮你从“会写DCL”升级到“理解整个懒加载并发体系”。

4.1 静态内部类方案:干掉了 volatile

在Java生态里,有一种被广泛认为比手写DCL更优雅的懒加载单例写法:Initialization-on-demand holder idiom(按需初始化占位类模式)。

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

这段代码没有volatile,没有synchronized,却能保证线程安全和懒加载。它的原理利用了JVM内部的类加载锁(class initialization lock):当多个线程同时第一次访问Holder.INSTANCE时,JVM会确保Holder类只被初始化一次,并且所有线程在初始化完成后才能继续执行。这个锁是JVM在类加载层面提供的,不需要你去写synchronized。

这种方案的优点非常明显:代码更短,没有锁竞争,也不存在指令重排的坑。它其实可以理解为“JVM帮你实现了一遍DCL”——第一次调用触发类加载,类加载过程天然具备“仅在需要时初始化”的懒加载语义,而且这一过程由JVM保证线程安全。

所以在现代Java里,如果只是实现一个懒加载单例,静态内部类方案通常是比手写DCL更优的选择。DCL真正的价值更多在于它还适合那些“初始化逻辑更复杂、需要等待外部条件满足”的延迟加载场景,比如动态配置、带条件的初始化流程,这时候你没法简单靠类加载来触发。

4.2 JDK 9+ 之后:Class 初始化语义强化

JDK 9之后,Java对类加载过程的语义做了进一步梳理,Class对象的初始化过程在不同平台和不同JVM实现上更加统一。这意味着基于Class初始化的懒加载方案(比如静态内部类)在不同环境下的行为一致性更可预测。

另外,java.lang.invoke和MethodHandles体系中也有ClassValue这类工具,本质上也是利用Class级别的生命周期管理实现线程安全的“延迟计算+缓存”。如果大家去看ClassValue的JDK源码实现,会发现它在底层确实处理了类似“一个Class对应一个值、多线程同时访问时保证只计算一次”的语义——这跟DCL想要解决的问题本质上是同一个。

4.3 枚举单例:最硬核的“防御型单例”

除了静态内部类,枚举单例也是一个值得说的变体:

public enum Singleton { INSTANCE; public void doSomething() { } }

枚举单例天然线程安全、天然序列化安全,还能防反射攻击。很多Java并发书籍把它推荐为“最完美的单例实现”。但它也有一个争议点:枚举常量是在类加载时初始化的,严格意义上并不“懒”——只要你引用了这个枚举类,INSTANCE就会立刻创建。

所以枚举单例和DCL各有各的适用场景。追求绝对安全选枚举,追求真正的延迟加载用静态内部类或者DCL,这个选择本身没有标准答案,取决于你的业务场景。

4.4 DCL思想在现代框架里的投影

DCL思想不只存在于手写单例中,它在很多框架里都被升华了。比如 Spring 的SingletonBeanRegistry在获取单例Bean时,会先尝试无锁获取(getSingleton),没拿到再上锁初始化——这个流程和DCL的第一二重检查神似。MyBatis、Netty、Guava等框架里的延迟缓存、连接池初始化、模板引擎加载等场景,大量使用“先检查、后加锁、再检查”的模式。

理解了DCL,你在阅读这些框架源码时会觉得很多地方似曾相识,这也是我为什么愿意把这么老的一个话题翻出来长篇大论:它看上去只是一个小技巧,但背后关联的其实是并发编程最核心的几个概念——原子性、可见性、有序性,以及对这些特性的系统性取舍。

5. DCL 的实战场景:哪些地方你真该用它

我知道很多同学看DCL教程最大的困惑是: "我平时写业务代码,好像根本不需要手写单例啊?" 确实,如果你用的是Spring这类IoC容器,Bean的生命周期管理早就替你把这些事干完了。但DCL的应用场景远不止“手写单例”这一处。

5.1 需要严格控制初始化时机的工具类/SDK

在写基础组件、SDK、工具库的时候,我们经常遇到“某个资源比较重,希望第一次真正使用时才初始化”的场景。比如数据库连接池的初始化、分布式锁工厂的创建、耗时统计上报器的启动等。这些场景通常没有Spring容器帮你管理生命周期,需要你手写延迟加载逻辑,DCL就是最自然的方案。

我在做一个内部RPC框架时,需要维护一个“客户端连接管理器”,每个目标服务地址对应一个连接池。连接池的创建成本很高,而且一个进程里同一地址只应该有一个连接池。这里我用的就是典型的 DCL 结构:先从ConcurrentHashMap里查,查不到再加锁创建,创建时再查一遍防止并发重复创建。这其实和DCL的双重检查在本质上一模一样。

5.2 缓存加载时的“防击穿”设计

在缓存领域有一个经典问题叫“缓存击穿”:某个热点key的缓存恰好过期,一瞬间有成百上千个请求同时打到数据库。此时如果没有保护机制,数据库压力会瞬间暴涨。常规做法是加锁加双重检查:

public Object getData(String key) { Object data = cache.get(key); if (data == null) { lock.lock(); try { data = cache.get(key); if (data == null) { data = queryFromDB(key); cache.put(key, data); } } finally { lock.unlock(); } } return data; }

这个模式是不是特别眼熟?先无锁查缓存,没查到再锁住,锁里再查一次,还没有才真正查库回填。能挡住同一时刻的并发穿透请求。这其实就是DCL思想在缓存场景中最标准的应用,只不过锁的对象从Singleton.class变成了每个key对应的锁。

5.3 代理对象和AOP中的延迟初始化

在一些动态代理框架里,被代理的目标对象往往不是启动时就创建好的,而是第一次调用某个方法时才初始化真正的目标对象。如果用DCL,可以保证在多个线程同时首次调用代理方法时,底层目标对象只被构造一次。

5.4 Netty 等网络框架中的资源懒加载

Netty的Channel创建、EventLoopGroup启动、Bootstrap绑定端口等操作,在许多封装中都存在“首次访问时才真正建立”的语义。这种只初始化一次且线程安全的要求,同样适合DCL模式。你在阅读网上的Netty封装工具类时,经常能看到类似if (channel == null) { synchronized(...) { ... } }的结构。

6. 避坑清单:DCL 最容易踩的 5 个雷区

这部分是今天最有价值的部分,全是我见过别人踩过的坑和自己踩过的坑。DCL虽然代码只有几行,但如果没有真正理解背后的原理,很容易在不经意间写出“看起来对、实际上错”的代码。

6.1 雷区一:忘了加 volatile

我可以非常肯定地说,这是DCL里最普遍的错误。代码逻辑看着完全正确,第一重检查、第二重检查、同步块一个都不少,唯独漏了volatile。少了 volatile 可能不一定会报错,但一旦发生指令重排,就是极其诡异的偶发问题,而且极难复现。

判断标准很简单:如果你看到没有volatile的DCL单例,不管代码是谁写的,都可以直接把“存在半初始化对象风险”这句话甩过去。

6.2 雷区二:把第一重检查当成线程安全的判断

有人会用if (instance == null)的结果去做业务分支,认为“既然instance不为空,那对象一定创建好了”。这在加了volatile后基本成立,但仍有一下需要补充的细节:volatile保证了这个字段在不同线程间的可见性,但如果你没有正确使用同步机制或者没有正确处理类的其他状态,还是可能遇到“实例有了,但实例内部引用的其他对象还没初始化”的情况。比如instance引用的对象持有的某个字段是普通(非volatile)引用,而这个字段被另一个线程在无同步保护下修改,读线程依然可能看到旧值。这个坑更多属于JMM基础知识,但放在DCL场景里容易被忽略。

6.3 雷区三:锁的不是同一个对象

比如一个类有两种单例字段,有人图省事写了两个DCL,但锁对象都用String字面量:

private static final String LOCK_A = "LOCK"; private static final String LOCK_B = "LOCK";

两个锁对象其实是同一个字符串实例(字符串常量池引用同一个对象),结果A的同步块和B的同步块互相阻塞,性能下降是小事,如果逻辑上依赖两个锁各自独立,还会引发死锁或者资源竞争。这属于锁对象选型上的经典失误。

6.4 雷区四:在锁内做耗时操作

把数据库查询、远程调用、大对象初始化放在synchronized块内部,导致所有等待线程被长时间阻塞。DCL适合“初始化动作尽量短”的场景,如果初始化逻辑很重,比如要加载一个大目录、预热几百个缓存条目,你需要评估持锁时间,必要时把初始化步骤拆分成多阶段,或者改用CountDownLatch、CompletableFuture等更灵活的并发工具。

6.5 雷区五:直接拿 DCL 当性能银弹

有些同学在网上听说DCL性能好,于是代码里但凡有懒加载就往上套。但DCL适用的场景是“读取远多于创建”的情况,如果你创建对象的频率和读取频率差不多,每次都得折腾锁,性能反而更差。要根据实际场景做选择,别为用而用。

7. 常见问题速查与排查思路

现象可能原因排查/解决建议
偶发NullPointerException,加日志也不稳定复现缺少volatile,拿到半初始化对象加上volatile,检查对应字段的JMM可见性
单例对象被创建了两次第二重检查缺失或锁对象不一致确认同步块内检查,确认锁对象是同一个类的Class实例
多线程调用性能下降明显锁被无关代码共用,或锁内做耗时操作检查锁对象唯一性,缩短临界区代码
无法实现真正的懒加载使用了枚举或静态字段初始化静态内部类或DCL按需创建
并发调用时出现多副本资源(如连接池)初始化与put操作不是原子流程用putIfAbsent或锁保护完整流程
编译期没问题,但不同环境表现不一致依赖了旧的volatile语义或特定JVM行为升级到JDK 5+,明确基于JSR-133模型理解问题

排查DCL问题时有个实用技巧:先在本地用高并发压测工具模拟大量线程同时首次调用getInstance(),如果问题无法稳定复现,就故意去掉volatile跑几轮对比,往往能快速判断问题是否和内存可见性相关。我之前做过一次实验,同样的代码在低并发下怎么跑都没事,一旦把线程数拉到100以上,半小时内必然出问题。

还有一个更隐蔽的场景:很多同学在单测里永远不会触发问题,因为单测通常是单线程顺序执行。所以写DCL相关代码后,建议务必安排一个多线程并发创建的测试用例,而且最好让每个线程都强制进入第一重检查的null分支,才能把“重复创建”或“半初始化”这类问题暴露出来。

8. 写出高质量 DCL 的实操清单

最后分享一套我自己写DCL时默认遵守的清单,直接照着抄能少踩很多坑。

  • 确定instance字段是private static volatile,三者缺一不可
  • 第一重检查不加锁,保持极薄逻辑,不要在里面做任何可能抛异常的运算
  • 同步块锁对象用当前类的Class对象,不要用实例字段或者字符串字面量
  • 第二重检查务必保留,并写在锁内部,这是防止并发重复创建的最后一道闸
  • 临界区代码保持精简,如果初始化逻辑很重,拆出去单独方法
  • 构造方法私有化,防止外部直接new
  • 如果要防序列化破坏单例,补充readResolve()方法
  • 如果项目里已经有Spring等IoC容器,优先交给容器管理,能不用手写DCL就不手写
  • 如果只是要个懒加载单例,优先考虑静态内部类方案,代码更少也更安全
  • 写完检查一遍:有没有volatile、锁是不是同一个、第二重检查在不在锁里

我在实际排查中确实遇到过一个很典型的案例:某服务凌晨定时任务和用户请求并发触发同一个工具类的初始化,偶发出现“部分用户拿到的配置不全”的现象。最初以为是配置中心问题,后来抓线程dump才发现是单例的Map字段在构造中被重排,加了一个volatile之后问题彻底消失。这种坑一旦碰上,没有JMM底子靠肉眼很难看穿,但理解DCL的原理之后,定位起来很快。

DCL这条技术线看起来只是几行代码的事,但它背后牵扯的内存模型、锁语义、类加载机制,放到任何Java进阶面试里都能追问出一大片内容。这也是它历久弥新的原因——它不是“一个单例写法”,而是一扇理解Java并发底层的大门。希望这篇梳理能帮你把这扇门推开,而不是仅仅记住一个模板代码。

返回列表