volatile 禁止重排序

候选人小钱在面试 B站 P6 时,面试官问道:

"volatile 除了保证可见性,还能禁止指令重排序。能具体说说它是怎么做到的吗?"

小钱说:"通过内存屏障..."面试官追问:"那 volatile 读写分别插入的是什么屏障?sfence、lfence 还是 mfence?"

小钱支支吾吾答不上来。面试官继续追问:"volatile 写在 volatile 读之前还是之后?两个 volatile 写之间呢?"

小钱彻底哑火了...

一、核心问题:volatile 如何禁止重排序 🔴

1.1 问题拆解

第一层:概念理解(禁止什么重排序?)
  "volatile 禁止哪些类型的重排序?"
  考察点:编译器重排序 vs CPU 重排序、as-if-serial 语义

第二层:内存屏障类型(怎么禁止?)
  "volatile 读写分别插入什么屏障?四种屏障组合的区别是什么?"
  考察点:loadload/loadstore/storestore/storeload 屏障的作用

第三层:平台差异(不同 CPU 表现一样吗?)
  "x86 和 ARM 架构下,volatile 的实现有什么区别?"
  考察点:TSO 模型 vs 弱内存模型、x86 的天然保证

第四层:Happens-Before 推导(规则怎么用?)
  "volatile 写 happens-before volatile 读,那 volatile 写在 volatile 写之后呢?"
  考察点:是否能推导出完整的 Happens-Before 链

1.2 ❌ 错误示范

候选人原话 A:"volatile 禁止所有指令重排序。"

问题诊断:这是不对的。volatile 只禁止与该 volatile 变量相关的重排序,不禁止其他普通变量的重排序。volatile 的屏障只影响与屏障相邻的 volatile 读/写操作的顺序。

候选人原话 B:"x86 下 volatile 读和 volatile 写代价一样大。"

问题诊断:在 x86 架构下,volatile 读的代价很小(因为 x86 的 TSO 模型天然保证读不重排序到写之前),但 volatile 写的代价较大(需要 lock 前缀指令)。在 ARM 架构下,两者代价都很大。

1.3 标准回答

P5 级别:说清禁止的类型

为什么需要禁止重排序?

编译器和 CPU 会进行指令重排序以提升性能,但重排序可能破坏多线程程序的正确性。as-if-serial 语义允许单线程内自由重排序,但多线程场景下,重排序可能导致可见性问题

volatile 禁止的重排序

重排序类型volatile 写前volatile 写后volatile 读前volatile 读后
编译器重排序普通写❌ 不允许✅ 允许✅ 允许❌ 不允许
编译器重排序普通读✅ 允许✅ 允许❌ 不允许✅ 允许
编译器重排序 volatile 写-❌ 不允许✅ 允许-
编译器重排序 volatile 读✅ 允许--❌ 不允许

简单记忆:屏障的"前"侧禁止重排序到"后"侧,屏障的"后"侧禁止重排序到"前"侧

P6 级别:四种内存屏障详解

四种基本内存屏障

volatile int a;
volatile int b;

// storestore barrier(禁止写前重排)
a = 1;           // 普通写
// storestore barrier
b = 1;           // volatile 写
// storeload barrier

// storeload barrier(禁止写后重排)
b = 1;           // volatile 写
// storeload barrier
System.out.println(a); // volatile 读

// loadload barrier(禁止读前重排)
int x = a;       // volatile 读
// loadload barrier
int y = b;       // 普通读

// loadstore barrier(禁止读后重排)
int x = a;       // volatile 读
// loadstore barrier
b = 1;           // 普通写

x86 架构的具体实现

操作x86 实现隐含屏障
volatile 写movl $val, offset(%reg); lock; movl %reg, (%rbx)storestore + storeload
volatile 读movl offset(%rbx), %eaxloadload + loadstore

为什么 volatile 读也隐含 loadstore?

因为如果 volatile 读之后是普通写,这个普通写可能被重排序到 volatile 读之前,导致错误。例如:

volatile int ready;
int data;

// 线程 A
data = 42;
ready = 1;  // volatile 写

// 线程 B
if (ready == 1) {  // volatile 读
    // 普通写 data = 0 绝不能被重排序到 if 之前
    System.out.println(data);
}

P7 级别:x86 TSO vs ARM 弱内存模型

x86 的 TSO(Total Store Order)模型

x86 实现了强内存模型,基本保证:

  • Store-Load 不重排序(写后读不会读旧值)
  • Store-Store 不重排序(写操作的顺序与程序一致)

因此 x86 上 volatile 写的主要开销是 lock 前缀的全局总线事务(广播 invalidate),volatile 读开销极小(天然顺序)。

ARM 的弱内存模型

ARM 是弱内存模型,几乎所有重排序都可能发生:

  • Store-Load 可重排序
  • Load-Load 可重排序
  • Store-Store 可重排序

所以 ARM 上必须使用显式屏障指令:

  • dmb ish:内存屏障(full)
  • dmb ishst:写屏障(store barrier)
  • dmb ishld:读屏障(load barrier)

JMM 的跨平台设计:JIT 编译器在生成字节码时,在 volatile 操作前后插入适当的内存屏障。这些屏障在不同平台上生成不同的机器指令。Java 程序员不需要关心平台差异,只需要记住 Happens-Before 规则。

一个实战细节:在 x86 上,volatile 的性能开销主要在写操作(约 30~100 纳秒,跨核通信延迟),读操作几乎无开销。所以在"一写多读"场景下,volatile 是非常高效的选择。

【面试官心理】 这道题我能问到 P7 级别。能说出四种屏障类型并解释何时需要哪种屏障的候选人,说明他理解了 volatile 的本质而非只是背诵。能比较 x86 和 ARM 内存模型差异的候选人,说明他对硬件有了解。能说出 x86 上 volatile 读开销很小的候选人,说明他有过实际性能优化的经验。

1.4 追问升级

追问 1:final 字段在 JMM 中有禁止重排序的保证吗?

JSR-133 对 final 字段有特殊规则:

  1. 禁止将 final 字段的写重排序到构造函数之外(通过在构造函数末尾插入 storestore 屏障实现)
  2. 前提:this 引用不能在构造函数中逸出

这使得不可变对象的 final 字段在发布后对所有线程可见,即使不使用 volatile。这个规则使得 String 的 value 数组(final + private)可以安全地在无锁情况下共享。

追问 2:synchronized 块中的代码会被重排序吗?

synchronized 块中的代码可能被重排序,但必须遵守以下约束:

  • synchronized 块内的代码不会重排序到 synchronized 块外
  • synchronized 块外的代码不会重排序到 synchronized 块内
  • synchronized 块内的代码在 synchronized 块内部可能重排序(只要不违反语义)

这是 synchronized 实现中的"管程(Monitor)"语义的一部分。

二、volatile 与 Happens-Before 的推导 🟡

2.1 完整推导链

// 线程 A
a = 1;          // 普通写
b = 1;          // volatile 写
c = 1;          // 普通写

// 线程 B
while (b == 1) {  // volatile 读
    d = c;        // 普通读
}

推导过程

  • a HB b(程序顺序规则)
  • b HB 循环条件成立(volatile 变量规则)
  • d HB ... (普通读没有 Happens-Before 保证,除非通过其他手段)

关键点c = 1(普通写)和 b = 1(volatile 写)之间,volatile 写规则保证了 b HB c?错!普通写和 volatile 写之间没有 Happens-Before 关系——ac 是普通写,不受 volatile 影响。

所以线程 B 不一定能看到 c 的最新值。这就是为什么有时 a = 1; b = 1; 重排序后,b 可能先执行(因为 volatile 写有 storeload 屏障,但普通写在 volatile 写之前的 storestore 屏障只保证"volatile 写之前的普通写在 volatile 写之前")。

2.2 volatile 写的 storestore 屏障细节

storestore barrier 的作用是:禁止屏障之前的普通写重排序到屏障之后的任何写操作之后

即:a = 1(普通写)不能被重排序到 b = 1(volatile 写)之后。但 a = 1 可以被重排序到 b = 1 之前(这是 storestore 屏障允许的)。

这保证了:当其他线程看到 volatile 写 b = 1 时,之前的普通写 a = 1 一定已经完成。

三、生产避坑

3.1 错误的链式依赖

// 错误代码
x = 1;              // 普通写
volatile y = true;  // volatile 写:保证 x 对其他线程可见
z = x;              // 普通读:可能看不到 x 的最新值!

// 正确代码
volatile y = true;
x = 1;              // 如果需要保证 x 的可见性,也要用 volatile

问题:误以为 volatile 写 y 能保证之前的普通写 x 对其他线程可见。实际上只有 volatile 变量本身被保证。

3.2 单例模式的 volatile 必须性

// 构造函数中的操作(可能在构造函数返回前被重排序)
instance = allocate();  // 1. 分配内存
constructor(instance);  // 2. 执行构造函数
// ← 在这里插入 storestore barrier
volatileInstanceRef = instance; // 3. volatile 写

// 如果没有 volatile 屏障,第3步可能重排序到第2步之前
// 导致其他线程看到未构造完成的对象
💡

面试加分点:能说出"JDK 9+ 的 VarHandle 提供了 setPlain/setOpaque/setRelease/getAcquire 等细粒度内存排序操作,Opaque 模式比 volatile 代价更低但不保证 Happens-Before"。这说明你对 JMM 的演进有跟进。

⚠️

面试陷阱:被问到"两个 volatile 写之间有 Happens-Before 关系吗",很多人会说"有"。错!volatile 写 HB volatile 读(读写配对),但两个 volatile 写之间没有直接的 Happens-Before 关系,除非通过传递性(程序顺序规则)间接建立。