介绍一下Java内存模型(JMM)中的 ​​happens-before​​

39 阅读4分钟

​happens-before​​ 是 JMM 中一个非常核心且抽象的概念,它是理解多线程内存可见性和有序性的关键。

1. 什么是 ​​happens-before​​?

​happens-before​​ 是一个 偏序关系,用于描述多线程环境下两个操作之间的 内存可见性执行顺序

如果操作 A​happens-before​​ 操作 B,那么:

  1. 内存可见性:操作 A 执行的结果对操作 B 是 可见的。也就是说,B 操作在执行时,能够看到 A 操作对共享变量所做的所有修改。
  2. 执行顺序:从 JMM 的角度来看,操作 A 的执行 先于 操作 B。

注意:​happens-before​​ 并不意味着操作 A 在物理时间上一定比操作 B 先执行。它是一个 JMM 层面的保证,确保了在符合规则的前提下,多线程程序的行为是可预测的。

2. 为什么需要 ​​happens-before​​ 规则?

在多线程环境中,由于编译器优化、CPU 指令重排序和 CPU 缓存等原因,代码的实际执行顺序和我们编写的顺序可能不同,这会导致线程间的数据竞争和可见性问题。

​happens-before​​ 规则为我们提供了一套 “安全网” 。只要我们的代码满足这些规则,JMM 就会保证程序的并发安全性,我们无需关心底层的重排序和缓存细节。

3. JMM 中的 ​​happens-before​​ 规则

JMM 定义了以下几条基本的 ​​happens-before​​ 规则,这些规则是所有并发编程的基础:

1. 程序顺序规则 (Program Order Rule)

在一个 单线程 中,按照代码的书写顺序,前面的操作​happens-before​​ 后面的操作

这保证了在单线程内,指令的执行看起来是有序的,符合我们的直觉。

示例:

// 同一个线程内
int a = 1; // 操作 A
int b = a + 2; // 操作 B

// 根据程序顺序规则,A happens-before B。
// 所以,B 操作可以看到 A 操作对 a 的赋值(a = 1),因此 b 的结果一定是 3
2. 监视器锁规则 (Monitor Lock Rule)

一个线程 释放锁 的操作 ​​happens-before​​ 另一个线程 获取同一个锁 的操作。

这是 ​​synchronized​​ 关键字实现线程安全的核心保证。

示例:

Object lock = new Object();
int sharedVar = 0;

// 线程 A
synchronized (lock) { // 获取锁
    sharedVar = 1; // 操作 A (释放锁前的最后一个操作)
} // 释放锁 (操作 A')

// 线程 B
synchronized (lock) { // 获取锁 (操作 B')
    System.out.println(sharedVar); // 操作 B
} // 释放锁

// 根据监视器锁规则,A' (释放锁) happens-before B' (获取锁)。
// 又因为 A happens-before A' (程序顺序规则),且 B' happens-before B (程序顺序规则),
// 所以 A happens-before B。
// 因此,线程 B 执行操作 B 时,一定能看到线程 A 执行操作 A 对 sharedVar 的修改(sharedVar = 1)。
3. ​​volatile​​ 变量规则 (Volatile Variable Rule)

对一个 ​​volatile​​ 变量的 写操作​happens-before​​ 后续对同一个 ​​volatile​​ 变量的 读操作

这保证了 ​​volatile​​ 变量的修改对其他线程是立即可见的。

示例:

volatile boolean flag = false;

// 线程 A
flag = true; // 操作 A (写 volatile 变量)

// 线程 B
if (flag) { // 操作 B (读 volatile 变量)
    // ...
}

// 根据 volatile 变量规则,A happens-before B。
// 所以,线程 B 执行操作 B 时,能够看到线程 A 对 flag 的修改(flag = true)。
4. 线程启动规则 (Thread Start Rule)

调用 ​​Thread.start()​​ 方法的操作 ​​happens-before​​ 该启动线程内的任意操作。

示例:

Thread threadB = new Thread(() -> {
    // 线程 B 内的操作 B
    System.out.println("Thread B is running");
});

// 主线程
threadB.start(); // 操作 A

// 根据线程启动规则,A happens-before B。
// 这意味着线程 B 开始执行时,主线程在 start() 之前的所有操作对 B 都是可见的。
5. 线程终止规则 (Thread Termination Rule)

线程内的所有操作都 ​​happens-before​​ 其他线程检测到该线程已经终止的操作。

检测线程终止的方式包括:调用 ​​Thread.join()​​ 并返回、调用 ​​Thread.isAlive()​​ 返回 ​​false​​ 等。

示例:

Thread threadA = new Thread(() -> {
    // 线程 A 内的操作 A
    sharedVar = 100;
});

threadA.start();

// 主线程
threadA.join(); // 操作 B (检测线程 A 终止)
System.out.println(sharedVar); // 操作 C

// 根据线程终止规则,A happens-before B。
// 又因为 B happens-before C (程序顺序规则),
// 所以 A happens-before C。
// 因此,主线程在操作 C 时,一定能看到线程 A 在操作 A 中对 sharedVar 的修改。
6. 传递性规则 (Transitivity)

如果操作 A ​​happens-before​​ 操作 B,并且操作 B ​​happens-before​​ 操作 C,那么操作 A ​​happens-before​​ 操作 C。

这是一个非常重要的推导规则,它允许我们将多个基本规则组合起来,推断出复杂操作之间的 ​​happens-before​​ 关系。

我们在前面的 ​​synchronized​​ 示例中已经用到了传递性。

总结

​happens-before​​ 规则是 JMM 为程序员提供的一套 并发编程的“契约” 。它屏蔽了底层硬件和编译器的复杂细节,让我们能够通过遵守这些规则来编写出正确的多线程程序。

简单来说,只要我们的代码满足​happens-before​​ 规则,我们就可以确保一个线程的修改能被另一个线程正确地看到,从而避免数据竞争和内存可见性问题。