JMM 与 happens-before:一次搞懂 Java 内存模型

「Java 进阶之路」系列 Day03

写在前面

前面两篇讲了并发的基本概念和线程创建方式,都还算"表层"知识。从今天这篇开始,正式进入并发编程最硬核也是最容易被问倒的部分------Java 内存模型(JMM)

很多人能背出"volatile 保证可见性和有序性,不保证原子性",但一旦追问"可见性到底是个什么问题?为什么会有这个问题?volatile 又是怎么解决的?",就说不清楚了。根源就在于没搞懂 JMM 和 happens-before------这篇把这套底层逻辑一次讲透,后面讲 volatile/synchronized/锁机制的时候就不用来回倒补基础了。


一、是什么:JMM 到底是个什么模型

先说结论:JMM 是 Java 对"多线程下内存可见性行为"的一套统一抽象规范,它不是真实存在的物理内存结构,而是屏蔽了不同 CPU、不同操作系统底层差异后,给 Java 程序员的一份"确定性承诺"。

JMM 的内存结构

flowchart TB subgraph 线程A WA[工作内存 A<br/>本地缓存] end subgraph 线程B WB[工作内存 B<br/>本地缓存] end Main[(主内存 Main Memory<br/>对应堆中的共享变量)] WA <-->|read / write| Main WB <-->|read / write| Main
  • 所有共享变量都保存在主内存里(可以类比成堆内存里的实例字段、静态字段)。
  • 每个线程都有自己独立的工作内存 (对应 CPU 寄存器/高速缓存的抽象),线程读写共享变量时,其实操作的是自己工作内存里的一份拷贝,再通过 read/load/use/assign/store/write/lock/unlock 这 8 种原子操作和主内存同步。

问题就出在这里:线程 A 改了变量,改动只是先落在 A 自己的工作内存里,什么时候同步回主内存、线程 B 什么时候能看到,本来是没有保证的------这就是"可见性问题"的根源。


二、为什么需要 JMM:不只是可见性,还有有序性

现代 CPU 为了性能会做两件事,都会破坏我们对"程序应该按顺序执行"的直觉:

  1. 多级缓存(L1/L2/L3) :不同核心的缓存互相独立,导致可见性问题------线程 A 改了值,线程 B 从自己缓存里读到的可能还是旧值。
  2. 指令重排序 :编译器和 CPU 会在不改变单线程语义的前提下调整指令执行顺序以提升性能,导致有序性问题------多线程环境下,别的线程观察到的执行顺序可能和代码书写顺序不一致。

再加上并发本身就有的原子性问题 (一个操作被线程切换打断,比如 i++ 其实是读-改-写三步,中间可能被别的线程插一脚),JMM 要解决的其实是三大问题:

问题 说明 JMM 给的解决手段
可见性 一个线程修改了值,另一个线程看不到 volatilesynchronizedfinal
原子性 操作被中途打断,数据不一致 synchronizedLockAtomic 原子类
有序性 指令重排导致执行顺序与代码顺序不同 volatile(禁止特定重排)、happens-before 规则

happens-before 就是 JMM 用来描述"什么条件下有序性和可见性能得到保证"的一套规则语言。


三、happens-before:一份写给程序员的承诺

先纠正一个常见的误解:happens-before 不是说"A 一定在物理时间上先于 B 执行",而是说:

如果 A happens-before B,那么 A 操作的结果,对 B 一定是可见 的,且逻辑上 A 排在 B 之前------至于 JVM/CPU 底层怎么优化、怎么重排,只要不违反这个可见性结果,程序员不需要关心。

也就是说,happens-before 是 JVM 对我们的一份"兜底承诺":只要代码满足下面这 8 条规则,无论底层怎么折腾,结果都是正确的。

编号 规则名 内容
1 程序次序规则 同一线程内,前面的操作 happens-before 后面的操作
2 管程锁定规则 同一把锁的 unlock happens-before 后续的 lock
3 volatile 变量规则 volatile 变量的 happens-before 后续对该变量的
4 线程启动规则 Thread.start() happens-before 被启动线程内的任何操作
5 线程终止规则 线程内所有操作 happens-before 其他线程检测到它终止(如 join() 返回)
6 线程中断规则 interrupt() 调用 happens-before 被中断线程检测到中断
7 对象终结规则 构造函数执行结束 happens-before finalize() 开始
8 传递性 若 A happens-before B,且 B happens-before C,则 A happens-before C

这 8 条里,日常写业务代码时用得最多的是 1、2、3、8,尤其是第 8 条"传递性",很多可见性保证其实都是靠它串起来的(下面第二个例子会演示)。


四、三个例子,把规则用起来

例 1:volatile 保证可见性(对应规则 3)

java 复制代码
volatile boolean flag = false;

// 线程 A
flag = true;   // volatile 写

// 线程 B
if (flag) {    // volatile 读,根据规则 3,一定能看到 flag = true
    doSomething();
}

如果 flag 不加 volatile,线程 B 什么时候能看到 flag = true 是没有保证的------可能是立刻,也可能是永远看不到(一直读自己工作内存里的旧值),这就是可见性问题在实际代码里的样子。

例 2:synchronized 靠传递性保证有序性(对应规则 2 + 8)

java 复制代码
// 线程 A
synchronized (lock) {
    x = 1;
    y = 2;
}  // unlock

// 线程 B
synchronized (lock) {  // lock 必须在 A 的 unlock 之后(规则 2)
    // 根据传递性(规则 8):
    // x=1,y=2 happens-before A 的 unlock
    // A 的 unlock happens-before B 的 lock
    // 所以 x=1,y=2 happens-before B 拿锁之后的代码
    // → B 在这里一定能看到 x=1, y=2
}

这也是为什么"加锁不仅仅是防止两个线程同时改数据,还顺带解决了可见性问题"------很多人只记得 synchronized 保证原子性,其实它是原子性、可见性、有序性三个都管

例 3:DCL 单例的经典坑,反过来证明 volatile 的必要性

java 复制代码
// ❌ 错误写法:不加 volatile
private static Singleton instance;

public static Singleton getInstance() {
    if (instance == null) {
        synchronized (Singleton.class) {
            if (instance == null) {
                instance = new Singleton();
                // new Singleton() 实际分三步:
                // ① 分配内存
                // ② 将引用赋值给 instance   ← 重排后可能先于 ③ 执行
                // ③ 执行构造函数,初始化对象
                // 若 ②③ 被重排,另一线程恰好在 ② 之后、③ 之前进来判断 instance == null,
                // 会拿到一个引用不为 null、但对象还没初始化完的"半成品"!
            }
        }
    }
    return instance;
}

// ✅ 正确写法:volatile 禁止 ②③ 之间的重排
private static volatile Singleton instance;

这个坑是"有序性问题"最经典的实战案例,也是很多人知道要给 DCL 单例加 volatile、却说不清楚"为什么"的地方------现在应该清楚了:不是为了可见性,而是为了禁止指令重排


五、面试追问

Q1:什么是 Java 内存模型(JMM)?

JMM 是 Java 对多线程环境下"内存可见性行为"的统一抽象规范:所有共享变量存在主内存,每个线程有自己的工作内存,线程操作的是工作内存里的副本,通过一组原子操作和主内存同步。它的作用是屏蔽不同硬件/操作系统的内存模型差异,给程序员一套确定性的可见性和有序性保证。

Q2:happens-before 是不是指时间上的先后顺序?

不是。happens-before 描述的是"可见性"和"逻辑顺序"的保证,不代表两个操作在物理时间上真的按这个顺序执行。JVM/CPU 只要保证最终结果符合 happens-before 关系,底层怎么重排、怎么优化都可以。

Q3:volatile 能保证原子性吗?

不能。volatile 只保证可见性和禁止指令重排(有序性),不保证原子性。经典反例是 i++:即使 ivolatile 修饰,多线程并发执行 i++ 依然可能丢失更新,因为"读取-加一-写回"这三步本身不是原子操作,volatile 管不了这个。

Q4:synchronized 和 volatile 都能保证可见性,区别是什么?

volatile 只解决可见性和有序性,代价小、性能高,但不保证原子性;synchronized 是互斥锁,进入和退出同步块时会保证原子性、可见性、有序性三者都满足,功能更全但开销更大(涉及锁竞争、线程阻塞唤醒)。选择依据:只需要保证可见性(比如状态标志位)用 volatile,需要保证复合操作的原子性用 synchronized

Q5:DCL(双重检查锁)单例为什么必须给 instance 加 volatile?

因为 new Singleton() 底层分"分配内存、赋值引用、执行构造函数"三步,如果不加 volatile,后两步可能被重排序,导致别的线程拿到一个引用不为 null 但还没构造完成的"半成品对象"。加上 volatile 就能禁止这几步之间的重排,保证对象一旦对外可见,就一定是完整构造好的。


下一篇预告

Day04 把 volatilesynchronizedfinal 这三个关键字和"原子性、可见性、有序性"三大特性对应起来,逐个讲清楚谁解决什么问题、谁又管不了什么。

相关推荐
An_s1 小时前
Java Spring Boot+vue3文件管理系统
java·开发语言
用户298698530141 小时前
在线将 Word 文档转换为 TXT 格式:快速免费的方法
人工智能·后端
wuqingshun3141591 小时前
接口和抽象类有什么区别?
java
shepherd1111 小时前
别再把 MCP 当成大模型的“手脚”:LLM 并不会直接调用 MCP
后端·ai编程·mcp
SimonKing1 小时前
别再写 setter 了!MapStruct Plus vs MapStruct,谁才是 Bean 转换的真神?
java·后端·程序员
名字还没想好☜1 小时前
Go for-range 循环变量陷阱:goroutine 里全打印同一个值(Go 1.22 前后差异)
开发语言·后端·golang·go
AI多Agent协作实战派1 小时前
AI多Agent协作系统实战(二十五):Spring Boot静态资源同步:为什么你改了代码但线上还是旧的?
后端
码栈研说1 小时前
Go 语言大白话入门 12 - JSON:工作里最常见的数据格式
后端·程序员
达达尼昂1 小时前
AI Native 工程实践:如何为 Claude 5 设计更有效的上下文
android·人工智能·后端