在Java并发编程中,单例模式的实现方式多种多样。其中,懒汉式的“双重检查锁定(Double-Checked Locking, DCL)”因其兼顾了线程安全、懒加载和高性能而备受推崇。然而,其标准实现中的 volatile 关键字常常让初学者感到困惑:既然已经有了 synchronized 锁,为什么还需要 volatile 来修饰实例变量呢?它究竟是画蛇添足,还是点睛之笔?

本文将带你深入剖析DCL的内部工作机制,彻底搞懂 volatile 在其中扮演的至关重要的角色。

完美的DCL实现

首先,让我们看一下被广泛推荐的DCL单例模式标准代码(JDK 1.5+):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package com.liboshuai.demo.thread.safe.singleton;

/**
* 懒汉式单例模式(线程安全 - 双重检查锁定版)
* 优点:线程安全,且性能较高,实现了懒加载
* 这是推荐的懒汉式单例模式实现之一
*/
public class LazySingletonDCL {

// 私有化构造函数,防止外部直接创建实例
private LazySingletonDCL() {
// 可以在这里添加一些初始化逻辑
System.out.println("线程 " + Thread.currentThread().getName() + " 正在初始化实例...");
}

/**
* 使用 volatile 关键字确保 instance 在多线程环境下的可见性和有序性
* 1. 禁止指令重排序(最关键)
* 2. 保证内存可见性
*/
private static volatile LazySingletonDCL instance = null;

public static LazySingletonDCL getInstance() {
// 第一次检查:如果实例已经存在,则直接返回,避免不必要的同步开销,提高性能。
if (instance == null) {
// 同步代码块:只有在实例未创建时才进入,保证只有一个线程能创建实例。
synchronized (LazySingletonDCL.class) {
// 第二次检查:防止多个线程同时通过第一次检查后,重复创建实例。
if (instance == null) {
instance = new LazySingletonDCL();
}
}
}
return instance;
}
}

这里的核心疑问就是:instance 变量为什么要用 volatile 修饰?

问题的根源:new 操作并非原子性

我们通常认为 instance = new LazySingletonDCL(); 是一行代码,应该是一步完成的。但在JVM层面,这个操作实际上包含三个主要步骤:

  1. 分配内存空间:为 LazySingletonDCL 对象在堆内存中分配一块空间。
  2. 初始化对象:调用 LazySingletonDCL 的构造函数,对这块内存空间进行初始化(例如,设置成员变量的初始值)。
  3. 建立引用:将栈中的 instance 变量指向堆中刚刚分配的内存地址。

在没有特殊限制的情况下,为了提高程序运行效率,JVM和CPU可能会对指令进行重排序。正常的执行顺序是 1 -> 2 -> 3,但它可能被优化重排为 1 -> 3 -> 2

致命场景:一个没有 volatile 的世界(精细化分析)

如果 instance 变量没有被 volatile 修饰,指令重排序就可能发生。让我们设想一个多线程环境下的致命场景,这个场景的核心在于:一个线程正在synchronized块内执行重排后的指令,而另一个线程在块外进行了无锁的读操作。

  1. 线程A 调用 getInstance(),发现 instancenull,于是进入 synchronized 代码块。
  2. 线程A开始执行 instance = new LazySingletonDCL();
  3. 指令重排序发生! JVM执行了 1 -> 3 -> 2 的顺序:
    • 步骤1:为对象分配内存空间。
    • 步骤3:将 instance 变量指向了这块内存。此刻,instance 已经不为 null 了!
    • 重点在于:这个被修改的 instance 引用有可能会被立即刷新到主内存中,从而对其他线程可见。而这一切都发生在线程A尚未退出synchronized块,且对象尚未完成初始化(步骤2还未执行)的时候。
  4. 线程B 此刻也调用 getInstance()
  5. 它执行第一次检查 if (instance == null)。这是一个在 synchronized 块之外的、无锁的普通读操作。
  6. 由于线程A重排序后的写操作结果已经对线程B可见,instance 在主内存中已经不为 null。因此,线程B的判断结果为 false
  7. 线程B跳过整个 ifsynchronized 块,直接返回 instance
  8. 灾难发生! 线程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 只防重排序,但不保证立即可见性,会怎样?

  1. 线程A进入同步块,完成了对象的创建(1->2->3顺序正确)。
  2. 在线程A退出同步块之前,它对 instance 的修改可能还只存在于自己的工作内存中,尚未同步到主内存。
  3. 此时线程B到来,执行第一次检查 if (instance == null)。它从主内存中读取到的 instance 仍然是 null
  4. 于是,线程B也认为需要创建实例,尝试获取 synchronized 锁,结果发现锁被线程A占用,只能进入阻塞等待。

结论是:如果没有立即可见性,DCL的第一次检查将在很大程度上失效。在单例创建的初期,大量并发线程都会“错误地”通过第一次检查,然后挤在 synchronized 关键字前排队等待,导致不必要的性能开销,使DCL退化为普通的低效加锁模式。

volatile 的可见性保证了只要 instance 被成功创建,其他线程就能立刻在第一次检查时看到,从而直接返回,避免了进入同步块的开销。这就保证了DCL的高性能

总结

现在,我们可以清晰地回答最初的问题了。在双重检查锁定(DCL)单例模式中:

  • synchronized 关键字负责提供互斥性,保证在任何时刻只有一个线程能够执行创建实例的代码块,避免了实例被重复创建。
  • volatile 关键字则提供了双重保障:
    1. 禁止指令重排序:从根本上保证了程序的正确性,防止其他线程获取到半初始化的对象。
    2. 保证内存可见性:确保了DCL的高性能,让第一次检查能够有效工作,避免不必要的线程阻塞。

在DCL中,synchronized 保证了“我来创建”,而 volatile 则保证了“我创建好之前,你别碰;我创建好了,你立刻就能看到”。二者珠联璧合,缺一不可,共同构成了这个严谨、高效且线程安全的单例模式。