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 标志位,但工作线程(Worker)却可能陷入无限阻塞等待,永远无法退出循环:

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)

为了极大地压榨 CPU 流水线的执行吞吐量,代码在底层的实际流转顺序未必等同于你编写的源码顺序。这种指令重排序机制主要发源于三个层面:

需要着重提醒的是,在单线程排障场景下这些重排序机制永远是安全的,因为 as-if-serial 规则严格兜底了最终结果的正确性;然而一旦步入多线程环境,一个线程观察到的另一个线程的操作顺序极有可能会被重排得面目全非。

2.3 原子性(Atomicity)

以极其常见的 i++ 操作为例,它看似只是一步简单的累加,在底层实则被切分为三个独立步骤:从主内存读取 i 的状态快照 → 执行寄存器加一操作 → 将计算完毕的新值写回主内存。在多线程高度交错竞争时,两个工作线程极有可能同时读取到 i=5 的快照,各自完成加一运算后,又先后将 6 写回主内存,从而导致其中一次高并发更新被无声无息地覆盖丢失。诚然,在 Java 中基本数据类型的直接读写(除 long 和 double 的非原子性协定外)通常具备天然的原子性,但类似读改写的复合操作绝对不是原子的。

总结来说,可见性决定了状态修改「看不看得见」,有序性决定了指令执行「顺序对不对」,原子性则决定了链路流转「会不会被打断」。这三大核心并发要素相互正交,在实际的高并发交易架构中缺失任何一环,都会直接诱发极其隐蔽且灾难性的并发安全 Bug。

3. JMM:一套屏蔽硬件差异的抽象内存契约

明确了并发的三大痛点后,我们来看 Java 是如何通过顶层设计来治理这些差异的。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 对开发者的神圣承诺

happens-before(先行发生)规则是 JMM 向开发者做出的核心承诺:如果操作 A happens-before 操作 B,那么 A 的执行结果对 B 一定是完全可见的,且 A 的执行顺序在 B 看来一定严格排在前面。需要特别强调的是,happens-before 并不意味着操作 A 在物理时钟上必定先于操作 B 发生,它提供的是一种「结果可见且指令有序」的逻辑语义保证——只要底层的重排序不破坏这种最终的语义观测,JMM 就会大胆放权给编译器与 CPU 执行极致优化。

JLS 中定义的核心规则如下:

规则核心含义
程序顺序规则同一线程内,前置操作必定 happens-before 后续操作
监视器锁规则针对同一把锁,其 unlock 释放动作必然 happens-before 后续的 lock 获取动作
volatile 规则对 volatile 变量的写入操作,一定 happens-before 后续对同一变量的读取
线程启动规则线程的 Thread.start() 方法调用 happens-before 该线程内部的任何操作
线程终止规则线程内部的所有操作,均 happens-before 其他线程通过 join() 成功返回的节点
传递性原则若 A happens-before B,且 B happens-before C,则 A 必定 happens-before C
中断响应规则对线程发起的 interrupt() 调用,happens-before 该被中断线程精准检测到中断事件

这套规则的核心价值在于:在编写高并发架构代码时,开发者只需利用 happens-before 链条将「写操作」与「读操作」有效串联,就能在理论上无懈可击地断定可见性与有序性绝对成立,而彻底免去了揣测某款特定 CPU 底层缓存流转行为的痛苦。结合这一规律回看本文第 1 节的死循环场景:由于普通 boolean 变量的主线程写与工作线程读之间不存在任何 happens-before 的关联边,导致其可见性毫无保障;而引入 volatile 关键字后,正是依靠 volatile 规则 加上 传递性原则 完美补齐了这条因果链,从而彻底消除了阻塞挂起的隐患。

5. 指令重排序与内存屏障

正如前文所述,JMM 允许编译器与底层硬件实行重排序优化。但为了坚守 happens-before 的底层契约,JMM 会在编译期于关键指令节点处动态插入内存屏障(Memory Barrier / Fence),以此来强行禁止跨越屏障的特定方向重排序。JMM 将底层的内存屏障抽象划分为四种基础类型:

屏障类型核心保证机制
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 仅能保证每步的单次可见性,却挡不住多线程交错覆盖
}

针对这种复合操作,正确的治理方案是引入底层基于 CAS(比较并交换)机制的原子类(如 AtomicInteger),或者直接使用显式锁来进行互斥等待排队。关于 CAS 与 AQS 同步队列的深层协同逻辑,可参阅后续的 AQS 与 Lock 家族:

private final AtomicInteger count = new AtomicInteger();
public void inc() { count.incrementAndGet(); }   // CAS 乐观自旋机制严格保证原子性

6.3 经典场景:安全发布对象(双重检查单例)

在经典的双重检查锁定(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 关键字的隐藏内存语义(附带一提)

除了 volatile,Java 中的 final 字段同样蕴含着深刻的并发语义:只要目标对象在自身构造期间没有发生 this 引用逸出,那么在构造器顺利执行结束后,其他工作线程通过正确发布的引用读取该对象时,就一定能稳定观察到其 final 字段被正确初始化的终态值。其底层机理在于,JMM 强制要求在构造器末尾插入了一道 StoreStore 内存屏障,从而将初始化操作彻底拦截在对象发布之前。这也正是不可变对象(如 String)在并发环境下天生具备绝对线程安全特性的底层根源所在。

8. 常见排障与认知误区

排障与调优口诀:并发安全三要素缺一不可;共享标志位优先依赖 volatile;复合操作必用 CAS 或加锁;单例双检必加 volatile 防重排。

透彻理解了 JMM 抽象模型与 happens-before 规则这套底层地基,我们便拥有了论证后续每一种同步容器与锁工具「为什么能保证线程安全」的理论武器。正如上文提及的 synchronized,它不仅能低成本实现互斥等待排队,还巧妙借助监视器锁规则提供了极强的可见性与有序性保障。至于其底层是如何流转状态与阻塞唤醒的——下一篇,我们将深入 JVM 对象头与 Monitor 管程机制,彻底拆解锁升级这条进阶路径。

延伸阅读


–views
Share this post on:

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