前言
在java并发编程中,我们经常会遇到这样的问题:多线程环境下,一个线程对共享变量的修改,另一个线程能看到吗?为什么有时候明明修改了变量,其他线程却读取到旧值?这些问题的答案,都与java内存模型(jmm)中的happens-before原则密切相关。
本文将从基础概念出发,循序渐进地解析happens-before的定义、核心规则及实际应用,帮助你彻底理解这一java并发编程的基石。
一、happens-before是什么?为什么需要它?
1.1 从一个问题说起
先看一段简单的代码:
// 线程a执行 int x = 1; // 操作a boolean flag = true; // 操作b // 线程b执行 if (flag) { // 操作c system.out.println(x); // 操作d }
如果线程a和线程b并发执行,线程d会打印出1吗?
直觉上应该会,但实际上可能不会。因为在多线程环境中,编译器优化、cpu指令重排序、缓存等机制可能导致:
- 线程a中,操作a和b的执行顺序可能被调换(重排序)
- 线程a修改的x和flag可能还停留在cpu缓存中,未同步到主内存
- 线程b可能读取的是主内存中未更新的旧值
这些问题会导致可见性和有序性问题。而happens-before原则就是jmm为解决这些问题提出的核心规范。
1.2 happens-before的定义
happens-before是jmm中定义的两个操作之间的偏序关系:
如果操作a happens-before操作b,那么a的执行结果必须对b可见,且a的执行顺序在逻辑上先于b。
注意:
- happens-before不代表“物理时间上的先后”,而是jmm定义的“逻辑先行”关系。
- 即使a在物理时间上后于b执行,只要a happens-before b,a的结果就必须对b可见。
1.3 为什么需要happens-before?
jmm的核心目标是:在保证并发程序正确性的前提下,尽可能为编译器和cpu的优化留出空间。
happens-before的价值在于:
- 它屏蔽了底层硬件和编译器的复杂细节(如重排序、缓存等),为开发者提供了简单清晰的可见性判断标准。
- 它允许编译器和cpu在不违反happens-before规则的前提下进行优化,保证性能。
简单说:开发者只需关注是否满足happens-before规则,无需关心底层如何实现可见性;jvm则需保证在满足规则的情况下,底层优化不破坏可见性。
二、happens-before的核心规则
jmm定义了8条核心的happens-before规则,这些规则是判断可见性的基础。我们逐一解析:
规则1:程序顺序规则(program order rule)
在同一个线程中,按照代码顺序,前面的操作happens-before后面的操作。
例如:
// 单线程内 int a = 1; // 操作a int b = a + 1; // 操作b
根据程序顺序规则,a happens-before b。因此:
- 操作a的结果(a=1)对操作b可见
- 逻辑上a先于b执行(即使编译器可能重排序,但会保证结果等价于顺序执行)
规则2:监视器锁规则(monitor lock rule)
对一个锁的解锁操作happens-before后续对同一个锁的加锁操作。
synchronized
是java中最典型的监视器锁,例如:
synchronized (lock) { // 加锁 // 临界区操作a x = 1; } // 解锁(操作u) // 其他线程 synchronized (lock) { // 加锁(操作l) // 临界区操作b system.out.println(x); // 必然打印1 }
根据规则:
解锁操作u happens-before 后续的加锁操作l,因此操作a的结果(x=1)对操作b可见。
这就是synchronized
能保证可见性的底层原因。
规则3:volatile变量规则(volatile variable rule)
对volatile变量的写操作happens-before后续对同一个volatile变量的读操作。
例如:
// 线程a volatile int x = 0; x = 1; // 写操作w // 线程b int y = x; // 读操作r
根据规则:w happens-before r,因此线程b读取到的y必然是1(而非0)。
原理:
volatile变量的写操作会强制将缓存中的值刷新到主内存,读操作会强制从主内存加载最新值,且禁止了volatile变量前后操作的重排序,从而保证可见性。
规则4:线程启动规则(thread start rule)
主线程对thread对象的start()方法调用happens-before子线程中的所有操作。
例如:
// 主线程 int x = 1; thread t = new thread(() -> { // 子线程操作 system.out.println(x); // 必然打印1 }); t.start(); // 启动操作s
根据规则:s happens-before子线程中的打印操作,因此主线程在start()前对x的修改(x=1)对sub线程可见。
规则5:线程终止规则(thread termination rule)
子线程中的所有操作happens-before主线程检测到子线程终止。
主线程可以通过join()
、isalive()
等方法检测子线程是否终止,例如:
// 主线程 thread t = new thread(() -> { // 子线程操作a x = 1; }); t.start(); t.join(); // 等待子线程终止(操作j) system.out.println(x); // 必然打印1
根据规则:子线程的操作a happens-before 主线程的操作j,因此主线程在join()后能看到x=1。
规则6:线程中断规则(thread interruption rule)
对线程interrupt()方法的调用happens-before被中断线程检测到中断事件。
例如:
// 线程a thread t = new thread(() -> { // 子线程 if (thread.interrupted()) { // 检测中断(操作c) system.out.println("被中断"); } }); t.start(); t.interrupt(); // 中断操作i
根据规则:i happens-before c,因此子线程能检测到中断事件。
规则7:传递性规则(transitivity)
如果a happens-before b,且b happens-before c,那么a happens-before c。
这是非常重要的一条规则,它可以将多个happens-before关系串联起来,例如:
// 线程a x = 1; // a volatile boolean flag = true; // b // 线程b if (flag) { // c(读volatile) int y = x; // d }
- 根据程序顺序规则:a happens-before b,c happens-before d
- 根据volatile规则:b happens-before c
- 根据传递性:a happens-before d → 因此d能看到x=1
规则8:对象终结规则(finalizer rule)
对象的构造函数执行完毕happens-before其finalize()方法开始执行。
确保对象在被回收前,其构造函数的所有初始化操作都已完成,例如:
class myobject { int x; myobject() { x = 1; // 构造函数操作a } @override protected void finalize() { system.out.println(x); // 必然打印1(操作b) } }
根据规则:a happens-before b,因此finalize()中能看到构造函数对x的初始化。
三、happens-before与重排序的关系
jmm允许编译器和cpu进行重排序优化,但有一个前提:重排序不能破坏happens-before规则。
例如,在单线程中:
int a = 1; // a int b = 2; // b int c = a + b; // c
编译器可能将a和b重排序(先执行b再执行a),但由于a和b之间没有数据依赖,且重排序后c的结果仍为3,因此这种重排序是允许的——它没有破坏程序顺序规则的happens-before关系(a和b的执行顺序不影响最终结果的可见性)。
但如果是:
int a = 1; // a int b = a; // b
a和b存在数据依赖(b依赖a的结果),编译器不能重排序a和b,否则会破坏程序顺序规则的happens-before关系(b可能读取到a的旧值)。
四、不满足happens-before的情况:可见性问题
如果两个操作之间不存在任何happens-before规则,jmm无法保证它们的可见性,可能出现“脏读”。
例如:
// 线程a int x = 1; // a // 线程b system.out.println(x); // b
a和b之间没有任何happens-before关系(不满足上述8条规则中的任何一条),因此:
- 线程b可能打印1(x已同步到主内存)
- 也可能打印0(x仍在a的cpu缓存中,未同步)
这种情况下,结果是不确定的,这就是多线程编程中“可见性问题”的根源。
五、happens-before的实际应用
happens-before是理解java并发工具的基础,以下是几个典型场景:
5.1 synchronized与happens-before
synchronized
通过“解锁-加锁”的happens-before关系保证可见性:
// 线程a synchronized (lock) { x = 1; // 解锁前的操作 } // 解锁u // 线程b synchronized (lock) { // 加锁l(u happens-before l) system.out.println(x); // 可见x=1 }
5.2 volatile与happens-before
volatile通过“写-读”的happens-before关系保证可见性:
// 线程a volatile boolean ready = false; int data = 0; data = 1; // a ready = true; // b(写volatile) // 线程b if (ready) { // c(读volatile,b happens-before c) system.out.println(data); // d(a happens-before d,因此可见1) }
这里结合了程序顺序规则(a happens-before b)、volatile规则(b happens-before c)和传递性规则(a happens-before d)。
5.3 concurrenthashmap与happens-before
concurrenthashmap的put
操作与get
操作之间存在happens-before关系:
- 线程a的
put(k, v)
操作happens-before线程b的get(k)
操作 - 因此线程b的
get(k)
能看到线程aput
的v值
这是concurrenthashmap保证线程安全的底层基础之一。
六、常见面试问题
- happens-before的定义是什么?
- 它是jmm中定义的两个操作之间的偏序关系:如果a happens-before b,则a的结果对b可见,且a的逻辑执行顺序先于b。
- happens-before和物理时间顺序有什么区别?
- 无关。happens-before是逻辑先行关系,与物理时间上的先后无关。即使a在物理时间上后于b执行,只要a happens-before b,a的结果就必须对b可见。
- volatile变量的写操作和读操作之间有什么happens-before关系?
- 对volatile变量的写操作happens-before后续对该变量的读操作,这保证了volatile变量的可见性。
- 如何利用happens-before规则判断多线程操作的可见性?
- 只要两个操作之间存在通过happens-before规则(直接或间接)建立的关系,就可以保证可见性;否则,可见性无法保证。
七、总结
happens-before是java内存模型的核心,它为开发者提供了判断多线程操作可见性的清晰标准。理解happens-before,你就能:
- 明白为什么
synchronized
、volatile
等关键字能保证可见性 - 避免多线程编程中的“脏读”“不可见”等问题
- 更深入地理解java并发工具(如concurrenthashmap、aqs)的底层原理
记住:happens-before的本质是“可见性契约”——jmm通过它承诺,只要满足规则,就保证操作结果的可见性。掌握这一原则,是成为java并发编程高手的关键一步。
到此这篇关于深入浅出java中的happens-before核心规则的文章就介绍到这了,更多相关java happens-before内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!
发表评论