「Java 进阶之路」系列 Day03
写在前面
前面两篇讲了并发的基本概念和线程创建方式,都还算"表层"知识。从今天这篇开始,正式进入并发编程最硬核也是最容易被问倒的部分------Java 内存模型(JMM)。
很多人能背出"volatile 保证可见性和有序性,不保证原子性",但一旦追问"可见性到底是个什么问题?为什么会有这个问题?volatile 又是怎么解决的?",就说不清楚了。根源就在于没搞懂 JMM 和 happens-before------这篇把这套底层逻辑一次讲透,后面讲 volatile/synchronized/锁机制的时候就不用来回倒补基础了。
一、是什么:JMM 到底是个什么模型
先说结论:JMM 是 Java 对"多线程下内存可见性行为"的一套统一抽象规范,它不是真实存在的物理内存结构,而是屏蔽了不同 CPU、不同操作系统底层差异后,给 Java 程序员的一份"确定性承诺"。
JMM 的内存结构
- 所有共享变量都保存在主内存里(可以类比成堆内存里的实例字段、静态字段)。
- 每个线程都有自己独立的工作内存 (对应 CPU 寄存器/高速缓存的抽象),线程读写共享变量时,其实操作的是自己工作内存里的一份拷贝,再通过
read/load/use/assign/store/write/lock/unlock这 8 种原子操作和主内存同步。
问题就出在这里:线程 A 改了变量,改动只是先落在 A 自己的工作内存里,什么时候同步回主内存、线程 B 什么时候能看到,本来是没有保证的------这就是"可见性问题"的根源。
二、为什么需要 JMM:不只是可见性,还有有序性
现代 CPU 为了性能会做两件事,都会破坏我们对"程序应该按顺序执行"的直觉:
- 多级缓存(L1/L2/L3) :不同核心的缓存互相独立,导致可见性问题------线程 A 改了值,线程 B 从自己缓存里读到的可能还是旧值。
- 指令重排序 :编译器和 CPU 会在不改变单线程语义的前提下调整指令执行顺序以提升性能,导致有序性问题------多线程环境下,别的线程观察到的执行顺序可能和代码书写顺序不一致。
再加上并发本身就有的原子性问题 (一个操作被线程切换打断,比如 i++ 其实是读-改-写三步,中间可能被别的线程插一脚),JMM 要解决的其实是三大问题:
| 问题 | 说明 | JMM 给的解决手段 |
|---|---|---|
| 可见性 | 一个线程修改了值,另一个线程看不到 | volatile、synchronized、final |
| 原子性 | 操作被中途打断,数据不一致 | synchronized、Lock、Atomic 原子类 |
| 有序性 | 指令重排导致执行顺序与代码顺序不同 | 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++:即使 i 被 volatile 修饰,多线程并发执行 i++ 依然可能丢失更新,因为"读取-加一-写回"这三步本身不是原子操作,volatile 管不了这个。
Q4:synchronized 和 volatile 都能保证可见性,区别是什么?
volatile 只解决可见性和有序性,代价小、性能高,但不保证原子性;synchronized 是互斥锁,进入和退出同步块时会保证原子性、可见性、有序性三者都满足,功能更全但开销更大(涉及锁竞争、线程阻塞唤醒)。选择依据:只需要保证可见性(比如状态标志位)用 volatile,需要保证复合操作的原子性用 synchronized。
Q5:DCL(双重检查锁)单例为什么必须给 instance 加 volatile?
因为 new Singleton() 底层分"分配内存、赋值引用、执行构造函数"三步,如果不加 volatile,后两步可能被重排序,导致别的线程拿到一个引用不为 null 但还没构造完成的"半成品对象"。加上 volatile 就能禁止这几步之间的重排,保证对象一旦对外可见,就一定是完整构造好的。
下一篇预告
Day04 把 volatile、synchronized、final 这三个关键字和"原子性、可见性、有序性"三大特性对应起来,逐个讲清楚谁解决什么问题、谁又管不了什么。