内存屏障(Memory Barrier)详解
内存屏障是理解 volatile、synchronized、JMM 的底层关键。下面从为什么需要 、四种屏障 、volatile 如何插入屏障 、底层 CPU 如何实现四个层面展开。
一、为什么需要内存屏障
内存屏障要同时解决两类"乱序"问题:
1. 编译器/CPU 的指令重排序
编译器和 CPU 为了提升性能,在不改变单线程语义的前提下会重排指令。这在单线程没问题,但多线程下会破坏程序的预期。
2. 硬件层面的存储缓冲(Store Buffer)
现代 CPU 写数据不是直接写主存,而是先写入 Store Buffer,再异步刷回缓存/主存。这导致:
- 写操作对其他 CPU 不是立即可见
- 后续读操作可能越过尚未提交的写操作
内存屏障就是告诉 CPU/编译器:"屏障前后的内存操作,不许跨越这条线重排,且必须保证刷写/失效完成。"
二、四种基本内存屏障
JMM 定义了四种屏障,按"读写方向"组合:
| 屏障类型 | 指令示例 | 作用 |
|---|---|---|
| LoadLoad | Load1; LoadLoad; Load2 | 保证 Load1 的读先于 Load2 的读完成 |
| StoreStore | Store1; StoreStore; Store2 | 保证 Store1 的写先于 Store2 的写刷新到主存 |
| LoadStore | Load1; LoadStore; Store2 | 保证 Load1 的读先于 Store2 的写完成 |
| StoreLoad | Store1; StoreLoad; Load2 | 保证 Store1 的写先于 Load2 的读,且写对后续读可见 |
StoreLoad 是全能屏障,开销最大。它同时具备其他三种屏障的效果,因为要"清空 Store Buffer 并等待写可见",代价最高。
记忆方法:屏障名字 = "前面操作类型 + 后面操作类型",表示禁止这两类操作交换顺序。
三、JMM 的保守策略:volatile 如何插入屏障
JMM 对 volatile 采用一套保守但正确的屏障插入策略(JSR-133 规范):
1. volatile 写操作
普通写...
↓
【StoreStore 屏障】 ← 保证之前的普通写都刷新到主存
↓
volatile 写
↓
【StoreLoad 屏障】 ← 保证 volatile 写先于后续任何读写(关键!)
↓
普通读/写...
2. volatile 读操作
普通读...
↓
volatile 读
↓
【LoadLoad 屏障】 ← 保证 volatile 读先于后续普通读
↓
【LoadStore 屏障】 ← 保证 volatile 读先于后续普通写
↓
普通读/写...
用表格总结
| 操作 | 前插屏障 | 后插屏障 |
|---|---|---|
| volatile 读 | --- | LoadLoad、LoadStore |
| volatile 写 | StoreStore | StoreLoad |
注意 :这些屏障是保守策略,实际执行时编译器会根据上下文优化掉不必要的屏障(例如连续的 volatile 写之间可以合并屏障)。
四、用屏障解释两个经典场景
场景 1:DCL 单例为何需要 volatile
java
instance = new Singleton();
// 实际三步:
// 1. memory = allocate() 分配内存
// 2. ctorInstance(memory) 初始化对象
// 3. instance = memory 赋值引用
若步骤 2、3 重排(1→3→2),另一个线程在第一次 if (instance == null) 检查时可能读到非 null 但未初始化的引用。
volatile 的作用 :写 volatile 前插入 StoreStore,保证"初始化对象"(普通写)先于"赋值引用"(volatile 写)完成,从而禁止 2、3 重排。
场景 2:状态标志的可见性
java
// 线程 A
data = 42; // 普通写
flag = true; // volatile 写
// 线程 B
if (flag) { // volatile 读
print(data); // 普通读
}
线程 A 侧 :volatile 写前有 StoreStore 屏障 → 保证 data = 42 先刷到主存,再写 flag。
线程 B 侧:volatile 读后有 LoadLoad 屏障 → 保证读到 flag=true 后,再去读 data 能读到 42。
这就是 volatile 实现 "安全发布" 的底层机制。
五、底层 CPU 如何实现(x86 为例)
JMM 的屏障是抽象规范,最终要落到具体 CPU 指令。不同架构实现不同:
x86 架构特点
- 强内存模型:只有 StoreLoad 可能重排(因为 Store Buffer),LoadLoad/StoreStore/LoadStore 天然不乱序
- 因此 x86 上:
- LoadLoad、StoreStore、LoadStore 屏障 = 空操作(no-op),仅编译期约束
- StoreLoad 屏障 =
lock前缀指令 或mfence
lock 前缀指令做了什么
lock addl $0, (%rsp) // 常见的空操作加 lock 前缀
lock 前缀触发:
- 将当前 CPU 的 Store Buffer / 缓存行写回主存
- 基于 MESI 协议,使其他 CPU 中该地址的缓存行失效(Invalidate)
- 后续读操作必须重新从主存/缓存加载,从而看到最新值
- 同时充当全能内存屏障,禁止指令重排
不同架构对比
| 架构 | 内存模型 | StoreLoad 实现 | 说明 |
|---|---|---|---|
| x86 | 强(TSO) | lock / mfence |
只有 StoreLoad 需真实指令 |
| ARM | 弱 | dmb ish |
四种屏障都需显式指令 |
| PowerPC | 弱 | sync / lwsync |
屏障更重 |
这也是为什么在 ARM 上运行 Java(如手机、Apple M 系列)时,volatile 的开销比 x86 更大------弱内存模型需要更多显式屏障指令。
六、屏障与缓存一致性协议的关系
内存屏障不能孤立理解,它和 MESI 缓存一致性协议 配合工作:
CPU0 CPU1
───── ─────
Store x=1 Load x
↓ Store Buffer ↓
【StoreLoad 屏障】 缓存行失效
↓ lock 指令 ↓
广播失效 x 的缓存行 ────→ 重新从主存加载 x=1
- MESI 负责缓存行状态同步(Modified/Exclusive/Shared/Invalid)
- 内存屏障 负责时序约束:保证失效广播完成、Store Buffer 清空后,才允许后续操作
- 没有屏障,Store Buffer 中的写可能延迟可见,导致其他 CPU 读到旧值
七、屏障在 JVM 中的其他应用
内存屏障不只服务 volatile,JMM 中多处使用:
| 场景 | 屏障策略 |
|---|---|
| volatile | 读后 LoadLoad+LoadStore,写前 StoreStore,写后 StoreLoad |
| synchronized | monitorenter 相当于读屏障,monitorexit 相当于写屏障(保证临界区内存可见) |
| final 字段 | 写 final 字段后插入 StoreStore,保证构造完成前不被发布 |
| CAS/原子类 | 底层 lock cmpxchg,自带全能屏障效果 |
| Thread.start() | 启动线程前插入屏障,保证 start 前的操作对子线程可见 |
八、一张图总览 volatile 写的内存语义
线程A 主内存 线程B
│ │ │
普通写 data=42 │ │
│ │ │
┌─────┴─────┐ │ │
│ StoreStore│ 保证 data 先刷出 │ │
└─────┬─────┘ │ │
│──────── data=42 刷到主存 ──→│ │
volatile 写 flag=true │ │
│──────── flag=true 刷到主存 ─→│ │
┌─────┴─────┐ │ │
│ StoreLoad │ 保证 flag 写先完成 │ │
└─────┬─────┘ │ │
│ │ volatile 读 flag │
│ │←──── 从主存读取 ────────┤
│ │ │
│ │ ┌──────────────┐ │
│ │ │LoadLoad+Store│ │
│ │ │Load 屏障 │ │
│ │ └──────┬───────┘ │
│ │ │ │
│ │←── 普通读 data ─────────┤
│ │ 读到 42(可见) │
总结口诀
写前 StoreStore,写后 StoreLoad;读后 LoadLoad + LoadStore。
StoreLoad 最贵,是全屏障;x86 靠 lock,ARM 靠 dmb。
屏障管时序,MESI 管一致,二者配合才有可见性。