JMM学习总结:深入理解happens-before关系

深入理解happens-before关系

  • 前言
  • [JMM(Java 内存模型)学习总结:深入理解 happens-before 关系](#JMM(Java 内存模型)学习总结:深入理解 happens-before 关系)
    • [一、学习目标:为什么必须理解 happens-before?](#一、学习目标:为什么必须理解 happens-before?)
    • [二、PO:程序顺序(Program Order)](#二、PO:程序顺序(Program Order))
      • [2.1 PO 的定义](#2.1 PO 的定义)
      • [2.2 学习标记:PO 只解决同一个线程内部的顺序](#2.2 学习标记:PO 只解决同一个线程内部的顺序)
    • 三、SW:同步关系(Synchronizes-With)
      • [3.1 SW 的定义](#3.1 SW 的定义)
      • [3.2 同步规则一:`synchronized` 的释放与获取](#3.2 同步规则一:synchronized 的释放与获取)
      • [3.3 同步规则二:`volatile` 写与读](#3.3 同步规则二:volatile 写与读)
    • [四、HB:Happens-Before 关系](#四、HB:Happens-Before 关系)
      • [4.1 HB 的形式化定义](#4.1 HB 的形式化定义)
      • [4.2 用 HB 关系判断数据竞争](#4.2 用 HB 关系判断数据竞争)
    • [五、HB 的传递性:最重要的推导规则](#五、HB 的传递性:最重要的推导规则)
      • [5.1 传递性定义](#5.1 传递性定义)
      • [5.2 结合 JDK 风格的代码理解传递性](#5.2 结合 JDK 风格的代码理解传递性)
    • [六、结合 JDK 源码学习主要同步规则](#六、结合 JDK 源码学习主要同步规则)
      • [6.1 线程启动与终止:`Thread.start()`、`Thread.join()`](#6.1 线程启动与终止:Thread.start()、Thread.join())
      • [6.2 JDK 并发同步器:`AbstractQueuedSynchronizer`](#6.2 JDK 并发同步器:AbstractQueuedSynchronizer)
      • [6.3 JDK 源码中对 HB 最明确的说明:`CountDownLatch`](#6.3 JDK 源码中对 HB 最明确的说明:CountDownLatch)
    • [七、主要 happens-before 同步规则汇总](#七、主要 happens-before 同步规则汇总)
    • 八、从系统工程角度总结学习方法

前言

本文旨在记录近期研读Java源码的学习心得与疑难问题。由于个人理解水平有限,文中内容难免存在疏漏,恳请读者不吝指正。

JMM(Java 内存模型)学习总结:深入理解 happens-before 关系

一、学习目标:为什么必须理解 happens-before?

从系统的角度看,JMM(Java Memory Model,Java 内存模型)的核心任务,是为多线程程序定义一套可验证的内存访问规则:一个线程对共享变量的写入,在什么条件下能够被另一个线程观察到,以及哪些跨线程操作必须保持特定的先后约束。

Java 源代码中的语句顺序,不等于处理器实际执行指令的绝对顺序。JIT 编译器、处理器乱序执行以及缓存一致性机制都可能影响运行表现。JMM 不要求所有平台采用相同的底层实现,而是规定所有合法实现必须遵守的可观察行为。

学习 happens-before(以下简称 HB),需要建立下面这条逻辑链:

PO:Program Order

同一线程内的程序顺序关系

SW:Synchronizes-With

由锁、volatile、线程生命周期等建立的同步边

传递闭包

HB:Happens-Before

确定跨线程的可见性与顺序约束

形式化地说,日常并发编程中的 HB 关系主要由 PO 和 SW 的传递闭包构成。理解 PO 和 SW,就掌握了推导 HB 的基础。JLS 第 17.4.3、17.4.4 和 17.4.5 节是本主题的规范依据。

二、PO:程序顺序(Program Order)

2.1 PO 的定义

PO 表示同一个线程内部,按照 Java 语言的程序执行语义所确定的动作顺序。

例如:

java 复制代码
int a = 10;  // A1
int b = 20;  // A2
int c = a + b; // A3

在该线程的程序语义中,可以建立:

\ A1 \\xrightarrow{PO} A2 \\xrightarrow{PO} A3 \\

因此:

\ A1 \\xrightarrow{HB} A3 \\

这里有一个关键点:PO 是语言级别的执行顺序关系,并不意味着编译器必须将源代码逐条翻译成严格相同顺序执行的机器指令。只要优化后的程序行为符合 JMM 和 Java 语言语义,编译器就可以进行合法优化。

2.2 学习标记:PO 只解决同一个线程内部的顺序

java 复制代码
int data = 0;
boolean ready = false;

// 线程 T1
data = 42;       // A1
ready = true;    // A2

// 线程 T2
if (ready) {     // B1
    System.out.println(data); // B2
}

在 T1 内部:

\ A1 \\xrightarrow{PO} A2 \\

在 T2 内部:

\ B1 \\xrightarrow{PO} B2 \\

但是,单凭这两个 PO 关系,不能推出:

\ A2 \\xrightarrow{HB} B1 \\

原因是两个线程之间还没有建立同步边。普通的 ready 变量不提供线程间的同步关系;即使 T1 在源代码中先执行赋值,T2 也没有因此获得必须读取到新值的 JMM 保证。

学习结论:PO 是线程内顺序的基础,但不同线程之间还需要 SW 来连接。

规范参考:JLS §17.4.3 Program Order。

三、SW:同步关系(Synchronizes-With)

3.1 SW 的定义

SW 是由 Java 内存模型规定的同步动作之间的关系。它为不同线程中的动作建立跨线程的同步边。

在 JMM 中,同步边的典型形式包括:

  • 一个线程释放监视器,后续另一个线程获取同一个监视器。
  • 一个线程写入 volatile 变量,后续另一个线程读取同一个 volatile 变量。
  • 启动线程的动作,与被启动线程中的第一个动作建立同步关系。
  • 线程终止,与其他线程检测到该线程终止的动作建立同步关系。

JLS 把同步边的源端称为 release,把目的端称为 acquire。这是理解锁和其他同步工具内存语义的重要思路。

3.2 同步规则一:synchronized 的释放与获取

示例代码:

java 复制代码
class SharedData {
    private int value;
    private final Object lock = new Object();

    void write() {
        synchronized (lock) {
            value = 100; // A1:写共享数据
        } // A2:退出同步块,释放监视器
    }

    int read() {
        synchronized (lock) {
            return value; // B2:读取共享数据
        } // B1 之前已经成功获取同一监视器
    }
}

假设线程 T1 先执行完 write() 中的同步块,线程 T2 随后成功获得同一个 lock 进入 read(),那么存在如下关系:

\ A1 \\xrightarrow{PO} A2 \\

\ A2 \\xrightarrow{SW} B1 \\

\ B1 \\xrightarrow{PO} B2 \\

结合传递性,可以推出:

\ A1 \\xrightarrow{HB} B2 \\

即 T1 在释放监视器之前所做的写入,对 T2 在随后获取同一监视器之后的访问具有 HB 保证。

学习标记:

  1. 释放与获取必须对应同一个监视器对象。
  2. synchronized 同时提供互斥和内存同步语义。
  3. 两个线程分别锁定不同对象,不能仅凭它们都使用了 synchronized 就建立 SW。

以上规则来自 JLS 对监视器锁定和释放的规范描述。

3.3 同步规则二:volatile 写与读

将前面的普通变量修改为 volatile:

java 复制代码
class PublishedData {
    private int data;
    private volatile boolean ready;

    // 线程 T1:发布数据
    void publish() {
        data = 42;       // A1:普通写
        ready = true;    // A2:volatile 写
    }

    // 线程 T2:读取数据
    void consume() {
        if (ready) {     // B1:volatile 读,读到 true
            int value = data; // B2:普通读
            System.out.println(value);
        }
    }
}

当 T2 的 ready 读取观察到 T1 写入的 true 时,可以建立:

\ A1 \\xrightarrow{PO} A2 \\

\ A2 \\xrightarrow{SW} B1 \\

\ B1 \\xrightarrow{PO} B2 \\

因此:

\ A1 \\xrightarrow{HB} B2 \\

在该场景下,T2 读取到的 data 必须符合 T1 在发布 ready 前写入的值,因而应当是 42。

学习标记:volatile 不只意味着"变量值可见",还提供特定的内存顺序约束。

但需要区分:volatile 不会把所有相关变量都变成 volatile,也不会自动让复合操作具备原子性。对于 count++ 这类读、改、写操作,单独声明 count 为 volatile 仍不足以保证并发递增的原子性。

四、HB:Happens-Before 关系

4.1 HB 的形式化定义

HB 表示 JMM 规定的一种偏序关系。如果动作 A happens-before 动作 B,通常写作:

\ A \\xrightarrow{HB} B \\

这意味着 A 在内存模型意义上先于 B,并为 A 的效果在 B 处被观察到提供相应保证。

JLS 的核心规则为:

  1. 同一线程内,PO 中先发生的动作 HB 于后发生的动作。
  2. 同步动作可以通过 SW 建立跨线程的 HB 边。
  3. HB 具有传递性。
  4. HB 是偏序关系,并非所有动作之间都必须存在先后关系。

也就是说,HB 不要求整个程序中的所有动作都排列成一条全局顺序。两个没有建立 HB 关系的跨线程动作,可能处于偏序结构中彼此不可比较的位置。

4.2 用 HB 关系判断数据竞争

假设:

java 复制代码
class Counter {
    int count;

    void increment() {
        count++;
    }
}

如果两个线程没有同步地对同一个 count 执行递增,便可能存在多个冲突访问:两个线程都读取并写入同一个变量,且至少有一个访问是写操作。

如果这些冲突访问之间不存在相应的 HB 顺序,程序就存在数据竞争(data race)。

从系统工程角度,排查并发问题应重点关注:

  • 是否存在共享变量;
  • 是否有不同线程对共享变量进行冲突访问;
  • 冲突访问之间是否存在 HB 关系;
  • 如果没有,是否需要锁、volatile、原子类或并发工具建立正确的同步协议。

不能仅凭线程 A 在时间上看起来先运行,就认定线程 B 必然看得到 A 的写入。数据竞争的规范定义及相关示例可参阅 JLS §17.4.5。

五、HB 的传递性:最重要的推导规则

5.1 传递性定义

如果:

\ A \\xrightarrow{HB} B \\

且:

\ B \\xrightarrow{HB} C \\

那么:

\ A \\xrightarrow{HB} C \\

这是 HB 关系的传递性。

传递性很重要,因为实际并发程序通常不是两个线程直接交接数据,而是经由多个线程和同步工具逐级传递。

5.2 结合 JDK 风格的代码理解传递性

下面的示例使用 volatile 和 CountDownLatch 构造一条明确的 HB 路径:

java 复制代码
import java.util.concurrent.CountDownLatch;

class HappenBeforeDemo {
    int data;
    volatile boolean ready;
    int observed;
    final CountDownLatch latch = new CountDownLatch(1);

    // 线程 T1
    void writer() {
        data = 42;       // A1:普通写
        ready = true;    // A2:volatile 写
    }

    // 线程 T2
    void relay() {
        if (ready) {     // B1:volatile 读,读到 true
            observed = data; // B2:普通读后写
            latch.countDown(); // B3:释放同步器
        }
    }

    // 线程 T3
    int reader() throws InterruptedException {
        latch.await();   // C1:成功返回后
        return observed; // C2:读取结果
    }
}

在符合上述前置条件的执行中,可以构造如下关系:

动作 关系 说明
A1 → A2 PO T1 先写数据,再写 ready
A2 → B1 SW volatile 写与后续读建立同步边
B1 → B2 PO T2 先读标志,再读数据
B2 → B3 PO T2 先写结果,再调用 countDown()
B3 → C1 SW countDown() 与对应成功返回的 await() 建立内存同步关系
C1 → C2 PO T3 等待成功返回后再读取结果

因此:

\ A1 \\xrightarrow{HB} B2 \\

并且:

\ B2 \\xrightarrow{HB} C2 \\

最终可以推出:

\ A1 \\xrightarrow{HB} C2 \\

这里 T3 并没有直接读取 T1 的 ready,但 T1 的写入仍然可以沿着同步链传递至 T3。这正是工程中通过队列、线程池、锁或其他同步器跨越多个组件传递内存可见性保证的基础。

学习标记:推导 HB 时,不要求源动作与目标动作之间存在直接同步边;只要能够建立连续的 PO/SW/HB 关系链,就可以使用传递性完成推导。

CountDownLatch 的这一语义在 JDK 源码注释中有明确说明:调用 countDown() 之前的动作,happens-before 另一个线程从对应的 await() 成功返回之后的动作。

六、结合 JDK 源码学习主要同步规则

以下源码以 OpenJDK 当前主线为参考。代码中的 [学习标记] 为学习注释,用于解释源码与 JMM 规则的对应关系,不是原始 JDK 注释的组成部分。

6.1 线程启动与终止:Thread.start()、Thread.join()

JLS 明确规定:启动线程的动作 synchronizes-with 被启动线程的第一个动作;一个线程中的所有动作 happens-before 另一个线程成功从该线程的 join() 返回之后的动作。

对应的应用代码:

java 复制代码
int config = 42;

Thread worker = new Thread(() -> {
    // [学习标记 B]
    // start() 之前的线程动作 HB 于本线程的第一个动作。
    System.out.println(config);
});

// [学习标记 A]
worker.start();

// [学习标记 C]
worker.join();

// join() 在 worker 终止后成功返回时,
// worker 的全部动作 HB 于这里后续执行的动作。

推导方式如下:

  • A:主线程先写入 config,然后调用 start(),它们之间具有 PO 关系。
  • B:start() 与工作线程的第一个动作建立 SW。
  • C:如果 join() 在工作线程终止后成功返回,则工作线程的动作 HB 于调用线程的后续动作。

注意: 如果使用带超时时间的 join(timeout),不能把"方法返回"一律当作"线程已经终止"。只有能够确认目标线程已经终止时,才可以使用对应的终止 HB 保证。

源码入口:OpenJDKThread.java。

6.2 JDK 并发同步器:AbstractQueuedSynchronizer

AbstractQueuedSynchronizer(AQS)是 JDK 多种锁和同步器的重要基础设施,例如 ReentrantLock 与 CountDownLatch。

在 OpenJDK 源码中,AQS 使用 volatile 状态字段,并在注释中明确说明 getState() 和 setState() 具有 volatile 读写的内存语义。

关键源码结构如下:

java 复制代码
// OpenJDK AbstractQueuedSynchronizer.java:节选
private volatile int state;

/**
 * Returns the current value of synchronization state.
 *
 * This operation has memory semantics of a {@code volatile} read.
 */
protected final int getState() {
    return state;
}

/**
 * Sets the value of synchronization state.
 *
 * This operation has memory semantics of a {@code volatile} write.
 */
protected final void setState(int newState) {
    state = newState;
}

学习标记 1 volatile int state

状态字段的读写具有 volatile 内存语义,为并发线程观察同步器状态建立相应的内存顺序约束。

学习标记 2 getState() 和 setState()

这两个方法不只是普通的字段访问封装。其读写语义是理解 AQS 状态同步的切入点。

学习标记 3 CAS 与原子性

AQS 还会通过 CAS 等机制更新状态,以支持竞争条件下的原子状态转换。原子更新、状态的 volatile 语义和具体同步器的公开契约共同构成正确的并发行为;不能只看到 volatile state,就认为任意两个业务操作已经自动建立了 HB 关系。

进一步阅读:OpenJDKAbstractQueuedSynchronizer.java。

6.3 JDK 源码中对 HB 最明确的说明:CountDownLatch

CountDownLatch.java 的类文档中直接写明了内存一致性语义,其核心内容是:

java 复制代码
/*
 * Memory consistency effects:
 *
 * Actions in a thread prior to calling countDown()
 * happen-before actions following a successful return
 * from a corresponding await() in another thread.
 */

此处为对源码注释的节选整理。原始注释可在 OpenJDKCountDownLatch.java 中查看。相关契约也可在 JDK 文档与 JLS 配套的并发 API 规范中查阅。

这一条源码注释对工程实践非常重要:

java 复制代码
// 生产线程
result = calculate();
latch.countDown();

// 消费线程
latch.await();
use(result);

当消费线程从对应的 await() 成功返回时,生产线程在调用 countDown() 之前的动作 HB 于消费线程在 await() 返回之后的动作。因此,result 在这一同步协议下可以被正确发布和读取。

学习标记:优先学习并发 API 文档中的 memory consistency effects(内存一致性语义)。 这些说明往往比试图根据队列字段、CAS 循环或 park/unpark 细节自行猜测内存可见性,更适合作为判断依据。

七、主要 happens-before 同步规则汇总

JMM 的核心规则及常用并发 API 的内存语义,可以整理为下表。规范依据主要来自 JLS §17.4.4、§17.4.5,以及 java.util.concurrent 的内存一致性契约。

规则 HB 的建立方式 工程意义
程序顺序(PO) 同一线程中,前面的动作 HB 于后面的动作 保持线程内语义及顺序约束
监视器锁 对同一监视器的释放 HB 于后续成功获取 synchronized 同时提供互斥和内存同步
volatile 对某字段的写 HB 于后续对该字段的读 构建轻量级发布与观察协议
线程启动 start() 动作 HB 于被启动线程的第一个动作 将启动前准备好的数据安全发布给新线程
线程终止 线程中的动作 HB 于其他线程检测到其终止后的动作 线程工作完成后获取结果
默认初始化 对象各变量的默认值初始化 HB 于程序中的其他相应动作 为初始化阶段的默认值提供规范保证
中断 interrupt() HB 于其他线程检测到中断的对应动作 在中断协议中建立内存顺序
显式锁 按 Lock 契约,释放锁前的动作 HB 于后续成功获取该锁后的动作 使用 ReentrantLock 等构建同步协议
同步器与并发工具 依 API 的具体 memory consistency effects 建立关系 利用 CountDownLatch、线程池、并发集合等传递可见性

其中有三点容易被忽略:

第一,默认初始化不等于构造方法中的全部写入都已经对其他线程安全可见。对象字段获得默认值和构造函数后续对字段进行的写入,是两个不同的问题。对象的安全发布仍然需要符合适当的同步协议。

第二,Thread.sleep() 和 Thread.yield() 主要影响调度行为,不能代替对共享变量的正确同步。线程暂停了一段时间,并不会自动建立所需的跨线程 HB 关系。

第三,notify() 的作用主要是通知等待线程具备重新竞争监视器的机会。对于 wait/notify 场景,应分析监视器释放与重新获取、共享条件变量以及相应的 PO/SW 链,而不能把"通知发生了"本身直接当作任意共享变量均已安全发布的充分条件。JLS 对监视器、等待集与通知行为有专门规定。

八、从系统工程角度总结学习方法

实际阅读 OpenJDK 的并发源码时,可以按照下面的流程进行分析。

  1. 确定共享数据。 标出哪些字段可能被多个线程读写,并区分普通读写、volatile 读写、原子操作以及监视器操作。
  2. 建立 PO。 对每个线程单独记录动作顺序,确认程序中的局部先后关系。
  3. 寻找 SW。 检查是否存在同一监视器的释放与获取、volatile 写与读、线程启动与终止,以及同步器 API 明确规定的内存一致性边。
  4. 使用传递性推导 HB。 从共享数据写入的位置出发,沿 PO 与 SW 形成的边向下追踪,判断目标线程中的读取是否受到约束。
  5. 检查未排序的冲突访问。 如果不同线程对同一变量存在冲突访问,却无法构造 HB 路径,就需要进一步判断是否存在数据竞争并修正同步协议。

最终学习结论

PO、SW、HB 并不是三个互相孤立的概念。PO 描述线程内的顺序,SW 提供跨线程同步边,HB 则将这些关系通过传递性组合成完整的内存顺序约束。

对于 Java 软件开发工程师,真正需要掌握的不是背诵规则名称,而是能够从 JDK 源码中的锁、volatile 字段、原子状态、线程生命周期和并发工具 API 出发,建立明确的同步边,推导完整的 HB 路径,并据此验证共享数据是否存在可见性问题或数据竞争。

这也是理解 AQS、ReentrantLock、CountDownLatch、线程池等并发基础设施时,应当具备的核心 JMM 分析能力。

相关推荐
电商API_180079052471 小时前
反向海淘多币种结算:汇率该在哪个时点被冻结
java·开发语言·数据库·跨境电商·1688 api
xxwl5851 小时前
区域赛分类题解
c++·笔记·学习·算法
开开心心就好2 小时前
多份文档怎么批量打印?支持多种格式
java·服务器·开发语言·pdf·ocr·散列表·启发式算法
青少儿编程课堂2 小时前
蓝桥杯+全国青少年信息素养大赛+电子学会PythonC/C++考级:最长公共子序列LCS解析
c++·python·算法·bfs·信息学竞赛
Pniubi2 小时前
模拟一个充值送token怎么保证幂等?一致性?原子性?
java
企业数字化笔记2 小时前
多租户配额怎么控制?用量口径、并发预留与超额处理的实现方法
java·数据库·spring boot
高晶3 小时前
ARM 嵌入式 Linux 文件系统从入门到实战:目录结构、权限管理、选型与挂载排查全攻略
linux·程序员
巴勒个啦3 小时前
Prompt Engineering 实战:5 个让 AI 生成生产级业务代码的工程模式
java·前端
Zwawa3 小时前
让电机走到指定位置:编码器位置环 PID
c语言·单片机·嵌入式