四种内存屏障的代码示例详解
下面针对 LoadLoad、StoreStore、LoadStore、StoreLoad 四种屏障,各用一个可复现的经典例子,结合代码和内存图说明"如果没有屏障会发生什么"以及"屏障如何修复"。
⚠️ 说明:内存屏障是 JMM 的抽象概念,Java 源码中没有直接写屏障的语法 。下面用 volatile (JMM 自动插入屏障)或 Unsafe/原子类(显式屏障)来触发,并解释屏障在其中起的作用。
一、LoadLoad 屏障
语义
保证前一个读 先于后一个读完成,禁止两个读操作重排。
关键点
LoadLoad 主要防止读-读重排 ,但单看"两个读"很难观察出错。真正有意义的场景是:volatile 读之后,普通读不能被重排到 volatile 读之前,否则会读到过期数据。
代码示例
java
public class LoadLoadDemo {
static int data = 0; // 普通变量
static volatile boolean flag = false; // volatile 变量
// 线程 A:写数据
static void writer() {
data = 42;
flag = true; // volatile 写
}
// 线程 B:读数据
static void reader() {
if (flag) { // volatile 读
// ↓↓↓ 这里就是 LoadLoad 屏障的位置 ↓↓↓
// 保证"读 flag"先于"读 data",否则 data 可能读到 0
int d = data; // 普通读
System.out.println("data = " + d); // 保证是 42
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(LoadLoadDemo::writer);
Thread t2 = new Thread(LoadLoadDemo::reader);
t2.start();
t1.start();
t1.join();
t2.join();
}
}
如果没有 LoadLoad 屏障会怎样
线程B的执行顺序被重排:
1. 读 data ← 先读 data(此时 data 还是 0)
2. 读 flag ← 再读 flag(为 true)
3. 判断 flag=true,打印 data=0 ← 错误!
LoadLoad 屏障保证 :flag 读操作之后,data 的读操作不能被提到 flag 读之前,从而保证读到 flag=true 时,data 一定已是最新值 42。
屏障位置图
volatile 读 flag ←──────────┐
│ │
┌────┴─────┐ │ LoadLoad 屏障
│ LoadLoad │ 禁止下面的读上移 │ (JMM 在读后插入)
└────┬─────┘ │
│ │
普通读 data │
二、StoreStore 屏障
语义
保证前一个写 先于后一个写刷新到主存,禁止两个写操作重排。
经典场景:DCL 单例 / 对象安全发布
java
public class StoreStoreDemo {
private static volatile StoreStoreDemo instance;
private int value = 100; // 普通字段
private StoreStoreDemo() {
value = 100;
}
public static StoreStoreDemo getInstance() {
if (instance == null) {
synchronized (StoreStoreDemo.class) {
if (instance == null) {
// ↓↓↓ 这里展开就是 StoreStore 屏障 ↓↓↓
instance = new StoreStoreDemo();
}
}
}
return instance;
}
}
展开 instance = new StoreStoreDemo() 的三步
java
memory = allocate(); // ① 分配内存
ctorInstance(memory); // ② 初始化对象(写 value=100 等)
instance = memory; // ③ volatile 写,赋值引用
如果没有 StoreStore 屏障
编译器/CPU 可能重排成 ① → ③ → ②:
线程A: 分配内存 → instance 指向内存 → 初始化对象
↑
线程B: 在此时检查 instance != null,
拿到未初始化完成的对象,读到 value=0 ← 错误!
StoreStore 屏障保证 :volatile 写 instance 之前插入屏障,使步骤②(普通写 value)必须先于步骤③(volatile 写 instance)完成并刷新到主存。
屏障位置图
普通写 value = 100
│
┌────┴──────┐
│ StoreStore│ 禁止下面的写提前
└────┬──────┘
│
volatile 写 instance ← 保证 value 已刷新到主存
三、LoadStore 屏障
语义
保证前一个读 先于后一个写完成,禁止"读-写"重排。
代码示例:一个标志位控制的循环
java
public class LoadStoreDemo {
static volatile boolean running = true;
static int counter = 0;
static void worker() {
while (running) { // volatile 读
// ↓↓↓ 这里就是 LoadStore 屏障的位置 ↓↓↓
counter++; // 普通写
}
System.out.println("退出,counter = " + counter);
}
static void stopper() throws InterruptedException {
Thread.sleep(100);
running = false; // volatile 写
}
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(LoadStoreDemo::worker);
t.start();
stopper();
t.join();
}
}
如果没有 LoadStore 屏障
注意 counter++ 本身不是原子操作,但这里我们只关注屏障对"读 running"和"写 counter"的顺序约束。
设想编译器把 counter++(写)提前 到 running 读之前:
错误重排:
1. counter++ ← 写操作提前
2. 读 running
...
更典型的真实危害是:while (running) 读之后,紧随的普通写如果被重排到读之前,会导致程序在"检查条件"之前就修改了状态,破坏逻辑依赖。
LoadStore 屏障保证 :volatile 读 running 之后,counter++ 的写操作不能被提到 running 读之前,保证逻辑上的"先检查后操作"顺序。
屏障位置图
volatile 读 running
│
┌────┴─────┐
│LoadStore │ 禁止下面的写上移
└────┬─────┘
│
普通写 counter++
四、StoreLoad 屏障(最重要 / 开销最大)
语义
保证前一个写 先于后一个读 完成,且该写对后续读可见 。它是全能屏障,同时具备其他三种效果。
关键点
StoreLoad 是唯一在 x86 上需要真实指令 (lock 前缀 / mfence)的屏障。它要清空 Store Buffer,代价最大。
经典场景:用 volatile 实现自旋锁 / 安全发布
java
public class StoreLoadDemo {
static int data = 0;
static volatile boolean ready = false;
// 线程 A:发布数据
static void producer() {
data = 42; // 普通写
ready = true; // volatile 写
// ↓↓↓ volatile 写后插入 StoreLoad 屏障 ↓↓↓
// 保证 ready=true 已刷到主存,后续任何读都能看到
}
// 线程 B:消费数据
static void consumer() {
while (!ready) { // volatile 读,自旋
// 空转
}
// ↓ volatile 读后有 LoadLoad/LoadStore 屏障 ↓
System.out.println("data = " + data); // 保证是 42
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(StoreLoadDemo::producer);
Thread t2 = new Thread(StoreLoadDemo::consumer);
t2.start();
t1.start();
t1.join();
t2.join();
}
}
如果没有 StoreLoad 屏障
设想 ready = true(volatile 写)之后紧跟一个普通读:
线程A:
1. data = 42
2. ready = true ← 写进 Store Buffer,尚未刷到主存
3. 读某个普通变量 x ← 没有 StoreLoad 屏障,这个读可能先于 ready 的刷新完成
线程B:
看到 ready=false(因为 A 的写还在 Store Buffer 里)
StoreLoad 屏障保证 :ready = true 必须真正刷到主存(并让其他 CPU 缓存失效)之后 ,才允许后续的读写操作,从而保证线程 B 一旦看到 ready=true,data=42 也一定可见。
屏障位置图(x86 上的 lock 指令)
普通写 data = 42
│
volatile 写 ready = true
│
┌────┴──────┐
│ StoreLoad │ ← x86: lock addl $0,(%rsp) / mfence
│ │ 清空 Store Buffer + 广播缓存失效
└────┬──────┘
│
后续读/写
│
─────┴────→ 其他 CPU 此时才能读到 ready=true
五、四者对比总表
| 屏障 | 禁止的重排 | 典型触发场景 | 无屏障的危害 | x86 实现 |
|---|---|---|---|---|
| LoadLoad | 读 → 读 | volatile 读后读普通变量 | 读到过期数据 | 空操作 |
| StoreStore | 写 → 写 | volatile 写前写普通变量(DCL) | 对象未初始化就被发布 | 空操作 |
| LoadStore | 读 → 写 | volatile 读后写普通变量 | 逻辑顺序被破坏 | 空操作 |
| StoreLoad | 写 → 读 | volatile 写后读任意变量 | 写不可见,读到旧值 | lock / mfence |
六、补充:如何"看到"这些屏障
如果你想实证观察,可以:
1. 用 javap -v 看字节码
volatile 变量在字节码中有 ACC_VOLATILE 标志:
bash
javap -v LoadLoadDemo.class | grep -A2 "flag"
# 会看到 flags: ACC_STATIC, ACC_VOLATILE
2. 用 JIT 汇编日志看 lock 指令
bash
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly LoadLoadDemo
在 x86 上,volatile 写附近能看到 lock addl $0x0,(%rsp) 或 mfence。
3. 用 Unsafe 显式触发屏障(JDK9+ 用 VarHandle)
java
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
public class ExplicitBarrier {
static int x = 0, y = 0;
public static void main(String[] args) {
VarHandle.fullFence(); // StoreLoad 全屏障
VarHandle.acquireFence(); // 类似 LoadLoad + LoadStore
VarHandle.releaseFence(); // 类似 StoreStore + LoadLoad
}
}
VarHandle 的 fullFence/acquireFence/releaseFence 是 Java 层最接近"显式内存屏障"的 API。
总结
LoadLoad : 读后读 → volatile 读之后,普通读不能上移
StoreStore : 写前写 → volatile 写之前,普通写必须先完成
LoadStore : 读后写 → volatile 读之后,普通写不能上移
StoreLoad : 写后读 → volatile 写之后,写必须可见才能继续读
一句话记忆 :屏障名字 = "前操作 + 后操作",禁止这两类操作交换顺序;其中 StoreLoad 是全能屏障,也是性能分水岭。
附带其他重要笔记结论
