Java volatile为什么能禁止指令重排序:内存屏障原理
在Java多线程编程中,代码的执行顺序往往并不等同于源代码的编写顺序。编译器和处理器为了优化性能,会对指令进行重排序。在单线程环境下,这种优化不会影响结果,但在多线程环境下,指令重排会导致严重的并发安全问题。volatile 关键字正是解决这一问题的核心工具,其背后的技术支撑是“内存屏障”。
本文将深入剖析 volatile 如何通过内存屏障禁止指令重排序,以及其在硬件层面的实现原理。
1. 识别指令重排带来的风险
在理解原理之前,必须先通过一个经典案例明确问题的严重性。指令重排可能导致“未初始化完成的对象被使用”或“状态标识与数据不一致”的情况。
观察以下代码场景:
// 线程 1 执行
public void actor1(I_Result r) {
if (ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
// 线程 2 执行
public void actor2(I_Result r) {
num = 2; // 语句 A
ready = true; // 语句 B
}
在这个场景中,如果 ready 和 num 是普通变量,语句 A 和语句 B 可能会被重排序。处理器可能先执行 ready = true,再执行 num = 2。
此时,如果线程 2 刚执行完 ready = true,线程 1 立刻判断 if(ready) 成立,就会进入分支计算 r.r1 = num + num。但由于 num 还未赋值为 2(仍是默认值 0),最终结果 r.r1 变成了 0,而不是预期的 4。这就是指令重排导致的逻辑错误。
2. 使用 volatile 建立有序性保障
要解决上述问题,只需在变量 ready 上加上 volatile 修饰符。
private volatile boolean ready = false;
private int num = 0;
加上 volatile 后,JMM(Java 内存模型)会强制限制特定的指令重排序规则。它保证了“在 volatile 变量写操作之前的所有操作,一定在写操作之前完成”。这意味着 num = 2 一定会在 ready = true 之前执行并对其他线程可见。
3. 理解内存屏障的核心机制
volatile 禁止指令重排的底层实现依赖于“内存屏障”。内存屏障是一类 CPU 指令,用于阻止特定类型的指令重排序,并强制刷新缓存。
JMM 将内存屏障分为四类,针对 volatile 变量的读写操作插入不同的屏障组合。参考下表了解四种屏障的具体功能:
| 屏障类型 | 指令示例 | 功能说明 |
|---|---|---|
| LoadLoad | Load1; LoadLoad; Load2 | 确保 Load1 数据的装载先于 Load2 及其后所有装载指令。 |
| StoreStore | Store1; StoreStore; Store2 | 确保 Store1 数据刷新到内存先于 Store2 及其后所有存储指令。 |
| LoadStore | Load1; LoadStore; Store2 | 确保 Load1 数据的装载先于 Store2 及其后所有存储指令。 |
| StoreLoad | Store1; StoreLoad; Load2 | 确保 Store1 数据刷新到内存先于 Load2 及其后所有装载指令。该屏障开销最大,具有全能屏障的效果。 |
4. 掌握 volatile 的屏障插入策略
Java 编译器在生成字节码时,会在 volatile 变量的读写操作前后插入上述屏障,从而禁止特定类型的重排序。
4.1 volatile 写操作的屏障插入
当执行 volatile 变量写操作时,JMM 会在其前后插入屏障。
查看 volatile 写操作指令序列示意图:
- StoreStore 屏障:保证在
volatile写操作之前的所有普通写操作已经刷新到主内存。这防止了前面的普通写(如num = 2)被排到volatile写(如ready = true)之后。 - StoreLoad 屏障:避免
volatile写操作与后面的可能出现的volatile读/写操作发生重排序。这个屏障功能最强大,开销也最大。
4.2 volatile 读操作的屏障插入
当执行 volatile 变量读操作时,JMM 同样会插入特定的屏障。
查看 volatile 读操作指令序列示意图:
- LoadLoad 屏障:禁止上面的普通读操作和下面的
volatile读操作重排序。 - LoadStore 屏障:禁止上面的普通读操作和下面的普通写操作重排序。
通过这种策略,volatile 变量的读写操作形成了一个固化的顺序,编译器和处理器不能越过这些屏障进行乱序优化。
5. 深入硬件层面:Lock 前缀指令
在汇编语言层面,volatile 变量的写操作会被编译器转化为一个带有 lock 前缀的指令。这个 lock 前缀指令实际上起到了“全能型内存屏障”的作用。
lock 前缀指令在硬件层面主要做三件事:
- 锁定总线或缓存行:确保对内存的读-改-写操作是原子的。
- 禁止指令重排:虽然它本身不是内存屏障指令,但它具有类似内存屏障的效果,阻止该指令前后的指令发生乱序执行。
- 强制刷新缓存:将写缓冲区中的数据立即写入主内存。这一步非常关键,它保证了修改的可见性。
配合 CPU 的总线嗅探机制,当一个处理器修改了 volatile 变量并回写到主内存时,其他处理器通过总线嗅探到该变量缓存行失效,下次读取时必须从主内存重新获取最新值。
6. 实战应用:双重检查锁定
理解内存屏障后,就能看懂为什么单例模式的双重检查锁定(DCL)必须使用 volatile。
public class Singleton {
private static volatile Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // 第1次检查
synchronized (Singleton.class) {
if (instance == null) { // 第2次检查
instance = new Singleton();
}
}
}
return instance;
}
}
instance = new Singleton(); 这行代码在 JVM 层面分为三步:
- 分配内存空间。
- 初始化对象。
- 将
instance引用指向分配的内存地址。
如果不加 volatile,步骤 2 和步骤 3 可能会发生重排序(执行顺序变为 1 -> 3 -> 2)。线程 A 执行了步骤 3(引用指向了内存,但对象还没初始化),此时线程 B 进来判断 instance != null,直接拿去使用,就会报错。
加上 volatile 后,利用 StoreStore 屏障,确保步骤 2(初始化对象)一定在步骤 3(引用赋值)之前完成,从而避免了其他线程看到未初始化的半成品对象。

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