深入解析DCL单例模式:为何volatile不可或缺?
在Java并发编程中,单例模式的实现方式多种多样。其中,懒汉式的“双重检查锁定(Double-Checked Locking, DCL)”因其兼顾了线程安全、懒加载和高性能而备受推崇。然而,其标准实现中的 volatile 关键字常常让初学者感到困惑:既然已经有了 synchronized 锁,为什么还需要 volatile 来修饰实例变量呢?它究竟是画蛇添足,还是点睛之笔?
本文将带你深入剖析DCL的内部工作机制,彻底搞懂 volatile 在其中扮演的至关重要的角色。
完美的DCL实现
首先,让我们看一下被广泛推荐的DCL单例模式标准代码(JDK 1.5+):
1 | package com.liboshuai.demo.thread.safe.singleton; |
这里的核心疑问就是:instance 变量为什么要用 volatile 修饰?
问题的根源:new 操作并非原子性
我们通常认为 instance = new LazySingletonDCL(); 是一行代码,应该是一步完成的。但在JVM层面,这个操作实际上包含三个主要步骤:
- 分配内存空间:为
LazySingletonDCL对象在堆内存中分配一块空间。 - 初始化对象:调用
LazySingletonDCL的构造函数,对这块内存空间进行初始化(例如,设置成员变量的初始值)。 - 建立引用:将栈中的
instance变量指向堆中刚刚分配的内存地址。
在没有特殊限制的情况下,为了提高程序运行效率,JVM和CPU可能会对指令进行重排序。正常的执行顺序是 1 -> 2 -> 3,但它可能被优化重排为 1 -> 3 -> 2。
致命场景:一个没有 volatile 的世界(精细化分析)
如果 instance 变量没有被 volatile 修饰,指令重排序就可能发生。让我们设想一个多线程环境下的致命场景,这个场景的核心在于:一个线程正在synchronized块内执行重排后的指令,而另一个线程在块外进行了无锁的读操作。
- 线程A 调用
getInstance(),发现instance为null,于是进入synchronized代码块。 - 线程A开始执行
instance = new LazySingletonDCL();。 - 指令重排序发生! JVM执行了
1 -> 3 -> 2的顺序:- 步骤1:为对象分配内存空间。
- 步骤3:将
instance变量指向了这块内存。此刻,instance已经不为null了! - 重点在于:这个被修改的
instance引用有可能会被立即刷新到主内存中,从而对其他线程可见。而这一切都发生在线程A尚未退出synchronized块,且对象尚未完成初始化(步骤2还未执行)的时候。
- 线程B 此刻也调用
getInstance()。 - 它执行第一次检查
if (instance == null)。这是一个在synchronized块之外的、无锁的普通读操作。 - 由于线程A重排序后的写操作结果已经对线程B可见,
instance在主内存中已经不为null。因此,线程B的判断结果为false。 - 线程B跳过整个
if和synchronized块,直接返回instance。 - 灾难发生! 线程B得到了一个引用,但这个引用指向的是一个没有完成初始化的**半初始化(Partially Constructed)**对象。如果线程B立即使用这个对象的任何方法或成员变量,都可能触发
NullPointerException或得到错误的数据,导致程序崩溃。
volatile 的双重保险
volatile 关键字在这里提供了两重至关重要的保障,彻底杜绝了上述风险。
第一重保险:禁止指令重排序(保证正确性)
这是 volatile 在DCL中最核心的作用。volatile 关键字会插入一个“内存屏障”(Memory Barrier),这个屏障会强制性地保证:
volatile写操作(instance = ...)之前的所有操作,必须全部完成。volatile写操作之后的所有操作,必须在其完成后才能开始。
这意味着,JVM必须严格按照 1.分配内存 -> 2.初始化对象 -> 3.建立引用 的顺序来执行。它杜绝了 1 -> 3 -> 2 这种危险的重排序。
因此,只要任何线程在第一次检查时看到 instance 不为 null,它拿到的就一定是一个结构完整、初始化完毕的对象。这就从根本上保证了程序的正确性。
第二重保险:保证内存可见性(保证性能)
volatile 的另一个作用是保证变量的内存可见性。它能确保:
- 写操作:当一个线程修改了
volatile变量的值,这个新值会立即被强制从该线程的工作内存刷新到主内存中。 - 读操作:当一个线程读取
volatile变量时,它会强制从主内存中读取最新的值,而不是使用自己工作内存中的缓存副本。
这为什么对性能很重要?
正如你在思考中提出的那样,我们来做一个思想实验:假如 volatile 只防重排序,但不保证立即可见性,会怎样?
- 线程A进入同步块,完成了对象的创建(
1->2->3顺序正确)。 - 在线程A退出同步块之前,它对
instance的修改可能还只存在于自己的工作内存中,尚未同步到主内存。 - 此时线程B到来,执行第一次检查
if (instance == null)。它从主内存中读取到的instance仍然是null! - 于是,线程B也认为需要创建实例,尝试获取
synchronized锁,结果发现锁被线程A占用,只能进入阻塞等待。
结论是:如果没有立即可见性,DCL的第一次检查将在很大程度上失效。在单例创建的初期,大量并发线程都会“错误地”通过第一次检查,然后挤在 synchronized 关键字前排队等待,导致不必要的性能开销,使DCL退化为普通的低效加锁模式。
volatile 的可见性保证了只要 instance 被成功创建,其他线程就能立刻在第一次检查时看到,从而直接返回,避免了进入同步块的开销。这就保证了DCL的高性能。
总结
现在,我们可以清晰地回答最初的问题了。在双重检查锁定(DCL)单例模式中:
synchronized关键字负责提供互斥性,保证在任何时刻只有一个线程能够执行创建实例的代码块,避免了实例被重复创建。volatile关键字则提供了双重保障:- 禁止指令重排序:从根本上保证了程序的正确性,防止其他线程获取到半初始化的对象。
- 保证内存可见性:确保了DCL的高性能,让第一次检查能够有效工作,避免不必要的线程阻塞。
在DCL中,synchronized 保证了“我来创建”,而 volatile 则保证了“我创建好之前,你别碰;我创建好了,你立刻就能看到”。二者珠联璧合,缺一不可,共同构成了这个严谨、高效且线程安全的单例模式。


