Lesson 20 · 并发编程
JMM 内存模型:happens-before 八条规则全解
面试官:你能解释一下为什么在多线程环境下,一个线程修改了变量,另一个线程可能看不到吗?
flag = true,另一个线程却一直读到 false——是 Bug 吗?不是。这是 Java 内存模型(JMM)在起作用。" —— 面试官想考察的不是你会不会用 synchronized,而是你是否真正理解并发编程的底层规则。在日常开发中,我们写多线程代码时往往依赖 synchronized、volatile、ReentrantLock 等工具。但如果追问"为什么加了 volatile 就能保证可见性?""为什么双重检查锁必须加 volatile?"——很多人就答不上来了。
这些问题的根源都指向同一个东西:JMM(Java Memory Model,Java 内存模型),即 JSR-133 规范。它定义了 Java 程序中各种内存操作之间的 happens-before 关系——这是理解一切并发问题的理论基础。
本文将沿着以下路线,逐层深入:
- 主内存与工作内存 —— JMM 的抽象架构
- 可见性、原子性、有序性 —— 并发编程的三大问题
- happens-before 八条规则 —— JMM 的核心法则
- 内存屏障 —— volatile 背后的硬件机制
- 指令重排序 —— 编译器和 CPU 的"自作主张"
- JMM vs JVM 内存模型 —— 澄清最常见的概念混淆
主内存与工作内存
JMM 在逻辑上把内存划分为两个区域:主内存(Main Memory)和工作内存(Working Memory)。
- 主内存:所有线程共享,存放所有变量(实例字段、静态字段、数组元素)
- 工作内存:每个线程私有,是主内存中变量的本地副本(类比 CPU 缓存)
线程对变量的所有操作(读/写)都必须在自己的工作内存中进行,不能直接操作主内存。这就意味着:
- 读变量:从主内存拷贝到工作内存 → 使用
- 写变量:在工作内存中修改 → 刷回主内存
问题在于——刷回的时机不确定。如果一个线程修改了变量但没有及时刷回主内存,或者另一个线程没有从主内存重新加载,就会出现"看不到"的情况。
三大问题:可见性、原子性、有序性
JMM 的设计背景是:多线程环境下会出现三类问题。每一种都有经典的代码案例。
1. 可见性(Visibility)
一个线程的修改对另一个线程不可见。根源:工作内存未及时同步。
class VisibilityDemo {
static boolean flag = false;
static int number = 0;
// Thread-1
static void writer() {
number = 42; // 写入
flag = true; // 标志位
}
// Thread-2
static void reader() {
if (flag) { // 看到了 flag=true
System.out.println(number); // 但 number 可能还是 0!
}
}
}
没有 volatile 或锁的保护,即使 Thread-2 看到 flag=true,也可能看到 number=0——因为指令可能被重排序,或者 number 还没从工作内存刷回主内存。
JMM 定义了 8 种内存操作指令来规范主内存和工作内存之间的交互:
// 主内存操作:
lock → 锁定主内存中的变量(标识为线程独占)
unlock → 解锁主内存中的变量
read → 从主内存读取变量值(传输到工作内存)
write → 将工作内存的值写入主内存
// 工作内存操作:
load → 将 read 得到的值放入工作内存副本
store → 将工作内存的值传送到主内存(配合 write)
use → 将工作内存的值传递给执行引擎
assign → 将执行引擎的值赋给工作内存变量
一次完整的变量读取:read → load → use;一次完整的变量写入:assign → store → write。问题就出在 assign 到 write 之间存在延迟。
2. 原子性(Atomicity)
复合操作不是原子的,中间可能被打断。
class Counter {
static int count = 0;
// i++ 实际是三步:read → increment → write
static void increment() { count++; }
public static void main(String[] args) throws Exception {
Thread[] threads = new Thread[100];
for (int i = 0; i < 100; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) increment();
});
threads[i].start();
}
for (Thread t : threads) t.join();
System.out.println(count); // 期望 100000,实际 < 100000
}
}
3. 有序性(Ordering)
编译器和 CPU 可能对指令进行重排序,导致程序执行顺序与代码顺序不一致。
class ReorderDemo {
static int a = 0, b = 0;
static int x = 0, y = 0;
public static void main(String[] args) throws Exception {
Thread t1 = new Thread(() -> { a = 1; x = b; }); // 可能被重排为 x=b; a=1;
Thread t2 = new Thread(() -> { b = 1; y = a; }); // 可能被重排为 y=a; b=1;
t1.start(); t2.start(); t1.join(); t2.join();
// 理论上 x=0, y=0 是可能出现的!
}
}
| 问题 | 根源 | 解决方案 |
|---|---|---|
| 可见性 | 工作内存未及时同步 | volatile、synchronized、final |
| 原子性 | 复合操作非原子 | synchronized、AtomicInteger、Lock |
| 有序性 | 编译器/CPU 重排序 | volatile、happens-before 规则 |
happens-before 八条规则
happens-before 是 JMM 的核心概念。如果操作 A happens-before 操作 B,那么 A 的结果对 B 可见。JMM 定义了八条规则:
若 A happens-before B,则 A 产生的内存写入,在 B 执行时一定已经刷到主内存并对 B 可见。
规则 1:程序顺序规则(Program Order Rule)
同一个线程内,前面的操作 happens-before 后面的操作。
int a = 1; // A
int b = 2; // B
// 在单线程中:A happens-before B
// 编译器/CPU 可以重排序,但必须保证单线程结果一致(as-if-serial)
规则 2:监视器锁规则(Monitor Lock Rule)
对同一个锁的 unlock 操作 happens-before 后续的 lock 操作。
class SyncExample {
int value = 0;
synchronized void set(int v) { value = v; } // unlock happens-before...
synchronized int get() { return value; } // ...后续 lock
}
// Thread-1 调用 set(42) → unlock
// Thread-2 调用 get() → lock → 一定看到 42
规则 3:volatile 变量规则(Volatile Variable Rule)
对 volatile 变量的写操作 happens-before 后续对该变量的读操作。
volatile boolean ready = false;
int data = 0;
// Thread-1
data = 42; // A
ready = true; // B (volatile write)
// Thread-2
if (ready) { // C (volatile read) → B happens-before C
print(data); // 一定看到 42(A→B→C 传递性)
}
规则 4:线程启动规则(Thread Start Rule)
Thread.start() 调用 happens-before 被启动线程中的任何操作。
int config = loadConfig();
Thread worker = new Thread(() -> {
// 这里一定能看到 config 的值
// start() happens-before 此线程中的任何操作
process(config);
});
worker.start(); // 这个调用 happens-before worker 线程内的所有代码
规则 5:线程终止规则(Thread Join Rule)
线程中的所有操作 happens-before 其他线程成功从该线程的 join() 返回。
Thread worker = new Thread(() -> {
result = compute(); // 计算结果
});
worker.start();
worker.join(); // join 返回后
print(result); // 一定能看到 worker 线程中写入的 result
规则 6:线程中断规则(Thread Interrupt Rule)
对线程 interrupt() 的调用 happens-before 被中断线程检测到中断事件。
Thread t = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
// 当 interrupt() 被调用,isInterrupted() 一定返回 true
// interrupt() happens-before isInterrupted() 检测到中断
}
});
t.start();
t.interrupt(); // 中断信号一定对目标线程可见
规则 7:终结器规则(Finalizer Rule)
对象的构造函数 happens-before 该对象的 finalize() 方法。
class Resource {
FileInputStream stream;
Resource() { stream = new FileInputStream("data.bin"); } // 构造
protected void finalize() {
// 一定能看到构造函数中初始化的 stream
// 构造 happens-before finalize
stream.close();
}
}
// 注:Java 9+ 已弃用 finalize,推荐 Cleaner / try-with-resources
规则 8:传递性(Transitivity)
如果 A happens-before B,B happens-before C,那么 A happens-before C。
// 这是 happens-before 关系最重要的性质
// volatile 的经典用法就是利用传递性:
int a = 1; // A
int b = 2; // B
volatile boolean flag = true; // C(volatile write)
// A → B → C (程序顺序) → volatile read → 后续操作
// 传递性保证:读到 flag=true 的线程,一定能看到 a=1, b=2
内存屏障(Memory Barriers)
happens-before 是规范层面的保证,而内存屏障是硬件层面的实现机制。JMM 通过四种内存屏障来控制指令顺序和内存可见性:
| 屏障类型 | 作用 | 禁止的重排序 |
|---|---|---|
| LoadLoad | 确保 Load1 在 Load2 之前完成 | Load1 ↔ Load2 |
| LoadStore | 确保 Load 在 Store 之前完成 | Load1 ↔ Store2 |
| StoreLoad | 确保 Store 在 Load 之前完成(万能屏障) | Store1 ↔ Load2 |
| StoreStore | 确保 Store1 在 Store2 之前完成 | Store1 ↔ Store2 |
volatile 的底层实现就是内存屏障:
- volatile 写之后插入
StoreStore+StoreLoad屏障 - volatile 读之前插入
LoadLoad+LoadStore屏障
在 x86 架构上,StoreLoad 屏障通常由 lock 前缀指令实现(如 lock addl $0, (%rsp)),它会把当前处理器的写缓冲区(Store Buffer)全部刷新到缓存,从而保证可见性。
值得注意的是,不同 CPU 架构的内存模型强弱不同:
| 架构 | 内存模型 | 特点 |
|---|---|---|
| x86/x64 | TSO(Total Store Order) | 强内存模型,仅允许 Store→Load 重排 |
| ARM | 弱内存模型 | 几乎所有重排都可能,需要更多屏障 |
| POWER | 弱内存模型 | 比 ARM 更宽松 |
这也是为什么 JMM 要在语言层面定义 happens-before——屏蔽底层硬件差异。Java 程序只要在 JMM 规则下编写,就能在 x86、ARM、RISC-V 上得到一致的行为。
volatile 读写性能差异:volatile 读在 x86 上几乎没有额外开销(因为 x86 本身就保证读可见性),但 volatile 写需要刷新 Store Buffer,开销与普通写相比高出一个数量级。在高频写场景下,应考虑用 VarHandle 的 opaque/acquire-release 模式代替。
StoreLoad 屏障?因为写操作之后可能是读操作。如果不加这个屏障,写可能还在 Store Buffer 中没有刷出,后续的读就可能看到旧值。StoreLoad 是四种屏障中开销最大的,这也是 volatile 写比 volatile 读更昂贵的原因。指令重排序
指令重排序可能发生在三个层面:
| 层面 | 执行者 | 示例 |
|---|---|---|
| 编译器重排序 | javac / JIT | 在不改变单线程语义的前提下调整指令顺序 |
| CPU 重排序 | 处理器 | 乱序执行(Out-of-order Execution) |
| 内存系统重排序 | 缓存/写缓冲区 | Store Buffer 导致写入延迟可见 |
as-if-serial 语义
不管怎么重排序,单线程程序的执行结果不能被改变。这叫 as-if-serial 语义——"好像"是按顺序执行的。编译器和 CPU 只对单线程负责,多线程的正确性需要程序员通过 happens-before 关系来保证。
经典案例 1:双重检查锁定(Double-Checked Locking)
class Singleton {
private static volatile Singleton instance; // 必须 volatile!
public static Singleton getInstance() {
if (instance == null) { // 第一次检查(无锁)
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查(有锁)
instance = new Singleton(); // 问题在这里!
}
}
}
return instance;
}
}
为什么必须加 volatile?因为 new Singleton() 在字节码层面是三步:
- 分配内存空间
- 调用构造函数初始化
- 将引用指向分配的内存
如果没有 volatile,步骤 2 和 3 可能被重排序为 1→3→2。此时另一个线程检查到 instance != null,但对象还没有初始化完成——拿到的是半初始化对象。加了 volatile 后,StoreStore 屏障禁止步骤 2 和 3 重排。
经典案例 2:延迟初始化
class LazyInit {
static Resource resource;
static Resource get() {
if (resource == null) {
resource = new Resource(); // 非 volatile,可能拿到半初始化对象
}
return resource;
}
}
// 解决方案:用 volatile,或用 Holder 内部类(静态内部类延迟加载)
JMM vs JVM 内存模型
这是面试中最容易混淆的概念。很多人把 JMM 和 JVM 内存模型混为一谈,其实它们解决的是完全不同的问题:
| 维度 | JMM(Java 内存模型) | JVM 内存模型(运行时数据区) |
|---|---|---|
| 规范来源 | JSR-133(JLS 第 17 章) | JVMS(JVM 规范) |
| 核心关注 | 多线程间的内存可见性 | JVM 运行时的内存布局 |
| 关键概念 | happens-before、volatile、原子性 | 堆、栈、方法区、程序计数器 |
| 解决的问题 | 线程 A 的写入何时对线程 B 可见 | 对象在哪分配、栈帧如何组织 |
| 类比 | 交通规则(谁先走谁后走) | 道路规划(车道、停车场、收费站) |
简单总结:
JMM = 多线程并发的规则(什么时候能看到别人的修改)
JVM 内存模型 = 程序运行的布局(堆在哪、栈在哪、方法区在哪)
// 面试官问:"请解释一下 Java 的内存模型"
// 先确认他问的是哪个:
// 如果问的是 JMM(JSR-133):
// → 聊 happens-before、volatile、可见性
// 如果问的是 JVM 运行时数据区:
// → 聊堆(新生代/老年代)、栈、方法区/元空间
// 如果不确定,可以主动区分:
// "Java 内存模型有两个层面,一个是 JSR-133 定义的
// 线程间可见性模型,另一个是 JVM 的运行时数据区划分,
// 您想了解哪方面?"
总结:happens-before 速查与实践指南
以下是 happens-before 八条规则的速查表:
| # | 规则 | 含义 | 实践场景 |
|---|---|---|---|
| 1 | 程序顺序 | 同一线程内,前 → 后 | 单线程代码无需担心 |
| 2 | 监视器锁 | unlock → 后续 lock | synchronized 保证可见性 |
| 3 | volatile 变量 | 写 → 后续读 | 状态标志、双重检查锁 |
| 4 | 线程启动 | start() → 新线程内操作 | 传递初始配置给子线程 |
| 5 | 线程终止 | 线程内操作 → join() 返回 | 获取子线程计算结果 |
| 6 | 线程中断 | interrupt() → 检测中断 | 线程间协作停止 |
| 7 | 终结器 | 构造函数 → finalize() | 资源清理(已废弃) |
| 8 | 传递性 | A→B, B→C ⟹ A→C | volatile piggyback 效应 |
- 共享可变状态必须用同步机制保护——synchronized、volatile 或 JUC 工具类
- volatile 适合一写多读的状态标志,不适合
count++这类复合操作 - 双重检查锁(DCL)中的实例变量必须声明为 volatile
- 优先使用不可变对象——final 字段在构造完成后天然对其他线程可见
- 不要依赖"经验主义"——"我跑了 100 次都没出问题"不代表代码正确,并发 Bug 可能在生产环境潜伏数月