Skip to content
Charles Shao
Go back

JMM 与可见性:volatile、happens-before 与重排序

views

一段在单线程下「看起来绝对正确」的代码,放到多线程里可能永远不会退出循环、可能读到一个「构造了一半」的对象、可能让两个线程都以为自己抢到了锁。这些不是玄学,而是 Java 内存模型(JMM) 在编译器优化、CPU 缓存与指令重排序共同作用下的必然结果。

这一篇先把地基打牢:可见性、有序性、原子性分别是什么问题,JMM 用什么抽象约束它们,happens-before 怎么用来论证正确性,以及 volatile 的能力边界。后续锁、AQS、线程池的正确性,都建立在这套语义之上。

TL;DR

Table of contents

Open Table of contents

1. 从一段”不会停”的代码说起

下面这段代码,主线程改了 running,工作线程却可能永远跑下去:

public class StopThread {
    private static boolean running = true;   // 注意:没有 volatile

    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            long i = 0;
            while (running) {  // 期望主线程改成 false 后退出
                i++;
            }
            System.out.println("stopped, i = " + i);
        });
        worker.start();

        Thread.sleep(1000);
        running = false;       // 主线程写
        System.out.println("main set running=false");
    }
}

在服务端 JIT(C2)编译并开启优化后,工作线程很可能永远不退出。原因不是”没读到”,而是编译器发现循环体内没有修改 running,把它提升(hoist)到寄存器,等价于改写成了 if (running) while (true) i++;——主线程后来的写,工作线程根本不再去主内存看。

running 加上 volatile 就能立刻修复。要理解为什么,得先把并发的三个问题拆开。

2. 并发的三大问题

2.1 可见性(Visibility)

现代 CPU 每个核心有自己的 L1/L2 缓存和写缓冲区(Store Buffer)。线程 A 修改了一个变量,可能只落在自己核心的缓存里,还没刷回主内存;线程 B 在另一个核心上读,读到的是旧值。一个线程的写,何时对另一个线程可见,没有默认保证。

2.2 有序性(Ordering)

为了榨干流水线性能,代码的实际执行顺序未必等于你写的顺序:

单线程里这些重排永远安全(as-if-serial 保证结果不变),但多线程里,一个线程观察到的另一个线程的操作顺序可能被打乱。

2.3 原子性(Atomicity)

i++ 看似一步,实则三步:读取 i → 加一 → 写回。多线程交错执行时,两个线程可能都读到 i=5,各自加一后都写回 6,丢了一次更新。基本类型的读写除 long/double 的非原子性约定外通常是原子的,但复合操作不是

可见性是「看不看得见」,有序性是「顺序对不对」,原子性是「会不会被打断」——三个问题正交,缺一都会出并发 bug。

3. JMM:一套抽象的内存契约

Java 内存模型(Java Memory Model, JMM)不是某种硬件,而是一份语言级规范(JSR-133,定义在 JLS 第 17 章)。目标是:让同一份 Java 代码在 x86(强内存序)、ARM/POWER(弱内存序)等不同架构上,都表现出一致且可推理的并发语义。

JMM 把内存抽象为主内存(Main Memory)与每个线程的工作内存(Working Memory)

JMM 抽象模型示意图。左右两个面板分别是 Thread A / Thread B 的工作内存(变量副本,对应寄存器、缓存、写缓冲),下方绿色面板是主内存(共享变量的权威副本);两侧工作内存各自通过标注 read/write 的箭头与主内存交互。底部说明:JMM 是语言级契约而非硬件,用工作内存/主内存屏蔽不同 CPU 内存序差异;日常推理用 happens-before 即可。

线程不能直接读写主内存中的共享变量,必须通过工作内存的副本。JMM 规定了 8 个原子操作(read/load/use/assign/store/write/lock/unlock)来约束副本与主内存的同步时机。实际推理时几乎不必背这 8 个操作——其上封装的 happens-before 规则才是日常工具

4. happens-before:JMM 给开发者的承诺

如果操作 A happens-before 操作 B,那么 A 的结果对 B 一定可见,且 A 在 B 看来一定排在前面。注意:happens-before 不代表时间上一定先发生,而是”结果可见且有序”的语义保证。

JLS 定义的核心规则:

规则含义
程序顺序规则同一线程内,前面的操作 happens-before 后面的操作
监视器锁规则对一个锁的 unlock happens-before 后续对同一锁的 lock
volatile 规则对 volatile 变量的写 happens-before 后续对同一变量的读
线程启动规则Thread.start() happens-before 该线程内的任何操作
线程终止规则线程内所有操作 happens-before 其他线程 join() 成功返回
传递性A hb B 且 B hb C,则 A hb C
中断规则interrupt() happens-before 被中断线程检测到中断

这套规则的价值:写并发代码时,你只要能用 happens-before 链把”写”和”读”串起来,就能断定可见性与有序性成立——不需要去猜某款 CPU 的缓存行为。回看第 1 节:普通 boolean 的写与读之间没有任何 happens-before 边,所以可见性无保证;加上 volatile 后,volatile 规则 + 传递性补上了这条边。

5. 重排序与内存屏障

JMM 允许重排序,但会在必要处插入**内存屏障(Memory Barrier / Fence)**来禁止特定方向的重排。屏障分四类:

屏障保证
LoadLoad屏障前的读,先于屏障后的读完成
StoreStore屏障前的写,先于屏障后的写刷出
LoadStore屏障前的读,先于屏障后的写完成
StoreLoad屏障前的写,先于屏障后的读——开销最大,通常用于 volatile 写

volatile 的实现正是靠屏障。JMM 要求编译器在 volatile 读写前后插入:

volatile 读写屏障示意图。左面板「volatile 写」自上而下:普通写 → StoreStore → volatile 写 → StoreLoad(红色标出全能屏障);右面板「volatile 读」自上而下:volatile 读 → LoadLoad → LoadStore → 普通读/写。底部说明写前普通写不会排到写后、读后普通读写不会排到读前;StoreLoad 是开销最大的屏障。

效果是:volatile 写之前的所有普通写,不会被重排到 volatile 写之后;volatile 读之后的所有普通读写,不会被重排到 volatile 读之前。这正是 volatile「可见性 + 有序性」的来源——写时把前面的修改一并刷出,读时使缓存失效并重新加载。

6. volatile 能做什么,不能做什么

6.1 能:状态标志位

第 1 节的 running 是 volatile 的理想场景——单纯的”写一次,其他线程读”,不涉及复合操作:

private volatile boolean running = true;   // 修复:写立即可见

6.2 不能:复合操作的原子性

volatile 不保证原子性。下面的计数器在高并发下依然会丢更新:

private volatile int count = 0;

public void inc() {
    count++;   // 仍是"读-改-写"三步,volatile 只保证每步的可见性,挡不住交错
}

正确做法是用原子类(AtomicInteger 等,底层是 CAS)或锁。CAS 与 AQS 的关系见 AQS 与 Lock 家族

private final AtomicInteger count = new AtomicInteger();
public void inc() { count.incrementAndGet(); }   // CAS 保证原子

6.3 能:安全发布对象(double-check 单例)

双重检查锁定(DCL)单例里,instance 必须 volatile:

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

instance = new Singleton() 实际分三步:① 分配内存 → ② 调用构造器初始化 → ③ 把引用赋给 instance。JMM 允许 ② 和 ③ 重排成 ①→③→②:

DCL 单例重排对比。左面板「无 volatile」允许构造与发布重排:①分配内存 → ③引用赋给 instance → ②构造器初始化(红色标出危险顺序);右面板「有 volatile」严格 ①→②→③。底部说明无 volatile 时他线程可能读到半初始化对象;StoreStore 保证构造完成才发布。

一旦重排,另一个线程在第一次检查时可能看到 instance != null,但对象尚未初始化完volatile 写前的 StoreStore 禁止了 ②③ 重排。

边界:volatile = 可见性 + 禁重排,但没有互斥。凡是「读了旧值再基于它写」(i++、check-then-act),仍需 CAS 或锁。

7. final 的内存语义(附带一提)

final 字段也有并发语义:只要对象在构造期间 this 没有逸出,构造器结束后,其他线程通过正确发布的引用读到该对象时,一定能看到 final 字段被正确初始化的值——JMM 在构造器末尾插入了 StoreStore 屏障。这也是不可变对象(如 String)天生线程安全的底层原因。

8. 常见误区

理解了 JMM 与 happens-before,就能论证后面每一种同步工具「为什么正确」。synchronized 不仅互斥,还借监视器锁规则保证可见性与有序性——下一篇从对象头与 Monitor 拆开这条路径。

延伸阅读


views
Share this post on:

Previous Post
synchronized 与锁升级:对象头、Monitor 与偏向 / 轻量 / 重量级锁
Next Post
Grafana JVM 监控分析:从指标采集到看板解读的实战指南