并发05:JMM与Happens-Before

从 volatile 标志位、双重检查锁和指令重排序入手,系统理解 Java 内存模型里的可见性、原子性、有序性与 happens-before 规则。

字数 1681 阅读时长 ≈ 5 分钟 2026-5-27 2026-7-12
并发05:JMM与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++;

这行代码包含读取、加一、写回。多个线程同时执行,仍然可能丢失更新。计数场景要看 AtomicIntegerLongAdder、锁或数据库原子更新。

有序性:代码顺序不一定是执行顺序

编译器和 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。对象创建不是一个不可拆的动作,它大致包含:

  1. 分配内存
  2. 初始化对象
  3. 把引用赋给变量

如果引用赋值被其他线程先看见,而初始化还没完成,就可能拿到半初始化对象。volatile 能禁止这类关键重排序。

happens-before 规则

JMM 里常说 happens-before。它不是简单的时间先后,而是可见性规则:如果 A happens-before B,那么 A 的结果对 B 可见,并且 A 的执行顺序排在 B 之前。

JMM happens-before 可见性路径

常用规则包括:

规则说明
程序顺序规则一个线程内,前面的操作 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 或其他同步方案。

误区三:只在写变量时加同步

可见性是一条读写之间的关系,读侧也必须通过相同的同步机制参与进来。

项目里怎么判断

看到并发共享变量时,可以先问:

  1. 这个变量会不会被多个线程读写
  2. 是否存在复合操作,比如检查后更新、读多个字段组成判断?
  3. 写入后,哪个线程必须在什么时间看到?
  4. 是否已经通过锁、volatile、并发容器、线程启动或 join 建立 happens-before

如果只是开关标志,volatile boolean 往往足够。如果是计数器,看原子类或锁。如果是多个字段组成状态,优先考虑不可变对象整体替换,或者用同一把锁保护读写。

一句话:JMM 不是为了让人背术语,它是在回答并发程序最基础的问题:我写的值,别人什么时候能看见;我以为的顺序,别人是不是也按这个顺序观察到。