08.2 - volatile 的内存语义
定位: volatile 是 Java 提供的最轻量级同步机制——保证单个变量的可见性和禁止重排序,但不保证原子性。本质是通过内存屏障操作缓存一致性协议。 面试高频度: ⭐⭐⭐⭐⭐ 考查方式: volatile 的两层语义、DCL 为什么要 volatile、volatile 能保证原子性吗、内存屏障的插入策略
一、这是什么?为什么需要它?
问题:没有 volatile,共享变量不可预期
CPU Core 0 (Thread A) CPU Core 1 (Thread B)
┌────────────────────┐ ┌────────────────────┐
│ write x = 42 │ │ read x │
│ ↓ │ │ ↓ │
│ L1 Cache: x=42 │ │ L1 Cache: x=0 ← 旧值!
│ (已更新) │ │ (未收到更新通知) │
│ ↓ │ │ ↓ │
│ Store Buffer │ │ Thread B 读到 x=0 │
│ (可能还没 flush) │ │ (完全不知道 A 写了) │
└────────────────────┘ └────────────────────┘
硬件层面:MESI 缓存一致性协议
────────────────────────────
Store Buffer 优化导致"写后立刻对其他核心不可见"
→ 缓存一致性协议需要时间传播
→ volatile = 强制插入内存屏障 + 清空 Store BufferWHY volatile:每个 CPU 核心的缓存各自为政。Thread A 写一个变量,这个写操作可能停留在 A 的 Store Buffer 或 L1 缓存里,Thread B 从自己的缓存读到的是旧值。volatile 强制两个语义:
- 可见性:volatile 写 → 立即 flush 到主存 + 无效化其他核心的缓存行
- 禁止重排序:volatile 读/写周围不能有指令重排序
二、原理拆解
2.1 语义一:可见性 (Visibility)
volatile 如何保证可见性?
volatile 写的 CPU 级操作:
┌─────────────────────────────────────────────────────────────────┐
│ │
│ ① 写入 volatile 变量 │
│ ② 执行 StoreLoad 内存屏障 (mfence / lock addl 指令) │
│ ③ 清空 Store Buffer, 确保写入结果进入缓存/主存 │
│ ④ 发送缓存一致性消息 (MESI: 修改→其他核心的缓存行无效化) │
│ │
│ volatile 读的 CPU 级操作: │
│ │
│ ① 其他核心的缓存行已被无效化 (由 volatile 写的 MESI 广播保证) │
│ ② 从主存重新读取 (或从提供缓存行的核心读取) │
│ ③ 执行 LoadLoad + LoadStore 屏障, 防止后续指令重排 │
│ │
└─────────────────────────────────────────────────────────────────┘MESI 缓存一致性协议的作用:
MESI 状态 (每个缓存行):
┌──────────────────────────────────────────────────────────────┐
│ M (Modified): 仅本核心持有, 已被修改 │
│ E (Exclusive): 仅本核心持有, 未修改 │
│ S (Shared): 多个核心持有副本 │
│ I (Invalid): 已失效 (被其他核心修改后发送 RFO) │
└──────────────────────────────────────────────────────────────┘
volatile 写:
→ 执行 RFO (Read For Ownership): 请求独占缓存行
→ 其他核心的同一缓存行 → 状态变为 I (Invalid)
→ 写入 M 状态
volatile 读:
→ 检查缓存行状态
→ 如果是 I → 必须从主存或其他核心重新读取
→ 读取到 S 或 E 状态2.2 语义二:禁止指令重排序 (Ordering Prevention)
volatile 通过内存屏障 (Memory Barrier / Fence) 实现禁止重排序:
四种内存屏障:
┌─────────────────────────────────────────────────────────────────────┐
│ │
│ 屏障类型 功能 类比 │
│ ──────── ────── ──── │
│ LoadLoad Load1 → [LoadLoad] → Load2 所有之前的读必须在 │
│ 阻止 Load1 与后续 Load 重排 后续读之前完成 │
│ │
│ StoreStore Store1 → [StoreStore] → Store2 所有之前的写必须在 │
│ 阻止 Store1 与后续 Store 重排 后续写之前完成 │
│ │
│ LoadStore Load1 → [LoadStore] → Store2 所有之前的读必须在 │
│ 阻止 Load1 与后续 Store 重排 后续写之前完成 │
│ │
│ StoreLoad Store1 → [StoreLoad] → Load2 所有之前的写必须在 │
│ 阻止 Store1 与后续 Load 重排 后续读之前完成 │
│ (最昂贵的屏障! 需要清空 Store Buffer) │
│ │
└─────────────────────────────────────────────────────────────────────┘JMM 对 volatile 的屏障插入策略:
volatile 写:
┌─────────────────────────────────────┐
│ [StoreStore] ← 确保所有普通写在 volatile 写之前完成并可见 │
│ volatile 赋值 │
│ [StoreLoad] ← 最贵! 确保 volatile 写立即对后续读可见 (清空 SB) │
└─────────────────────────────────────┘
volatile 读:
┌─────────────────────────────────────┐
│ volatile 读取 │
│ [LoadLoad] ← 确保 volatile 读完成后才执行后续普通读 │
│ [LoadStore] ← 确保 volatile 读完成后才执行后续普通写 │
└─────────────────────────────────────┘x86 下的实际实现 (简化):
x86 是 TSO (Total Store Order) 模型,天然保证 StoreStore、LoadLoad、LoadStore
→ 只有 StoreLoad 需要显式屏障
volatile 写在 x86 → 实际只生成 StoreLoad 屏障
→ 具体指令: mfence 或 lock addl $0, -N(%rsp)
volatile 读在 x86 → 不需要任何屏障 (x86 TSO 已经保证)2.3 volatile 不能保证原子性
这是面试最高频的陷阱题:
volatile int count = 0;
// 两个线程同时执行:
count++; // 这不是原子操作!
等价于:
int temp = count; // 读 volatile (原子)
temp = temp + 1; // 计算 (线程内)
count = temp; // 写 volatile (原子)
// 线程交错:
Thread A: temp = count → 0
Thread B: temp = count → 0 (读到同样的值!)
Thread A: count = 0 + 1 → count = 1
Thread B: count = 0 + 1 → count = 1 (丢失一次递增!)| 操作 | volatile 保证 | volatile 不保证 |
|---|---|---|
单次读 (int x = field) | 看到最新值 | — |
单次写 (field = x) | 其他线程立即看到 | — |
复合操作 (count++) | 单次读和写各自可见 | 读-改-写 整体不是原子 |
| long/double 非对齐 | — | 可能读到"字撕裂" (Word Tearing) |
正确做法:
AtomicInteger(CAS 保证原子性 + volatile 保证可见性)synchronized包裹整个复合操作ReentrantLock+ 保护
2.4 为什么 DCL 需要 volatile —— 经典案例
这是 volatile 的必考题——DCL 构造函数的指令重排序问题:
问题: 没有 volatile 的 DCL
┌────────────────────────────────────────────────────────────────────┐
│ public class Singleton { │
│ private static Singleton instance; // 没有 volatile! │
│ │
│ public static Singleton getInstance() { │
│ if (instance == null) { │
│ synchronized (Singleton.class) { │
│ if (instance == null) { │
│ instance = new Singleton(); // 有问题的赋值! │
│ } │
│ } │
│ } │
│ return instance; │
│ } │
│ } │
└────────────────────────────────────────────────────────────────────┘
instance = new Singleton() 的三步 (Java 字节码):
1. allocate 分配内存 (构造半初始化对象)
2. invokespecial <init> 调用构造函数 (初始化字段)
3. astore 赋值引用 (instance 指向内存)
C2 编译器/Core CPU 可以重排 2 和 3:
正常顺序: allocate → <init> → astore (安全)
实例: [零值] [Singleton{x=42}]
重排后: allocate → astore → <init> (危险!)
实例: [零值] [Singleton{x=0}]!
此时 instance != null, 但 x == 0!
线程交错:
Thread A: allocate → astore (instance != null, 但 x=0!)
Thread B: if (instance == null) → false → return instance
→ Thread B 看到 instance != null
→ 使用 instance.x → 读到 0 而不是 42!修复: 加 volatile:
private static volatile Singleton instance;
↑ 关键!
volatile 插入 StoreStore 屏障:
allocate
invokespecial <init> // 构造函数
[StoreStore] ← 屏障: 确保构造函数完成前不赋值!
astore instance ← 赋值
→ 重排序被禁止! Thread B 看到 instance != null 时, 构造函数一定已完成三、图解全景
3.1 volatile 写读的全流程
┌─────────────────────────────────────────────────────────────────────────┐
│ volatile 写 → volatile 读 的全流程可见性 │
│ │
│ Thread A (volatile 写) Thread B (volatile 读) │
│ ┌────────────────────────────┐ ┌────────────────────────────┐ │
│ │ │ │ │ │
│ │ 普通变量 x = 1 │ │ 普通变量 r1 = x │ │
│ │ ↓ (x 在 L1 缓存, 可能 │ │ ↑ (从 L1 缓存读, 还 │ │
│ │ 其他核心看不到) │ │ 是旧值) │ │
│ │ │ │ │ │
│ │ [StoreStore 屏障] │ │ [LoadLoad 屏障] │ │
│ │ (保证 x=1 已全局可见) │ │ (保证 volatile 读完成后 │ │
│ │ │ │ 才读/写后续变量) │ │
│ │ volatile flag = true │ │ while(!flag){} ← 读到 │ │
│ │ ↓ (MESI: 发送 RFO) │ │ ↓ flag=true 后 │ │
│ │ ↓ (其他核心缓存行→Invalid) │ │ ↓ (从主存或 A 的缓存) │ │
│ │ │ │ │ │
│ │ [StoreLoad 屏障] │ │ 读 r1 = x → 一定是 1! │ │
│ │ (清空 Store Buffer) │ │ (因为 StoreStore 确保了 │ │
│ │ │ │ x=1 在 flag=true 之前 │ │
│ │ │ │ 已可见) │ │
│ └────────────────────────────┘ └────────────────────────────┘ │
│ │
│ 可见性传递: │
│ x=1 ─[StoreStore]─→ flag=true ─[StoreLoad]─→ (其他核心看到) │
│ ↑ ↑ │
│ 普通写在 volatile 写之前 清空 Store Buffer 使写全局可见 │
│ 必须完成 │
└─────────────────────────────────────────────────────────────────────────┘3.2 DCL 的修复对比
无 volatile (错误):
┌────────────────────────────────────────────────────────────────────┐
│ Thread A: Thread B: │
│ │ │
│ allocate ──────┐ │ if (instance == null) → false │
│ │ │ return instance │
│ astore ────────┤──→ │ → instance 非空, 但构造函数还没执行完! │
│ │ │ → instance.x 读到默认值 0 │
│ <init> ────────┘ │ │
│ (构造函数太慢!) │ │
└────────────────────────────────────────────────────────────────────┘
有 volatile (正确):
┌────────────────────────────────────────────────────────────────────┐
│ Thread A: Thread B: │
│ │ │
│ allocate ────────── │ if (instance == null) → true (因为 StoreStore │
│ <init> ──────────── │ 屏障阻止了引用赋值提前) │
│ [StoreStore barrier]│ → 等待 A 完成 │
│ astore ──────────── │ if (instance == null) → false (A 已完成) │
│ │ return instance → 完全初始化好的对象! │
└────────────────────────────────────────────────────────────────────┘3.3 四种屏障的 x86 映射
JMM 要求的屏障 x86 上的实际实现 是否有效
───────────────── ──────────────── ────────
StoreStore 无 (x86 TSO 天然保证) no-op
LoadLoad 无 (x86 TSO 天然保证) no-op
LoadStore 无 (x86 TSO 天然保证) no-op
StoreLoad mfence / lock addl 显式屏障 (最贵!)
→ 在 x86 上, volatile 读是零开销的 (只保证缓存一致性)
→ 在 x86 上, volatile 写的开销就是 StoreLoad 屏障的开销 (约 20-50 cycles)
→ 在 ARM/PPC 等弱内存模型上, 四种屏障都需要! volatile 开销大得多四、实战验证
验证 1: volatile 可见性
java
public class VolatileVisibility {
static volatile boolean running = true; // volatile → 可见!
public static void main(String[] args) throws Exception {
Thread worker = new Thread(() -> {
long count = 0;
while (running) { // volatile 读 → 一定能看到主线程的修改
count++;
}
System.out.println("Worker stopped, count=" + count);
});
worker.start();
Thread.sleep(1000);
running = false; // volatile 写 → 立即对 worker 可见
worker.join();
// 输出: Worker stopped, count=...
// 一定在 1-2 秒内退出 (vs 非 volatile 可能永远不退出)
}
}验证 2: volatile 不保证原子性
java
public class VolatileNotAtomic {
static volatile int count = 0;
static final int THREADS = 10;
static final int INCREMENTS = 10000;
public static void main(String[] args) throws Exception {
Thread[] threads = new Thread[THREADS];
for (int i = 0; i < THREADS; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < INCREMENTS; j++) {
count++; // 不是原子的!
}
});
}
for (Thread t : threads) t.start();
for (Thread t : threads) t.join();
System.out.println("Expected: " + (THREADS * INCREMENTS));
System.out.println("Actual: " + count);
// 几乎每次都会丢失计数! 例如: Expected=100000, Actual=98734
}
}验证 3: DCL + volatile
java
public class Singleton {
// 关键: volatile 禁止实例化时的指令重排序
private static volatile Singleton instance;
private final int value;
private Singleton() {
this.value = 42;
// 模拟构造函数耗时操作
try { Thread.sleep(1); } catch (InterruptedException e) {}
}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查 (无锁)
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查 (加锁)
instance = new Singleton(); // volatile 保证不会重排
}
}
}
return instance;
}
public int getValue() { return value; }
// 测试: 多线程验证不出现半初始化对象
public static void main(String[] args) throws Exception {
for (int round = 0; round < 100; round++) {
final int[] results = new int[1000];
Thread[] threads = new Thread[1000];
for (int i = 0; i < 1000; i++) {
final int idx = i;
threads[i] = new Thread(() -> {
results[idx] = Singleton.getInstance().getValue();
});
}
for (Thread t : threads) t.start();
for (Thread t : threads) t.join();
// 验证所有线程都读到 42
for (int r : results) {
if (r != 42) {
System.out.println("ERROR! 读到 value=" + r);
return;
}
}
// 重置 (为下一轮准备)
instance = null;
}
System.out.println("All rounds passed: value=42 for all threads");
}
}验证 4: JOL 查看 volatile 字段
bash
# JOL 只能看到 volatile 字段的访问标志, 内存屏障需要通过 JIT 编译日志观察
# JIT 编译后查看是否插入 mfence 指令
# 使用 -XX:+PrintAssembly 查看 volatile 写的汇编
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly VolatileDemo
# 搜索 lock addl 或 mfence
# volatile 写附近应该出现:
# lock addl $0x0,-0x40(%rsp) ← x86 StoreLoad 屏障五、面试视角
| 追问 | 答案要点 |
|---|---|
| volatile 的两层语义是什么? | (1) 可见性:volatile 写立即刷入主存 + 无效化其他核心缓存行;volatile 读总是从主存获取最新值。(2) 禁止指令重排序:通过内存屏障阻止 volatile 操作与前后指令重排。 |
| volatile 能保证原子性吗? | 不能。volatile 只保证单次读/写的原子性和可见性。复合操作(如 count++)包含读-改-写三步,这三步之间可能被其他线程打断。需要 AtomicInteger 或 synchronized。 |
| 什么是指令重排序? | 编译器和 CPU 为了优化指令流水线,在不改变单线程执行结果的前提下改变指令执行顺序。单线程安全,多线程下可能导致不可预期的结果。 |
| volatile 与 synchronized 的区别? | synchronized 保证可见性+原子性+互斥,但更重。volatile 只保证可见性+禁止重排序,不能保证原子性。volatile 没有锁竞争、没有线程阻塞。 |
| DCL 为什么需要 volatile? | instance = new Singleton() 不是原子操作——分配内存、调用构造函数、赋值引用三步可能被重排。没有 volatile 时,其他线程可能看到已赋值的引用但构造函数还没执行完(读到默认值)。volatile 的 StoreStore 屏障禁止构造函数在引用赋值之后执行。 |
| volatile 的内存屏障在 x86 上怎么实现的? | x86 TSO (Total Store Order) 模型天然提供 StoreStore、LoadLoad、LoadStore 保障。只有 StoreLoad 需要显式屏障——通过 mfence 或 lock addl 指令实现。x86 上 volatile 读零额外指令开销。 |
| volatile long/double 需要吗? | 在 32 位 JVM 上,long/double 的 64 位读写可能被分为两个 32 位操作(字撕裂)。64 位 JVM 上非 volatile 的 long/double 对齐到 8 字节时也是原子的。加上 volatile 确保不会发生字撕裂。 |
| volatile 和 MESI 协议的关系? | volatile 底层利用 MESI(或同类缓存一致性协议)的 RFO (Read For Ownership) 机制——volatile 写发出 RFO 请求独占缓存行,其他核心收到后将自己的缓存行标记为 Invalid,下次读取时必须从主存获取。 |
| volatile 数组是怎么回事? | volatile int[] arr 只保证 arr 引用本身的可见性,不保证数组元素的可见性。元素的 volatile 语义需要用 AtomicIntegerArray 或 Unsafe 的 putIntVolatile。 |
📚 相关链接
- **JMM与happens-before** — volatile 变量规则是 8 条 hb 规则之一
- **synchronized的JVM实现** — synchronized 的锁升级过程和 ObjectMonitor
- **锁升级过程** — 偏向锁→轻量锁→重量锁的详细过程
- **对象内存布局(MarkWord与Klass与实例数据)** — MarkWord 锁状态位
- **锁消除与标量替换** — 锁消除使 synchronized 在不逃逸场景下被消除
- ← 返回 **JMM索引**