Lesson 20 · 并发编程

JMM 内存模型:happens-before 八条规则全解

深度·🔥 极高·#并发·#JMM·#深度

第 1 站

面试官:你能解释一下为什么在多线程环境下,一个线程修改了变量,另一个线程可能看不到吗?

"你写了 flag = true,另一个线程却一直读到 false——是 Bug 吗?不是。这是 Java 内存模型(JMM)在起作用。" —— 面试官想考察的不是你会不会用 synchronized,而是你是否真正理解并发编程的底层规则。

在日常开发中,我们写多线程代码时往往依赖 synchronizedvolatileReentrantLock 等工具。但如果追问"为什么加了 volatile 就能保证可见性?""为什么双重检查锁必须加 volatile?"——很多人就答不上来了。

这些问题的根源都指向同一个东西:JMM(Java Memory Model,Java 内存模型),即 JSR-133 规范。它定义了 Java 程序中各种内存操作之间的 happens-before 关系——这是理解一切并发问题的理论基础。

本文将沿着以下路线,逐层深入:

  • 主内存与工作内存 —— JMM 的抽象架构
  • 可见性、原子性、有序性 —— 并发编程的三大问题
  • happens-before 八条规则 —— JMM 的核心法则
  • 内存屏障 —— volatile 背后的硬件机制
  • 指令重排序 —— 编译器和 CPU 的"自作主张"
  • JMM vs JVM 内存模型 —— 澄清最常见的概念混淆
核心观点
理解 JMM 不是为了应付面试——它是你写出正确并发程序的唯一理论基础。不懂 JMM,你写的多线程代码就是"碰巧能跑"。
第 2 站

主内存与工作内存

JMM 在逻辑上把内存划分为两个区域:主内存(Main Memory)工作内存(Working Memory)

  • 主内存:所有线程共享,存放所有变量(实例字段、静态字段、数组元素)
  • 工作内存:每个线程私有,是主内存中变量的本地副本(类比 CPU 缓存)

线程对变量的所有操作(读/写)都必须在自己的工作内存中进行,不能直接操作主内存。这就意味着:

  1. 读变量:从主内存拷贝到工作内存 → 使用
  2. 写变量:在工作内存中修改 → 刷回主内存

问题在于——刷回的时机不确定。如果一个线程修改了变量但没有及时刷回主内存,或者另一个线程没有从主内存重新加载,就会出现"看不到"的情况。

JMM 内存架构:主内存与工作内存 主内存 (Main Memory) x = 10 flag=F cnt = 5 Thread-1 工作内存 x = 10 flag=T CPU Core 0 / L1 Cache Thread-2 工作内存 x = 10 flag=F CPU Core 1 / L1 Cache load/store load/store flag 不一致!Thread-2 看到旧值
图 1Thread-1 将 flag 改为 true 并刷回工作内存,但 Thread-2 的工作内存仍是旧值 false——这就是可见性问题
面试追问
"工作内存"并不是真实的 JVM 内存区域,而是 JMM 的抽象概念。它的物理对应是 CPU 寄存器、L1/L2 缓存以及编译器优化产生的临时存储。
第 3 站

三大问题:可见性、原子性、有序性

JMM 的设计背景是:多线程环境下会出现三类问题。每一种都有经典的代码案例。

1. 可见性(Visibility)

一个线程的修改对另一个线程不可见。根源:工作内存未及时同步。

VisibilityDemo.java · 可见性问题
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 种内存操作指令来规范主内存和工作内存之间的交互:

JMM 8 种内存操作 · 完整交互协议
// 主内存操作:
lock      → 锁定主内存中的变量(标识为线程独占)
unlock    → 解锁主内存中的变量
read      → 从主内存读取变量值(传输到工作内存)
write     → 将工作内存的值写入主内存

// 工作内存操作:
load      → 将 read 得到的值放入工作内存副本
store     → 将工作内存的值传送到主内存(配合 write)
use       → 将工作内存的值传递给执行引擎
assign    → 将执行引擎的值赋给工作内存变量

一次完整的变量读取:read → load → use;一次完整的变量写入:assign → store → write。问题就出在 assign 到 write 之间存在延迟。

2. 原子性(Atomicity)

复合操作不是原子的,中间可能被打断。

AtomicityDemo.java · i++ 不是原子操作
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 可能对指令进行重排序,导致程序执行顺序与代码顺序不一致。

OrderingDemo.java · 指令重排序
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 规则
第 4 站

happens-before 八条规则

happens-before 是 JMM 的核心概念。如果操作 A happens-before 操作 B,那么 A 的结果对 B 可见。JMM 定义了八条规则:

happens-before 定义
若 A happens-before B,则 A 产生的内存写入,在 B 执行时一定已经刷到主内存并对 B 可见。

规则 1:程序顺序规则(Program Order Rule)

同一个线程内,前面的操作 happens-before 后面的操作。

规则1 · 程序顺序
int a = 1;   // A
int b = 2;   // B
// 在单线程中:A happens-before B
// 编译器/CPU 可以重排序,但必须保证单线程结果一致(as-if-serial)

规则 2:监视器锁规则(Monitor Lock Rule)

对同一个锁的 unlock 操作 happens-before 后续的 lock 操作。

规则2 · synchronized 保证可见性
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 后续对该变量的读操作。

规则3 · volatile 保证可见性
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 被启动线程中的任何操作。

规则4 · Thread.start()
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() 返回。

规则5 · Thread.join()
Thread worker = new Thread(() -> {
    result = compute();  // 计算结果
});
worker.start();
worker.join();          // join 返回后
print(result);          // 一定能看到 worker 线程中写入的 result

规则 6:线程中断规则(Thread Interrupt Rule)

对线程 interrupt() 的调用 happens-before 被中断线程检测到中断事件。

规则6 · interrupt()
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() 方法。

规则7 · Finalizer
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。

规则8 · 传递性
// 这是 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
关键
八条规则中,程序顺序规则、监视器锁规则、volatile 变量规则是日常用得最多的三条。传递性则是理解 volatile "piggyback" 效应的关键——volatile 写之前的所有写操作,对后续 volatile 读都可见。
第 5 站

内存屏障(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 屏障
volatile 读写与内存屏障位置 volatile 写操作 普通写操作 (Store) 普通写操作 (Store) StoreStore 屏障 volatile 写 StoreLoad 屏障 普通读操作 (Load) volatile 读操作 普通读操作 (Load) LoadLoad 屏障 LoadStore 屏障 volatile 读 LoadLoad 屏障 LoadStore 屏障 普通写操作 (Store)
图 2volatile 写前后插入 StoreStore + StoreLoad 屏障;volatile 读后插入 LoadLoad + LoadStore 屏障——这就是 volatile 能保证可见性和禁止重排序的硬件实现

在 x86 架构上,StoreLoad 屏障通常由 lock 前缀指令实现(如 lock addl $0, (%rsp)),它会把当前处理器的写缓冲区(Store Buffer)全部刷新到缓存,从而保证可见性。

值得注意的是,不同 CPU 架构的内存模型强弱不同:

架构内存模型特点
x86/x64TSO(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 模式代替。

深度追问
为什么 volatile 写之后需要 StoreLoad 屏障?因为写操作之后可能是读操作。如果不加这个屏障,写可能还在 Store Buffer 中没有刷出,后续的读就可能看到旧值。StoreLoad 是四种屏障中开销最大的,这也是 volatile 写比 volatile 读更昂贵的原因。
第 6 站

指令重排序

指令重排序可能发生在三个层面:

层面执行者示例
编译器重排序javac / JIT在不改变单线程语义的前提下调整指令顺序
CPU 重排序处理器乱序执行(Out-of-order Execution)
内存系统重排序缓存/写缓冲区Store Buffer 导致写入延迟可见

as-if-serial 语义

不管怎么重排序,单线程程序的执行结果不能被改变。这叫 as-if-serial 语义——"好像"是按顺序执行的。编译器和 CPU 只对单线程负责,多线程的正确性需要程序员通过 happens-before 关系来保证。

经典案例 1:双重检查锁定(Double-Checked Locking)

DCL.java · 必须用 volatile
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() 在字节码层面是三步:

  1. 分配内存空间
  2. 调用构造函数初始化
  3. 将引用指向分配的内存

如果没有 volatile,步骤 2 和 3 可能被重排序为 1→3→2。此时另一个线程检查到 instance != null,但对象还没有初始化完成——拿到的是半初始化对象。加了 volatile 后,StoreStore 屏障禁止步骤 2 和 3 重排。

经典案例 2:延迟初始化

LazyInit.java · 不安全的延迟初始化
class LazyInit {
    static Resource resource;

    static Resource get() {
        if (resource == null) {
            resource = new Resource();  // 非 volatile,可能拿到半初始化对象
        }
        return resource;
    }
}
// 解决方案:用 volatile,或用 Holder 内部类(静态内部类延迟加载)
"如果我的对象是 immutable(不可变的),还需要 volatile 吗?" —— 需要。即使对象本身不可变,引用的赋值仍然可能被重排序。volatile 保护的是引用本身的可见性和有序性。
第 7 站

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 的运行时数据区划分,
//  您想了解哪方面?"
易错点
很多博客把"堆/栈/方法区"说成是 JMM 的内容,这是错误的。JMM 只关心主内存和工作内存之间的同步规则,跟堆栈布局完全无关。面试时能主动区分这两个概念,会让面试官对你刮目相看。
第 8 站

总结:happens-before 速查与实践指南

以下是 happens-before 八条规则的速查表:

#规则含义实践场景
1程序顺序同一线程内,前 → 后单线程代码无需担心
2监视器锁unlock → 后续 locksynchronized 保证可见性
3volatile 变量写 → 后续读状态标志、双重检查锁
4线程启动start() → 新线程内操作传递初始配置给子线程
5线程终止线程内操作 → join() 返回获取子线程计算结果
6线程中断interrupt() → 检测中断线程间协作停止
7终结器构造函数 → finalize()资源清理(已废弃)
8传递性A→B, B→C ⟹ A→Cvolatile piggyback 效应
日常编码实践要点:
  • 共享可变状态必须用同步机制保护——synchronized、volatile 或 JUC 工具类
  • volatile 适合一写多读的状态标志,不适合 count++ 这类复合操作
  • 双重检查锁(DCL)中的实例变量必须声明为 volatile
  • 优先使用不可变对象——final 字段在构造完成后天然对其他线程可见
  • 不要依赖"经验主义"——"我跑了 100 次都没出问题"不代表代码正确,并发 Bug 可能在生产环境潜伏数月
终极理解
JMM 的本质是一套契约:只要你遵守 happens-before 规则编写代码,Java 就保证你的程序在任何硬件、任何 JVM 实现上都能得到正确结果。反之,如果你绕过了这些规则(比如不使用 volatile 就共享可变变量),那你就是在赌——赌编译器不重排、赌 CPU 不乱序、赌缓存一致性协议刚好帮你同步。在复杂的现代硬件上,这个赌大概率会输。
面试加分项:能画出主内存/工作内存架构图,能说出 volatile 底层对应的内存屏障类型,能解释 DCL 为什么必须加 volatile,能区分 JMM 和 JVM 内存模型——这四样答出来,面试官基本会给你打高分。