首页 / Java 学习笔记 / 20

JAVA · Vol.II · DAY 20 · 并发编程

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

深度极高#并发#JMM#深度
第 1 站

一次"改不生效"的线上事故

真实生产事故

凌晨 1 点 20 分,值班电话把你叫醒:新版本刚发布,灰度节点上的老逻辑还在跑,流量没有切过去。你登上去一看——配置中心明明把开关 flag 置成了 true,服务端日志也显示"已收到新配置",可业务线程读到的 flag 一直是 false。重启节点,好了;半小时后,又坏了。

是配置中心的问题吗?不是。是 flag 的锅——它是个普通 boolean,每个线程在自己的工作内存里缓存了旧值 false,主线程改的 true 它压根没重新读。这就是并发编程里最经典的可见性(Visibility)问题,而它的理论根源,就是本文的主角 JMM(Java Memory Model,Java 内存模型)

"你写了 flag = true,另一个线程却一直读到 false——是 Bug 吗?严格说不是代码 Bug,是没遵守 JMM 规则。" —— 面试官想考察的不是你会不会用 synchronized,而是你是否真正理解并发编程的底层规则。

先给你一个 30 秒版答案,后面每一站都在给这句话"上细节":

30 秒版答案
JMM 是 JSR-133 定义的跨平台并发规范:主内存 + 工作内存 + happens-before 八条规则 + 内存屏障。
并发三性各有人负责:原子性靠 synchronized / Lock / CAS;可见性靠 volatile / synchronized / final;有序性靠 volatile / happens-before 规则。
遇到并发 Bug,先别急着加锁,用 happens-before 判断"到底缺哪条规则"。

在日常工作中,JMM 是你写的每一行并发代码的"交通规则":synchronizedvolatileReentrantLock、线程池、CompletableFuture……这些工具的正确性,全部建立在这套规则之上。

本文的路线图:

  • 主内存与工作内存 —— JMM 的抽象架构(第 2 站)
  • 为什么需要 JMM —— 各 CPU 内存模型不一致,需要统一交规(第 3 站)
  • 8 大原子操作 —— 主内存/工作内存的交互协议(第 4 站)
  • 可见性、原子性、有序性 —— 并发三大问题(第 5 站)
  • 指令重排序 —— 编译器与 CPU 的"自作主张"(第 6 站)
  • happens-before 八条规则 —— JMM 的核心法则(第 7–10 站)
  • 内存屏障 —— volatile 背后的硬件机制(第 11–12 站)
  • final 域语义 —— 免同步的"安全通道"(第 14 站)
  • JMM vs JVM 内存模型 —— 澄清最常见的概念混淆(第 16 站)
核心观点
理解 JMM 不是为了应付面试——它是你写出正确并发程序的唯一理论基础。不懂 JMM,你写的多线程代码就是"碰巧能跑",线上迟早还你一个凌晨的电话。
第 2 站

主内存与工作内存:JMM 的抽象架构

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

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

把主内存想象成公司的中央数据库,把每个线程的工作内存想象成你工位上贴着的便利贴:你算出来的中间数字先记在便利贴上,只有你主动"同步回数据库",别人(其它线程)才能查到;别人也只有在"重新拉取"之后,才能看到你写的新值。问题在于——什么时候同步、什么时候拉取,时机不确定。有人 1 毫秒就同步了,有人攒着 100 毫秒才同步,于是同一个变量在不同线程眼里就是不同的值。

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

  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 缓存以及编译器优化产生的临时存储。注意:JMM 的"工作内存" ≠ JVM 运行时数据区的"栈",这两个"内存"是两个体系,第 16 站专门区分。
追问:CPU 不是有缓存一致性协议(如 x86 的 MESI)吗?为什么 Java 还要自己搞一套工作内存模型?
点破

MESI 只保证单个缓存行在硬件层面的一致性,它管不了两件事:① 编译器和 CPU 的指令重排序(第 6 站讲);② 写缓冲区(Store Buffer)造成的"写后读自己、别人读旧值"的延迟。而且不同架构的缓存一致性实现差异巨大——JMM 干脆抽象出"主内存 + 工作内存"这套统一模型,把底层差异全部屏蔽掉。这就是为什么说 JMM 是"规范",不是"物理实现"。

核心要点
  • 所有共享变量都在主内存,线程只操作自己的工作内存副本
  • 可见性问题 = 工作内存副本没刷回 / 没重读,时机不确定;
  • "工作内存"是抽象概念,物理对应寄存器 + 缓存 + 编译器临时存储,不是 JVM 栈。
第 3 站

为什么需要 JMM:各 CPU 的内存模型不一样

面试官如果追问一句"JMM 是怎么来的",你要能答出背后的硬件现实:现代 CPU 为了性能,普遍做了三件"越界"的事——

  • 写缓冲区(Store Buffer):写操作先进缓冲区,稍后再落缓存/内存,导致"别人看到旧值";
  • 乱序执行(Out-of-Order Execution):CPU 发现指令间没有数据依赖,就提前执行后面的指令;
  • 多级缓存:L1/L2/L3 各自为政,缓存一致性协议只能解决"缓存行"级别的一致。

更要命的是,不同架构的"宽松程度"完全不同

架构内存模型强弱特点
x86/x64TSO(Total Store Order)强内存模型仅允许 Store→Load 重排;日常开发的主力环境
ARM弱内存模型几乎所有重排都可能发生,需要更多屏障
POWER弱内存模型更弱比 ARM 更宽松,屏障要求更高

同一段 Java 代码,不加任何同步,在 x86 上可能"碰巧能跑对",搬到 ARM 服务器(手机、云上 ARM 实例)上就出并发问题。怎么办?两个方案:

  • 方案 A:为每个架构各写一套代码——维护成本爆炸,且 Java 号称"一次编写,到处运行";
  • 方案 B:在语言层面定义一套统一的内存模型,由 JVM 负责把它翻译成各架构需要的屏障——这就是 JMM 的定位。
JMM:Java 程序的"统一交通规则" Java 并发代码(synchronized / volatile / …) JMM(JSR-133):happens-before + 内存屏障规范 只定义"最小保证",给编译器/CPU 留足优化空间 JVM 实现翻译成 x86 需要的 lock/mfence JVM 实现翻译成 ARM 需要的 dmb 屏障 JVM 实现翻译成 POWER 需要的屏障序列 你只跟 JMM 打交道,跨平台一致行为由 JVM 兜底
图 2JMM 是语言层的统一契约:把同一套 happens-before 规则翻译成各 CPU 架构需要的内存屏障

一段不得不说的历史:JSR-133

JMM 不是一开始就这么完善。Java 5 之前的内存模型(旧 JMM)有三大硬伤:volatile 语义太弱(不能阻止重排)、final 字段没有任何可见性保证happens-before 规则残缺。结果是 DCL(双重检查锁)在旧模型下是错的、final 字段可能看到默认值——全是著名大坑。2004 年,JSR-133 正式重写了内存模型(JLS 第 17 章),确立了以 happens-before 为核心的新体系,这些坑才被系统性修复。所以"JDK 5 之前 DCL 不靠谱"这种说法,根源就在 JSR-133。

核心要点
  • CPU 有写缓冲区、乱序执行、多级缓存,且 x86 / ARM / POWER 的宽松程度天差地别;
  • JMM 的存在意义 = 跨平台统一 + 只给最小保证、给优化留空间
  • JSR-133(Java 5)重写 JMM,才有了今天的 happens-before 体系。
第 4 站

8 大原子操作:主内存与工作内存的交互协议

为了规范主内存和工作内存之间的数据搬运,JMM 定义了 8 种原子操作(Atomic Operations)——所谓"原子",就是这 8 个动作每一个都不可再拆分:

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

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

一次完整的变量读取:read → load → use;一次完整的变量写入:assign → store → write问题就出在 assign 到 write 之间存在不确定的延迟——赋值早已发生在工作内存,但什么时候 store + write 刷回主内存,JMM 不保证。

面试加分点:别把 8 操作讲"死"

这 8 个操作是 JMM 早期(JSR-133 草案阶段)提出的交互协议,帮你理解"主内存/工作内存怎么协作"。但JSR-133 正式落地后,规范层面(JLS 第 17 章)已经改用 happens-before 作为主模型来描述可见性——8 操作退居"教学工具"的地位。面试时先讲 8 操作,再补一句"不过现在规范以 happens-before 为准",面试官就知道你有体系,而不是背书。

追问:synchronized 和这 8 个操作是什么关系?

lock / unlock 正是 synchronized 在 JMM 层面的对应物:进入同步块时对锁对象执行 lock,退出时执行 unlockunlock 之前的所有写操作必须已刷回主内存,lock 之后的所有读操作必须重新从主内存加载——这就是"解锁的写对加锁的读可见"的底层来源(第 8 站规则 2)。

核心要点
  • 8 操作分两组:主内存侧 lock/unlock/read/write,工作内存侧 load/store/use/assign;
  • 读 = read→load→use,写 = assign→store→write,中间环节的"时机"不受控;
  • 现代规范以 happens-before 为主,8 操作用于理解分工。
第 5 站

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

JMM 的存在就是为了解决多线程的三大问题。每一种都有经典的代码案例——面试让你"手写一个并发 Bug",这三个就是现成模板。

1. 可见性(Visibility):一个线程的修改,另一个线程看不到

根源:工作内存未及时同步。典型场景就是第 1 站的"开关不生效"。

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!
        }
    }
}

即使 Thread-2 看到 flag=true,也可能看到 number=0——因为指令可能被重排序,或者 number 还没从工作内存刷回主内存。

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
    }
}

100 个线程各加 1000 次,期望 100000,实际往往差一截——因为两个线程可能同时读到 count=0,各自加 1 写回,一次递增被"吞"掉了

3. 有序性(Ordering):执行顺序和代码顺序不一样

根源:编译器和 CPU 的重排序。这是最能考倒人的一个,第 6 站单独展开。

ReorderDemo.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 是可能出现的!
    }
}

逻辑上不可能同时 x=0y=0(总有一个线程先写),但重排序后它真的会出现——这就是面试里著名的"0,0 结果"问题。

三大问题,各由谁负责?

问题根源由谁负责解决典型工具
可见性工作内存未及时同步volatile / synchronized / finalvolatile 标志位、锁
原子性复合操作非原子锁与 CAS(volatile 不负责!)synchronized、ReentrantLock、AtomicInteger、LongAdder
有序性编译器/CPU 重排序volatile / happens-before 规则volatile、锁(也有序性语义)
追问:volatile int count; count++ 线程安全吗?
点破

不安全。count++ 是"读-改-写"三步,volatile 只保证每一步的可见性,不保证三步之间不被其它线程插队。两个线程同时读到 count=5,各自加 1 写回,结果是 6 而不是 7。volatile 管可见性和有序性,原子性必须交给锁或 CAS。这也是面试高频陷阱。

核心要点
  • 可见性 = "看不看得见",原子性 = "会不会被插队",有序性 = "顺序乱不乱";
  • volatile 解决可见性 + 有序性,不解决原子性
  • 遇到并发问题先定位是"三性"里的哪一性,再选工具,别一把锁梭哈。
第 6 站

指令重排序:三层"自作主张"与 as-if-serial

重排序(Reordering)是"程序写的是 A、B,实际执行可能是 B、A"。它可能发生在三个层面:

层面执行者举例
编译器重排序javac / JIT(C1/C2)没有依赖关系的两条语句交换顺序,减少寄存器压力
CPU 重排序处理器乱序执行指令流水线提前执行后面无依赖的指令
内存系统重排序缓存 / 写缓冲区Store Buffer 让写操作"看起来"延后了

但重排序不是随心所欲的,它有一条铁律:不能破坏数据依赖(Data Dependency)

依赖类型示例能重排吗
写后读a = 1; b = a;❌ 不能(b 依赖 a 的新值)
写后写a = 1; a = 2;❌ 不能(顺序影响最终值)
读后写b = a; a = 1;❌ 不能
无依赖a = 1; b = 2;✅ 可以随意交换

把这两张表放一起,你就理解 ReorderDemo 为什么能出现 x=0, y=0 了:a=1x=b 之间没有数据依赖,编译器/CPU 有充分的理由交换它们。

as-if-serial:单线程的"遮羞布"

不管怎么重排,单线程程序的执行结果不能被改变,这叫 as-if-serial(似串行)语义——"好像"是按顺序执行的。编译器和 CPU 只对单线程负责,把"结果一致性"兜住;多线程的正确性,需要程序员自己用 happens-before 关系来保证

后端类比:出餐窗口

把重排序想象成后厨的出餐:厨师(CPU)发现"煮面"和"烧水"互不依赖,就提前把水烧上(重排),只要最后端给顾客的菜(单线程结果)不变,随便折腾。但如果有两个厨师共享一个灶台(多线程),一个厨师提前用了灶台,另一个厨师的菜就乱了——后厨之间没有协调机制(happens-before),就会出乱子

追问:我能不能写一个 Demo,真的复现出重排序?

可以,但很看运气。用 -XX:+PrintAssembly 看汇编能看到重排痕迹;想稳定复现"0,0 结果"通常要循环跑几万次加上 CPU 压力。更实用的验证工具是 jcstress(OpenJDK 官方的并发压力测试框架),专门用来验证并发正确性——JMM 相关的测试和生产级并发库都在用它。

核心要点
  • 重排序三来源:编译器、CPU、内存系统;
  • 有数据依赖的指令不能重排,无依赖的随便排;
  • as-if-serial 只保护单线程结果,多线程正确性靠 happens-before。
第 7 站

happens-before:JMM 的现代核心

JSR-133 之后,JMM 描述可见性的主模型不再是 8 个原子操作,而是 happens-before(先行发生)关系。它回答一个问题:线程 A 的写入,什么时候能保证线程 B 一定看得到?

happens-before 定义
若操作 A happens-before 操作 B,则:
① A 的所有内存写入,在 B 执行时对 B 可见
② A 不会被重排序到 B 之后(有序性)。

注意一个关键 nuance:happens-before 是"逻辑顺序保证",不是"物理时间先后"。JMM 并不要求 A 真的先于 B 在时间上执行完,它只保证"如果 B 看到了 A 的结果,那么 A 写的一切都对 B 可见"。这给编译器和 CPU 留出了重排空间——只要重排不破坏 happens-before 关系,就随便优化。

银行叫号类比

把 JMM 想象成银行大厅:A 号(写操作)在 B 号(读操作)之前叫号,那么柜台给 B 号办理业务时,A 号办的业务(存款/转账)一定已经入账。银行不保证 A 号客户"实际到达时间"一定早于 B 号,只保证叫号顺序——按号办理,先叫的先入账。happens-before 就是 JMM 的"叫号规则"。

happens-before 一共八条规则,分三组理解:

#规则一句话详细站
1程序顺序规则同一线程内,写在前的 happens-before 写在后的第 8 站
2监视器锁规则解锁 happens-before 后续对同一把锁的加锁第 8 站
3volatile 变量规则volatile 写 happens-before 后续对该变量的 volatile 读第 8 站
4线程启动规则start() happens-before 新线程内的任何操作第 9 站
5线程终止规则线程内所有操作 happens-before join() 返回第 9 站
6线程中断规则interrupt() happens-before 被中断线程检测到中断第 9 站
7对象终结规则构造完成 happens-before finalize() 执行第 10 站
8传递性A→B 且 B→C ⟹ A→C第 10 站
核心要点
  • happens-before = "前面的写,后面的读一定看得见";
  • 它是逻辑顺序保证,不是物理时间保证,给优化留空间;
  • 八条规则是 JMM 的全部"交通规则",背下来不难,难的是会用——接下来三站逐个实战。
第 8 站

规则 1–3:程序顺序、监视器锁、volatile

规则 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

生产里最常见的错误:写方法加了锁、读方法没加锁(或者两把不同的锁)。这时候规则 2 根本不成立——读线程可能看到旧值。所以"加锁要成对、锁对象要一致",这是代码评审时一眼就能抓的问题。

规则 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)
}

这正是第 1 站事故的解法:把 flag 声明成 volatile boolean,配置下发后所有线程立刻能看到。volatile 最经典的用法就是一写多读的状态标志(服务停止开关、配置开关)。

追问:规则 3 为什么能"捎带"上 data 的可见性?
点破

因为 data=42(A)和 ready=true(B)在同一线程内满足规则 1(A happens-before B),B 又 happens-before C(规则 3),由规则 8 传递性得到 A happens-before C——所以读线程看到 ready=true 时,data 一定已经是 42。这就是 volatile 的 piggyback(搭便车)效应,也是"标志位 + 数据"组合的合法性来源。

核心要点
  • 规则 1 只保护单线程;规则 2 要求"同一把锁、成对加锁";规则 3 是 volatile 的理论根基;
  • 规则 3 + 传递性 = volatile 能"捎带"其它普通变量可见性的原因;
  • 这三个规则覆盖了日常 80% 的并发场景。
第 9 站

规则 4–6:线程 start、join、interrupt

规则 4:线程启动规则(Thread Start Rule)

Thread.start() 调用 happens-before 被启动线程中的任何操作。也就是说:主线程在 start 之前写的一切,子线程一进来就都能看到,不需要任何同步。

规则4 · Thread.start()
int config = loadConfig();     // 主线程先准备好
Thread worker = new Thread(() -> {
    // 这里一定能看到 config 的值
    // start() happens-before 此线程中的任何操作
    process(config);
});
worker.start();  // 这个调用 happens-before worker 线程内的所有代码

生产落点:给子线程传初始化好的配置、上下文、连接,靠 start 规则就够,不用画蛇添足加 volatile。同样,线程池 execute() 提交任务也有类似保证:任务提交 happens-before 任务开始执行(JUC 内部通过 AQS 的 volatile state 实现发布)。

规则 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

生产落点:主线程等一批子线程跑完再汇总,join() 返回后直接读结果就是安全的,不需要额外的锁或 volatile。这也解释了为什么 Future.get() 返回后能安全读取任务结果——底层正是同一套发布语义(AQS 的 state 写入 + 读取)。

规则 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();  // 中断信号一定对目标线程可见

生产落点:优雅停机的推荐姿势就是"interrupt + 线程内检查中断标志",比"共享 boolean 停止标志"更规范(sleep/wait 时还能自动抛中断异常退出阻塞)。注意 interrupted()(静态,会清除标志)和 isInterrupted()(实例,不清除)的区别——写循环条件时用后者。

追问:三条规则串起来能干什么?
组合拳

start 规则保证"主线程的准备对子线程可见",join 规则保证"子线程的成果对主线程可见",interrupt 规则保证"停止信号对子线程可见"——线程生命周期的三个节点,JMM 都给你兜好了底。这也是为什么"线程间共享数据"在很多场景下根本不需要显式加锁。

核心要点
  • start 之前写的,子线程一定看得到;join 之后读的,一定看得到子线程写的;
  • interrupt 是协作式停机的官方通道,信号一定可见;
  • 线程池 execute/submit 的内部可见性,同样建立在 AQS 的 volatile 发布上。
第 10 站

规则 7–8:对象终结与传递性

规则 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();
    }
}
// 注意:finalize 已进入"死亡名单"——
// JDK 9 标记弃用,JDK 18(JEP 421)标记待移除
// 生产环境用 Cleaner 或 try-with-resources,别再用 finalize

这条规则现在更多是"考古"价值:finalize 的执行时机不确定、性能差、还容易出 bug,JVM 官方已明确要移除。记住结论就行:资源清理走 try-with-resources 或 Cleaner,finalize 面试题答完即弃

规则 8:传递性(Transitivity)

如果 A happens-before B,B happens-before C,那么 A happens-before C。这是八条规则里最"值钱"的一条,因为它让简单的规则组合出强大的效果。

规则8 · 传递性
// volatile 的经典用法就是利用传递性:
int a = 1;            // A
int b = 2;            // B
volatile boolean flag = true;  // C(volatile write)

// A → B → C(程序顺序)→(volatile 写读)→ 后续操作
// 传递性保证:读到 flag=true 的线程,一定能看到 a=1, b=2
生产案例:配置发布的"版本号"模式

配置中心下发一批配置(普通变量),再更新一个 volatile long version。客户端线程循环里先读 version,变了就整批重读配置。靠的就是传递性:配置写 → version 写(volatile)→ 客户端读 version → 重读配置,中间全靠规则 1 + 规则 3 + 规则 8 串起来,一行锁都不用加。这就是 happens-before 在生产里最优雅的用法。

八条规则合体
程序顺序 + 监视器锁 + volatile + start + join + interrupt + finalizer + 传递性
= JMM 的全部可见性保证。其它一切(内存屏障、8 操作)都是它们的实现细节。
核心要点
  • finalizer 规则是"历史包袱",生产用 Cleaner / try-with-resources;
  • 传递性是"放大器":把简单的规则组合成强大的可见性链条;
  • 配置发布、版本号切换等场景,靠 volatile + 传递性即可免锁实现。
第 11 站

内存屏障:happens-before 的硬件落地

happens-before 是规范层面的"承诺",而内存屏障(Memory Barrier / Memory Fence)是兑现承诺的硬件指令。JVM 在合适的位置插入屏障,CPU 看到屏障就"不敢乱动"。屏障一共四类,按"读写 × 读写"排列:

屏障类型作用禁止的重排序
LoadLoad确保 Load1 在 Load2 之前完成Load1 ↔ Load2
LoadStore确保 Load 在 Store 之前完成Load1 ↔ Store2
StoreLoad确保 Store 在 Load 之前完成(万能屏障)Store1 ↔ Load2
StoreStore确保 Store1 在 Store2 之前完成Store1 ↔ Store2

记忆技巧:名字里第一个操作在屏障前,第二个操作在屏障后。比如 StoreLoad = 屏障前的 Store 必须完成,屏障后的 Load 才能开始。

x86 上的实现:为什么强模型也要屏障

x86 是强内存模型(TSO),普通读写之间基本不乱序,唯独允许 Store → Load 重排(因为写缓冲区)。所以:

  • 普通场景(LoadLoad / StoreStore / LoadStore)在 x86 上大多数是空操作,硬件天然满足;
  • StoreLoad 是唯一必须显式执行的,x86 上由 mfence 指令或 lock 前缀指令(如 lock addl $0, 0(%rsp))实现——它们会把当前处理器的写缓冲区(Store Buffer)全部排空并刷到缓存。
面试追问:为什么 x86 是强模型,volatile 写还是比普通写贵?

因为 volatile 写在 x86 上必须执行一次 StoreLoad(mfence 或 lock 前缀),而 StoreLoad 是四类屏障里最贵的——它要等写缓冲区排空,相当于让 CPU "停下来喘口气"。而 volatile 读在 x86 上几乎免费(读天然强序)。所以结论:x86 上 volatile 读 ≈ 普通读,volatile 写贵一个数量级。在 ARM / POWER 这类弱模型上,读和写都需要屏障,都会更贵。

顺带说一句:JMM 的屏障要求是"保守放哨"的——它声明了最严格的插入位置,JIT 在具体平台上发现某类屏障是空操作(比如 x86 上的 LoadLoad)就省略掉。这也是"同一套 Java 代码,x86 上很快、ARM 上明显慢"的原因之一。

核心要点
  • 四类屏障 = 四种"读写顺序"的硬约束,名字即规则;
  • x86 只需 StoreLoad(mfence / lock 前缀),其余三类天然满足;
  • StoreLoad 最贵,这是 volatile 写在 x86 上变慢的根源。
第 12 站

volatile 读/写插入的屏障与生产建议

volatile 的底层实现就是内存屏障。JMM 规定(保守做法):

  • volatile 写之前插入 StoreStore 屏障(前面的普通写先落地),之后插入 StoreLoad 屏障(我的写立刻对外可见);
  • volatile 读之后插入 LoadLoad + LoadStore 屏障(后续普通读写不能越过我)。
volatile 读写与内存屏障位置 volatile 写操作 普通写操作 (Store) 普通写操作 (Store) StoreStore 屏障 volatile 写 StoreLoad 屏障 普通读操作 (Load) volatile 读操作 普通读操作 (Load) LoadLoad 屏障 LoadStore 屏障 volatile 读 LoadLoad 屏障 LoadStore 屏障 普通写操作 (Store)
图 3volatile 写前后插入 StoreStore + StoreLoad 屏障;volatile 读后插入 LoadLoad + LoadStore 屏障——这就是 volatile 能保证可见性和禁止重排序的硬件实现

把屏障落点和规则对上号:StoreStore 保证"写之前的写"先完成(配合规则 1),StoreLoad 保证写立刻对外可见(规则 3),读侧的 LoadLoad/LoadStore 保证读之后的普通操作不越过读(有序性)。

volatile 的完整语义

volatile = 可见性 + 有序性,绝不等于原子性
✅ 写 volatile 立即刷主内存,读 volatile 强制从主内存读(可见性)
✅ 禁止相关指令重排(有序性)
volatile int i; i++ 依然不是原子的(原子性不归它管)

生产里怎么用 / 别怎么用

场景该用什么为什么
服务停止开关、配置开关(一写多读)volatile boolean读多写少,volatile 读便宜,语义正好
DCL 单例的引用volatile阻止半初始化对象发布(第 13 站)
计数器 / 累加器(高频写)AtomicLong / LongAddervolatile 写贵 + 不原子,两样都占
多字段状态一致性synchronized / Lock / 不可变对象volatile 管不了"多个变量一起变"
追问:volatile 高频写场景有没有更快的替代?
点破

有。JDK 9+ 的 VarHandle 提供了比 volatile 更细粒度的访问模式:opaque(只保证原子性)、acquire/release(只保证单向),在 ARM 等弱模型上能省掉一半屏障;JDK 8 的 LongAdder 用分片累加解决高频写。原则:先问自己"我要的是可见性还是原子性",再挑最便宜的机制

核心要点
  • volatile 写 = StoreStore + StoreLoad,volatile 读 = LoadLoad + LoadStore;
  • x86 上 volatile 读几乎免费、写贵一个数量级;
  • 生产选型:一写多读用 volatile,计数器用 Atomic/LongAdder,多字段一致性用锁。
第 13 站

DCL 单例:为什么必须加 volatile

"双重检查锁(Double-Checked Locking)为什么必须 volatile?" —— 面试必问,答不上来基本告别高并发岗位。
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;
    }
}

new Singleton() 在字节码层面是三步:

  1. 分配内存空间
  2. 调用构造函数初始化对象
  3. 把引用指向分配的内存
new Singleton() 的三步与重排风险 ① 分配内存 obj = allocate() ② 初始化对象 ctor 执行,字段赋值 ③ 引用赋值 instance = obj 没有 volatile 时:②③ 可能被重排 ① 分配内存 obj = allocate() ③' 先赋值引用 instance = obj(还没构造!) 其它线程:instance != null?→ 直接用 → 拿到半初始化对象! 字段还是默认值 0 / null,一用就炸 加 volatile 后:③ 前的 StoreStore 屏障禁止 ②③ 重排
图 4没有 volatile 时,"初始化"与"引用赋值"可能重排,其它线程拿到半初始化对象

第一次检查(无锁)是性能优化:单例已经建好就直接返回,避免每次抢锁。但它也让重排序的后果暴露给了无锁路径的读者——所以必须用 volatile 的 StoreStore 屏障钉死 ②③ 的顺序。

版本事实:DCL 在 JDK 5 之前是错的

旧内存模型(Java 5 之前)下 volatile 语义太弱,挡不住这次重排,所以JDK 5 之前的 DCL 是错的;JSR-133 重写内存模型后,volatile 才真正能"禁止重排 + 保证可见",DCL 从 JDK 5 起才安全。如果面试官追问"DCL 是什么时候开始可靠的",答出"JSR-133 / JDK 5"是加分项。

最后,生产里更推荐用静态内部类(Holder),它用类加载机制天然实现"延迟 + 线程安全",连 volatile 都不用写:

Holder.java · 静态内部类单例(推荐)
class Singleton {
    private Singleton() {}

    private static class Holder {
        private static final Singleton INSTANCE = new Singleton();
    }
    // 类加载是线程安全的,Holder 只在首次 getInstance 时被加载
    public static Singleton getInstance() {
        return Holder.INSTANCE;
    }
}
核心要点
  • new 对象 = 分配 → 构造 → 赋值三步,无 volatile 时 ②③ 可重排 → 半初始化对象;
  • volatile 的 StoreStore 屏障禁止该重排,DCL 才安全(JDK 5 / JSR-133 起);
  • 生产首选静态内部类 Holder,代码更少、语义更稳。
第 14 站

final 域的内存语义:免同步的"安全通道"

volatile 和锁都是"要主动用"的机制,而 final 是被动生效的:final 字段在构造函数中正确赋值后,任何线程看到这个对象引用时,都一定能看到 final 字段的最终值——不需要任何同步。这就是 JSR-133 给 final 的承诺。

ImmutablePoint.java · final 的可见性
class ImmutablePoint {
    private final int x;   // final 字段
    private final int y;

    ImmutablePoint(int x, int y) { this.x = x; this.y = y; }
}

// Thread-1
ImmutablePoint p = new ImmutablePoint(3, 5);
cache = p;   // 发布到一个共享引用(如 volatile 字段或安全容器)

// Thread-2 拿到 cache 后:p.x 一定是 3,p.y 一定是 5,绝不可能是默认值 0

为什么能做到?JSR-133 规定:final 字段的写入之后要插入 StoreStore 屏障(防止 final 写被重排到构造函数之外),读者拿到引用后还有 LoadLoad 保证。实现上 JIT 会把这些屏障安排得比 volatile 更"便宜"。

final 字段的发布链路 final 字段赋值 StoreStore 屏障 final 写不能跑出构造 构造函数结束 发布引用 前提:对象必须"安全发布"——不能让引用在构造完成前被别人看到 ✗ 反例:构造函数里把 this 发出去(启动线程 / 注册到静态字段) this 逸出(escape)后,final 的保证就失效了——别人可能拿到"未构造完"的对象
图 5final 字段靠 StoreStore 屏障钉在构造内,但前提是安全发布(引用不逸出)

三个必须说清的边界

  • 安全发布是前提:如果构造函数里就把 this 泄露出去(比如在构造中 new Thread(...).start() 且线程里读了字段,或把 this 放进静态集合),final 的保证就失效——这叫 this 逸出(this Escape),是写框架代码时的高危点;
  • final 只保护引用本身:如果 final 字段指向一个可变对象,只保证"这个引用不变",不保证被引用对象内部字段的可见性——对象内部的状态更新仍需同步;
  • final 不解决发布问题:final 字段的可见性针对"已经拿到引用"的线程,引用本身怎么传过去(volatile、锁、并发容器、start/join),还得靠其它规则。
生产落点:为什么不可变对象是并发编程的"免死金牌"

StringIntegerBigDecimalLocalDate 全是 final 字段的不可变类,所以它们天然线程安全、可以放心跨线程共享。你自己的值对象(DTO / 配置项 / 坐标)也尽量设计成 final 字段 + 不提供 setter——不可变对象配安全发布,连 volatile 都省了,这是成本最低的并发方案。

核心要点
  • final 字段在构造后对所有线程可见,无需同步(JSR-133 保证);
  • 前提是安全发布:this 逸出会让保证失效;
  • final 引用指向可变对象时,只保护引用本身;不可变对象 = 最优并发策略。
第 15 站

生产落地:用 happens-before 排查并发 Bug

把八条规则背下来只是第一步,真正的价值在于:遇到并发 Bug,不靠猜、不靠"跑 100 次没出问题",而是用 happens-before 逐条推理。方法论就四步:

① 画时序图 把相关线程的读写 按执行顺序画出来 ② 标出共享变量 每个读/写对应 哪条 happens-before ③ 套八条规则 写和读之间 能找到关系吗? ④ 补同步 找到缺口 再动手修 找不到 happens-before 关系 = 缺同步 = Bug 的温床
图 6并发排查四步法:先推理,后动手

实战 1:优雅停机的"开关不生效"(第 1 站事故)

GracefulShutdown.java · 修法
class GracefulShutdown {
    private volatile boolean running = true;  // 关键:volatile

    void stop()  { running = false; }        // 写(volatile write)

    void work() {
        while (running) {                       // 读(volatile read)
            // 业务处理...
        }
    }
}
// 推理:stop() 的写 happens-before work() 中下一次读(规则 3)
// 结论:running 加 volatile 即可,不需要 synchronized

实战 2:主线程 join 子线程后读结果

场景:主线程起了 10 个子线程各算一块数据,join() 全部结束后汇总。推理:每个子线程的所有写 happens-before 主线程从它的 join() 返回(规则 5),所以汇总代码不需要额外加锁。很多人习惯性地加一堆 synchronized,其实是多余的——用规则推一遍就能省下来。

实战 3:线程池 submit 后 get() 为什么安全

场景:Future<Result> f = pool.submit(callable); Result r = f.get();。推理:任务内的写 happens-before get() 返回(JUC 内部:任务结束写入 AQS 的 volatile state,get() 读 state 解锁,本质是规则 3 + 规则 8 的组合)。所以 Future 的结果读取是天然安全的——这也是为什么线程池场景极少需要自己再传 volatile 标志。

反模式:用 sleep "等可见"

高危反模式

Thread.sleep(100) 之后再去读共享变量——sleep 不产生任何 happens-before 关系!它是纯时间等待,跟可见性毫无关系。线上很多"幽灵 Bug"就是这么来的:本地 sleep 100ms "碰巧"跑对了,生产环境线程调度一变就复现。如果代码里出现"写完等一会再读",直接判死刑——该上 volatile 上 volatile,该上锁上锁。

自查清单

症状先用哪条规则推理典型修复
一写多读的标志位不生效规则 3(volatile)加 volatile
方法内共享状态错乱规则 2(监视器锁)同一把锁包住读写
子线程启动后看不到主线程准备的数据规则 4(start)把准备动作移到 start() 之前
join/get 之后读不到结果规则 5 / AQS 发布检查是否真的 join/get 成功
看到半初始化对象规则 3 + 屏障volatile 或 Holder / 枚举单例
代码里出现 sleep 等可见——(不成立)删掉 sleep,换真正的同步机制
核心要点
  • 排查并发 Bug = 画时序 + 标读写 + 套规则 + 补同步,四步走;
  • 找不到 happens-before 关系,就是缺同步,别用 sleep / 玄学掩盖;
  • 会推理之后,很多"多余的锁"能省掉,性能与正确性兼得。
第 16 站

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 只关心主内存和工作内存之间的同步规则,跟堆栈布局完全无关。顺带一提:第 2 站说的"工作内存"也不是 JVM 的栈——工作内存是抽象概念,物理对应寄存器/缓存,而栈是真实的运行时数据区。面试时能主动区分这两层,会让面试官对你刮目相看。
总结

这一篇你掌握了什么

核心知识点回顾

  • JMM 是什么:JSR-133 定义的跨平台并发规范 = 主内存 + 工作内存 + happens-before 八条规则 + 内存屏障;存在的意义是屏蔽 x86 / ARM / POWER 的内存模型差异,同时只给最小保证、留足优化空间;
  • 8 大原子操作:lock/unlock/read/write(主内存侧)+ load/store/use/assign(工作内存侧);JSR-133 之后规范层面以 happens-before 为主,8 操作用于理解分工;
  • 并发三性:可见性(volatile/synchronized/final 负责)、原子性(锁/CAS 负责,volatile 不管)、有序性(volatile/happens-before 负责);
  • 重排序:编译器、CPU、内存系统三来源;有数据依赖的指令不能重排;as-if-serial 只保护单线程;
  • happens-before 八条规则:程序顺序、监视器锁、volatile、start、join、interrupt、finalizer、传递性——它是"逻辑顺序保证",不是物理时间先后;
  • 内存屏障:LoadLoad / LoadStore / StoreStore / StoreLoad;x86 是强模型只需 StoreLoad(mfence / lock 前缀),StoreLoad 最贵 → volatile 写在 x86 上贵一个数量级;
  • volatile:写插 StoreStore + StoreLoad,读插 LoadLoad + LoadStore;解决可见性 + 有序性,不解决原子性;生产用于一写多读的标志位、DCL;
  • final 域:构造完成后 final 字段对所有线程可见(无需同步),前提是安全发布(this 不能逸出);final 引用指向可变对象时只保护引用本身;
  • 排查方法论:画时序 → 标读写 → 套八条规则 → 补同步;sleep 不产生 happens-before,是反模式。

面试 30 秒总结

"JMM 是 JSR-133 定义的 Java 并发内存模型,回答分三层:模型层——主内存 + 工作内存,线程操作的是副本,同步时机不确定导致可见性问题;规则层——happens-before 八条规则(程序顺序、监视器锁、volatile、start、join、interrupt、finalizer、传递性),其中 volatile 写-读 + 传递性是日常主力;实现层——内存屏障,x86 上 volatile 写靠 StoreLoad(mfence/lock)保证,读基本免费。三性对应:volatile 管可见性和有序性,原子性必须靠锁或 CAS,final 字段构造后天然可见但前提是安全发布。遇到并发 Bug,用 happens-before 逐条推理,不靠 sleep 碰运气。"

一句话总结

JMM 是 Java 并发世界的交通规则:happens-before 是规则本身,内存屏障是执法工具,volatile / synchronized / final 是三种"合规驾驶"的方式。不遵守规则,程序"碰巧能跑";遵守规则,任何硬件上都稳。

Comments · 评论