文章目录

Java双重检查锁单例为什么要加volatile

发布于 2026-04-20 19:18:46 · 浏览 4 次 · 评论 0 条

Java双重检查锁单例为什么要加volatile

在Java单例模式的实现中,双重检查锁定是一种常见的写法。很多开发者都知道代码中要加 volatile 关键字,但往往不清楚其深层原因。如果忽略这个关键字,在高并发场景下,你的程序可能会返回一个未经完全初始化的对象,导致不可预料的错误。


1. 审视标准的双重检查锁定代码

首先,观察一段标准的双重检查锁定(DCL)代码示例。

public class Singleton {
    // 必须使用 volatile 关键字修饰
    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;
    }
}

这段代码的设计初衷是:减少锁的竞争。只有在 instance 为 null 时才进入同步块,后续调用直接返回实例。然而,这种写法在 Java 内存模型(JMM)下存在隐患,必须依靠 volatile 来修复。


2. 拆解对象创建的幕后步骤

问题的关键在于 instance = new Singleton(); 这行代码。在 Java 中,这看似简单的一行语句,在 CPU 和 JVM 层面其实被拆解为三个独立的步骤。

阅读下表,了解这三个步骤的具体含义。

步骤 操作名称 具体描述
1 分配内存 在堆内存中开辟一块空闲空间,用于存放 Singleton 对象。
2 初始化对象 调用构造函数 <init>,对对象成员变量进行初始化赋值。
3 引用赋值 将堆内存的地址赋值给 instance 引用变量,使其指向该内存空间。

3. 分析指令重排序引发的风险

为了优化性能,编译器和处理器在不改变单线程执行结果的前提下,会对指令的执行顺序进行调整,这被称为“指令重排序”。

在上述三个步骤中,步骤 2 和步骤 3 之间可能发生重排序。

正常顺序 (1 -> 2 -> 3)

  1. 系统分配内存。
  2. 系统初始化对象。
  3. instance 指向内存。此时对象已经准备好了,其他线程拿到的就是完整对象。

异常顺序 (1 -> 3 -> 2)

这是重排序可能产生的情况。

  1. 系统分配内存。
  2. instance 指向内存(引用变得不为 null)。
  3. 系统初始化对象。

当执行 1 -> 3 -> 2 顺序时,instance 引用已经不为 null,但对象内部的成员变量可能还没有被初始化。此时如果另一个线程介入,就会发生严重错误。

查看以下时序图,了解没有 volatile 时线程间的交互细节。

sequenceDiagram autonumber participant T1 as 线程 A (创建者) participant T2 as 线程 B (调用者) participant Mem as 主内存 Note over T1: 进入同步块,开始创建实例 T1->>Mem: 1. 分配内存空间 T1->>Mem: 3. 引用指向内存 (指令重排) Note right of Mem: 此时 instance != null
但对象未初始化 T2->>Mem: 第一次检查 instance == null? Mem-->>T2: false (引用不为空) T2->>T2: 返回 instance 引用 Note over T2: 线程 B 拿到不完整的对象
程序可能报错或行为异常 T1->>Mem: 2. 初始化对象

如上图所示,如果线程 A 发生了指令重排(先执行了步骤 3),线程 B 恰好在步骤 3 之后、步骤 2 之前调用了 getInstance()。线程 B 发现 instance 不为 null,于是直接返回了引用。但此时线程 B 拿到的是一个“半成品”对象,它未被正确初始化。


4. 解决可见性与原子性隐患

除了防止指令重排序,volatile 还解决了另一个问题:可见性

可见性问题

在 Java 内存模型中,每个线程都有自己的工作内存(缓存),而不是直接操作主内存。

  1. 如果没有 volatile,线程 A 修改了 instance 的值,可能只是更新了自己工作内存中的副本。
  2. 线程 B 随后去读取 instance 时,可能读取的依然是主内存中的旧值(即 null)。
  3. 这会导致线程 B 以为实例还没创建,从而再次进入同步块创建实例,最终产生多个实例,破坏单例模式。

volatile 的作用

volatile 关键字通过插入内存屏障来强制执行两个规则:

  1. 禁止指令重排序:确保“初始化对象”步骤发生在“引用赋值”步骤之前,即保证 1 -> 2 -> 3 的顺序。
  2. 保证可见性:一旦线程 A 修改了 volatile 变量,该值会立即刷新到主内存。当其他线程读取该变量时,会强制从主内存重新读取,确保拿到最新的值。

5. 确认最终实现方案

为了彻底规避上述风险,编写代码时必须严格遵循以下两点:

  1. 声明单例引用变量时,添加 volatile 修饰符。
  2. 保留双重检查锁结构,以兼顾线程安全与性能。

最终的正确代码如下:

public class Singleton {
    // 关键点:使用 volatile 修饰
    private static volatile Singleton instance;

    private Singleton() {
        // 私有构造函数
    }

    public static Singleton getInstance() {
        // 第1次检查
        if (instance == null) {
            synchronized (Singleton.class) {
                // 第2次检查
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

只有加上了 volatile,这段代码才能在多线程高并发环境下,既保证线程安全,又保证高性能,同时确保获取到的对象一定是完整初始化过的。

评论 (0)

暂无评论,快来抢沙发吧!

扫一扫,手机查看

扫描上方二维码,在手机上查看本文