并发05:JMM与Happens-Before
从 volatile 标志位、双重检查锁和指令重排序入手,系统理解 Java 内存模型里的可见性、原子性、有序性与 happens-before 规则。
JMM 解决什么问题
很多人第一次遇到 JMM,不是在读规范,而是在写这种代码:
boolean running = true;
while (running) {
doWork();
}
另一个线程把 running 改成 false,循环却不一定立刻停。问题不是 JVM “坏了”,而是线程之间的读写没有建立可见性保证。一个线程写了,另一个线程什么时候看见,并不是普通变量天然承诺的事。
JMM(Java Memory Model)解决的是并发程序里的三件事:
| 特性 | 含义 | 例子 |
|---|---|---|
| 可见性 | 一个线程改的数据,别的线程能不能看见 | volatile 保证写后立即可见 |
| 原子性 | 一组操作会不会被打断 | count++ 不是原子操作 |
| 有序性 | 代码执行顺序能不能被调整 | 编译器和 CPU 的指令重排序 |
可见性:写了不等于别人马上看见
每个线程执行时,可能会把变量读到寄存器、CPU 缓存或工作内存里。没有同步关系时,一个线程对共享变量的修改,另一个线程不一定马上重新读取主内存里的新值。
所以停止标志通常要写成:
private volatile boolean running = true;
volatile 的核心作用是保证可见性,并提供一定的有序性。线程写入 volatile 变量后,其他线程再读取这个变量,能看到之前写入的结果。
但 volatile 不能把复合操作变成原子操作:
count++;
这行代码包含读取、加一、写回。多个线程同时执行,仍然可能丢失更新。计数场景要看 AtomicInteger、LongAdder、锁或数据库原子更新。
有序性:代码顺序不一定是执行顺序
编译器和 CPU 会在不改变单线程结果的前提下调整指令顺序。单线程里你感觉不到,但多线程会暴露问题。
经典例子是双重检查锁:
class Holder {
private static volatile Instance instance;
static Instance get() {
if (instance == null) {
synchronized (Holder.class) {
if (instance == null) {
instance = new Instance();
}
}
}
return instance;
}
}
这里 instance 必须是 volatile。对象创建不是一个不可拆的动作,它大致包含:
- 分配内存
- 初始化对象
- 把引用赋给变量
如果引用赋值被其他线程先看见,而初始化还没完成,就可能拿到半初始化对象。volatile 能禁止这类关键重排序。
happens-before 规则
JMM 里常说 happens-before。它不是简单的时间先后,而是可见性规则:如果 A happens-before B,那么 A 的结果对 B 可见,并且 A 的执行顺序排在 B 之前。
常用规则包括:
| 规则 | 说明 |
|---|---|
| 程序顺序规则 | 一个线程内,前面的操作 happens-before 后面的操作 |
| volatile 规则 | 对 volatile 变量的写 happens-before 后续对同一变量的读 |
| 锁规则 | 解锁 happens-before 后续对同一把锁的加锁 |
| 线程启动规则 | 线程 start() 之前的操作 happens-before 新线程里的操作 |
| 线程 join 规则 | 线程里的操作 happens-before 其他线程成功从 join() 返回 |
这些规则让我们不用直接和 CPU 缓存打交道,而是通过锁、volatile、线程启动和等待这些语言机制建立可见性。
synchronized 的可见性语义
很多人只记得 synchronized 能保证同一时刻一个线程进入临界区。它还有可见性语义:线程释放锁时,会把临界区内的修改刷新出去;另一个线程获得同一把锁后,能看到这些修改。
所以如果共享变量的读写都在同一把锁保护下,即使变量不是 volatile,也能保证可见性。
问题往往出在”写加锁,读不加锁”。写线程确实保护了修改过程,但读线程没有通过同一把锁建立 happens-before,就可能看到旧值或不一致状态。
final 和安全发布
JMM 里还有一个工程上很有用的点:final 字段的初始化安全。对象构造完成后,如果对象引用被正确发布,其他线程看到这个对象时,通常能看到构造函数里对 final 字段的赋值。
但”正确发布”很关键。把对象引用随便塞进一个普通静态变量,让其他线程无同步读取,仍然可能出问题。常见安全发布方式包括:
- 通过类初始化发布静态对象
- 把引用写入 volatile 变量
- 在同一把锁保护下写入和读取
- 放入线程安全容器
- 在线程启动前完成赋值,再由新线程读取
不可变对象之所以适合并发,不只是因为”不改就不会冲突”,还因为 final 字段和安全发布能减少可见性风险。
volatile 的内存屏障语义
volatile 的底层语义可以理解成在读写附近插入内存屏障,限制编译器和 CPU 的重排序,并保证写入对后续读取可见。
这解释了它适合发布配置快照:
private volatile Config config;
public void refresh(Config newConfig) {
this.config = newConfig;
}
只要 Config 本身是不可变的,读线程拿到引用后就能用一个一致快照。反过来,如果 Config 里面还有可变 List、Map,刷新引用只能保证”看见新引用”,不能保证内部对象被并发修改时也安全。
所以项目里用 volatile 时,最好搭配不可变对象,而不是把一堆可变字段暴露出去。
常见误区
误区一:把 JMM 等同于 JVM 内存区域
堆、栈、方法区是运行时内存结构;JMM 是并发读写的规则。它们有关联,但不是一回事。
误区二:volatile 能替代锁
volatile 适合状态标志、配置引用、单次发布这类场景;涉及”先检查再修改”的复合逻辑,仍然要锁、CAS 或其他同步方案。
误区三:只在写变量时加同步
可见性是一条读写之间的关系,读侧也必须通过相同的同步机制参与进来。
项目里怎么判断
看到并发共享变量时,可以先问:
- 这个变量会不会被多个线程读写?
- 是否存在复合操作,比如检查后更新、读多个字段组成判断?
- 写入后,哪个线程必须在什么时间看到?
- 是否已经通过锁、volatile、并发容器、线程启动或
join建立 happens-before?
如果只是开关标志,volatile boolean 往往足够。如果是计数器,看原子类或锁。如果是多个字段组成状态,优先考虑不可变对象整体替换,或者用同一把锁保护读写。
一句话:JMM 不是为了让人背术语,它是在回答并发程序最基础的问题:我写的值,别人什么时候能看见;我以为的顺序,别人是不是也按这个顺序观察到。