为什么DCL单例要加volatile?——CPU乱序执行与内存屏障

为什么DCL单例要加volatile?------CPU乱序执行与内存屏障

面试的时候,面试官问:"DCL单例为什么要加volatile?"我脱口而出:"防止指令重排序。"面试官继续问:"那volatile是怎么实现的?底层的内存屏障是什么?"我...我卡住了。相信很多Java程序员都有类似的经历。今天我们就来彻底搞懂这个问题。

一、从一个经典的面试题说起

1.1 DCL单例的代码

先回顾一下DCL(Double Check Lock)单例的代码:

java 复制代码
public class Singleton {
    private static volatile Singleton instance;
    
    public static Singleton getInstance() {
        if (instance == null) {                    // 第一次检查:避免每次都加锁
            synchronized (Singleton.class) {
                if (instance == null) {            // 第二次检查:防止重复创建
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

注意那个volatile关键字,很多人知道要加,但不知道为什么。

1.2 问题的根源:对象创建过程

new Singleton() 这行代码,在CPU层面并不是原子操作。它大致分为以下几个步骤:

复制代码
1. 分配内存空间
2. 初始化对象(执行构造方法)
3. 把引用指向分配的内存

在正常情况下,这个顺序是没问题的。但CPU有个"坏习惯"------乱序执行。

二、CPU的乱序执行

2.1 什么是乱序执行

CPU为了提高效率,会对指令进行重排序。比如:

复制代码
指令1: 去内存读数据(要等80ns)
指令2: 计算一个值(不依赖指令1的结果,1ns就能完成)

CPU不会傻等指令1,而是先执行指令2

这就像你去餐厅点菜:

  • 你点了"红烧肉"(要等30分钟)
  • 你又点了"凉拌黄瓜"(2分钟就好)
  • 厨师不会等红烧肉做好再做黄瓜,而是先做黄瓜

2.2 乱序执行的证明

看这段代码:

java 复制代码
public class Disorder {
    private static int x = 0, y = 0;
    private static int a = 0, b = 0;
    
    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        for (;;) {
            i++;
            x = 0; y = 0;
            a = 0; b = 0;
            Thread one = new Thread(() -> {
                a = 1;      // 步骤1
                x = b;      // 步骤2
            });
            Thread other = new Thread(() -> {
                b = 1;      // 步骤3
                y = a;      // 步骤4
            });
            one.start();
            other.start();
            one.join();
            other.join();
            if (x == 0 && y == 0) {
                // 这种情况理论上不应该发生,但实际上会发生!
                System.out.println("第" + i + "次:" + x + "," + y);
                break;
            }
        }
    }
}

如果按照代码顺序执行,x和y不可能同时为0。但实际上,由于乱序执行,x=0,y=0是可能出现的。

2.3 乱序执行在DCL中的问题

回到DCL单例,new Singleton() 的三个步骤可能被重排:

复制代码
正常顺序:1.分配内存 → 2.初始化对象 → 3.引用指向内存
乱序后:  1.分配内存 → 3.引用指向内存 → 2.初始化对象

这会导致什么问题?

java 复制代码
// 线程1
instance = new Singleton();  // 执行了步骤1和3,还没执行步骤2

// 线程2
if (instance == null) {      // 返回false,因为instance已经不为null了
    // 不会进入if块
}
return instance;             // 返回了一个半初始化的对象!

线程2拿到的是一个"半初始化"的对象------内存分配了,引用指向了,但构造方法还没执行完。这时候使用这个对象,可能会出现各种奇怪的错误。

三、内存屏障:CPU的"交通规则"

3.1 什么是内存屏障

内存屏障(Memory Barrier)是一条CPU指令,它告诉CPU:"屏障前后的指令不能重排序。"

就像马路上的隔离带:

复制代码
指令1(写操作)
---- 内存屏障 ----
指令2(读操作)

有了屏障,指令1一定在指令2之前完成

3.2 x86的内存屏障指令

在x86架构下,有三种内存屏障:

assembly 复制代码
; sfence: 写屏障
; 在sfence指令前的写操作,必须在sfence指令后的写操作前完成
sfence

; lfence: 读屏障
; 在lfence指令前的读操作,必须在lfence指令后的读操作前完成
lfence

; mfence: 全屏障
; 在mfence指令前的读写操作,必须在mfence指令后的读写操作前完成
mfence

3.3 Lock指令

除了内存屏障,还有一种更"暴力"的方式------lock指令:

assembly 复制代码
lock add [counter], 1  ; 原子操作,同时是全屏障

lock指令会锁住内存子系统,确保操作的原子性和顺序性。

四、volatile的实现细节

4.1 JVM层面的内存屏障

当我们在Java代码中使用volatile时,JVM会在适当的位置插入内存屏障:

volatile写:

复制代码
StoreStoreBarrier    ← 确保之前的写操作对当前写可见
volatile 写操作
StoreLoadBarrier     ← 确保当前写对之后的读可见

volatile读:

复制代码
LoadLoadBarrier      ← 确保之前的读操作在当前读之前完成
volatile 读操作
LoadStoreBarrier     ← 确保当前读在之后的写操作之前完成

4.2 回到DCL单例

有了volatile,DCL单例的执行过程变成了:

java 复制代码
// 线程1
instance = new Singleton();
// 实际执行:
// 1. 分配内存
// 2. 初始化对象
// ---- StoreStoreBarrier ----
// 3. 引用指向内存(volatile写)
// ---- StoreLoadBarrier ----

// 线程2
if (instance == null) {  // volatile读
    // 由于内存屏障,线程1的步骤2一定在步骤3之前完成
    // 所以线程2看到的instance一定是完全初始化好的
}

五、JSR-133与happens-before原则

5.1 什么是happens-before

happens-before是JMM(Java Memory Model)的核心概念。如果操作A happens-before 操作B,那么A的结果对B可见。

注意:happens-before不是指时间上的先后,而是指"可见性"。

5.2 happens-before的规则

规则 说明
程序次序规则 同一个线程内,按代码顺序执行
管程锁定规则 unlock happens-before 同一个锁的lock
volatile变量规则 volatile写 happens-before volatile读
线程启动规则 start() happens-before 线程的每个操作
线程终止规则 线程的所有操作 happens-before join()
线程中断规则 interrupt() happens-before 检测到中断
对象终结规则 构造方法 happens-before finalize()
传递性 A happens-before B,B happens-before C → A happens-before C

5.3 volatile变量规则的应用

java 复制代码
// 线程1
volatile int a = 1;
int b = 2;

// 线程2
int c = a;  // volatile读
int d = b;

根据规则:

  1. a = 1 happens-before c = a(volatile规则)
  2. b = 2 happens-before a = 1(程序次序规则)
  3. c = a happens-before d = b(程序次序规则)
  4. 根据传递性:b = 2 happens-before d = b

所以线程2读到的b一定是2。

六、as-if-serial语义

6.1 单线程的保证

as-if-serial语义是指:不管怎么重排序,单线程程序的执行结果不能改变。

java 复制代码
// 单线程下
int a = 1;
int b = 2;
int c = a + b;

CPU可能会重排a和b的赋值顺序,但c的结果一定是3。

6.2 多线程的挑战

但在多线程下,as-if-serial就不够了:

java 复制代码
// 线程1
a = 1;
flag = true;

// 线程2
if (flag) {
    System.out.println(a);  // 可能输出0!
}

线程1可能重排序,先执行flag = true,再执行a = 1。线程2看到flag为true时,a可能还没被赋值。

这就是为什么我们需要volatile和内存屏障。

七、Write Combining技术

7.1 什么是Write Combining

CPU在写数据时,不是每次都直接写入L1缓存,而是先写到一个"合并写缓冲区"(Write Combining Buffer),等缓冲区满了再一起写入L2。

复制代码
CPU写入 → WC Buffer → L2缓存
           ↓
        同时写入L1

7.2 为什么要这样做

因为写L1缓存需要时间,如果每次都直接写,CPU就要等待。有了WC Buffer,CPU可以继续执行,不用等。

7.3 对编程的影响

java 复制代码
// 高性能场景下,数据的写入顺序可能和代码顺序不一致
// 如果对顺序有严格要求,需要使用内存屏障

八、总结

这篇文章我们深入探讨了:

  1. DCL单例为什么要加volatile:防止对象创建过程中的指令重排序
  2. CPU乱序执行:为了提高效率,但可能带来问题
  3. 内存屏障:CPU提供的禁止重排序的机制
  4. volatile的实现:JVM通过插入内存屏障来实现volatile
  5. happens-before原则:JMM的核心规则
  6. as-if-serial语义:单线程的保证
  7. Write Combining:CPU的写优化技术

理解这些底层知识,不是为了炫技,而是为了在遇到并发问题时,能够快速定位问题的根源。毕竟,知道"是什么"容易,知道"为什么"才是真本事。


参考资料

  • 《深入理解Java虚拟机》
  • Intel CPU手册
  • JSR-133规范
相关推荐
杨云龙UP1 小时前
Oracle/ODA RAC /u01 空间告警处理指南:grid 用户监听日志清理_2026-06-15
linux·数据库·oracle·oracle linux·oda·监听日志·在线清理
shushangyun_1 小时前
批发商城系统源码多少钱?2026最新报价一览
java·开发语言·人工智能·spring·spring cloud
cfm_29141 小时前
JVM深度详解:Class常量池、运行时常量池、字符串常量池、包装类对象池
java·jvm
JAVA面经实录9171 小时前
高频算法面试题
java·计算机网络·算法·面试
影视飓风TIM1 小时前
从C++引用到类封装:底层视角拆解核心语法与面试考点
java·开发语言
天天爱吃肉82182 小时前
豆包 vs DeepSeek API 对比分析报告
android·java·大数据·开发语言·功能测试·嵌入式硬件·汽车
柏舟飞流2 小时前
Spring Boot + Spring Security + RBAC:从登录鉴权到权限模型设计
java·spring boot·spring
赋缘汇(fableshare)-黄从庆2 小时前
Ubuntu重启后进入initramfs导致无法开机
linux·运维·ubuntu
AC赳赳老秦2 小时前
OpenClaw + 飞书多维表格:自动同步数据、生成统计图表、触发自动化任务
java·大数据·python·缓存·自动化·deepseek·openclaw