为什么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;
根据规则:
a = 1happens-beforec = a(volatile规则)b = 2happens-beforea = 1(程序次序规则)c = ahappens-befored = b(程序次序规则)- 根据传递性:
b = 2happens-befored = 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
// 高性能场景下,数据的写入顺序可能和代码顺序不一致
// 如果对顺序有严格要求,需要使用内存屏障
八、总结
这篇文章我们深入探讨了:
- DCL单例为什么要加volatile:防止对象创建过程中的指令重排序
- CPU乱序执行:为了提高效率,但可能带来问题
- 内存屏障:CPU提供的禁止重排序的机制
- volatile的实现:JVM通过插入内存屏障来实现volatile
- happens-before原则:JMM的核心规则
- as-if-serial语义:单线程的保证
- Write Combining:CPU的写优化技术
理解这些底层知识,不是为了炫技,而是为了在遇到并发问题时,能够快速定位问题的根源。毕竟,知道"是什么"容易,知道"为什么"才是真本事。
参考资料:
- 《深入理解Java虚拟机》
- Intel CPU手册
- JSR-133规范