根据语义,Happens-Before,就是即便是对于不同的线程,前面的操作也应该发生在后面操作的前面,也就是说,Happens-Before 规则保证:前面的操作的结果对后面的操作一定是可见的。
Happens-Before 规则本质上是一种顺序约束规范,用来约束编译器的优化行为。就是说,为了执行效率,我们允许编译器的优化行为,但是为了保证程序运行的正确性,我们要求编译器优化后需要满足 Happens-Before 规则。
根据类别,我们将 Happens-Before 规则分为了以下 4 类:
- 操作的顺序:
- 程序顺序规则: 如果代码中操作 A 在操作 B 之前,那么同一个线程中 A 操作一定在 B 操作前执行,即在本线程内观察,所有操作都是有序的。
- 传递性: 在同一个线程中,如果 A 先于 B ,B 先于 C 那么 A 必然先于 C。
- 锁和 volatile:
- 监视器锁规则: 监视器锁的解锁操作必须在同一个监视器锁的加锁操作前执行。
- volatile 变量规则: 对 volatile 变量的写操作必须在对该变量的读操作前执行,保证时刻读取到这个变量的最新值。
- 线程和中断:
- 线程启动规则:
Thread#start()
方法一定先于该线程中执行的操作。 - 线程结束规则: 线程的所有操作先于线程的终结。
- 中断规则: 假设有线程 A,其他线程 interrupt A 的操作先于检测 A 线程是否中断的操作,即对一个线程的
interrupt()
操作和interrupted()
等检测中断的操作同时发生,那么interrupt()
先执行。
- 线程启动规则:
- 对象生命周期相关:
- 终结器规则: 对象的构造函数执行先于
finalize()
方法。
- 终结器规则: 对象的构造函数执行先于
volatile 的实现原理
这里比较有趣的是有关 volatile 的规则,volatile 变量有以下两个特点:
- 保证对所有线程的可见性。
- 禁止指令重排序优化。
我之前一直以为,如果一个变量被标记成了 volatile 变量,那么这个变量的值就不会被加载进线程的工作内存中,而是直接在主内存上进行读写。
实际上并不是这样的,因为这样我们需要为 volatile 变量的读写设置一套特殊的规则,这显然是不合适。即使是 volatile 变量,也是从工作内存中读取的,只是它有特殊的操作顺序规定,使得看起来像是直接在主内存中读写。
Happens-Before 规则中要求,对 volatile 变量的写操作必须在对该变量的读操作前执行,这个规则听起来很容易,那实际上是如何实现的呢?解决方法分两步:
- 保证动作发生;
- 保证动作按正确的顺序发生。
1. 保证动作发生
首先,在对 volatile 变量进行读取和写入操作,必须去主内存拉取最新值,或是将最新值更新进主内存,不能只更新进工作内存而不将操作同步进主内存,即在执行 read
、load
、use
、assign
、store
、write
操作时:
use
操作必须与load
、read
操作同时出现,不能只use
,不load
、read
。use
<-load
<-read
assign
操作必须与store
、write
操作同时出现,不能只assign
,不store
、write
。assign
->store
->write
此时,我们已经保证了将变量的最新值时刻同步进主内存的动作发生了,接下来,我们需要保证这个动作,对于不同的线程,满足 volatile 变量的 Happens-Before 规则:对变量的写操作必须在对该变量的读操作前执行。
2. 保证动作按正确的顺序发生
其实,导致这个执行顺序问题的主要原因在于,这个读写 volatile 变量的操作不是一气呵成的,它不是原子的!无论是读还是写,它都分成了 3 个命令(use
<- load
<- read
或 assign
-> store
-> write
),这就导致了,你能保证 assignA
发生在 useB
之前,但你根本不能保证 writeA
也发生在 useB
之前,而如果 writeA
不发生在 useB
之前,主内存中的数据就是旧的,线程 B 就读不到最新值!
为什么volatile不是原子的
·举个栗子·
一个变量i被volatile修饰,两个线程想对这个变量修改,都对其进行自增操作也就是i++,i++的过程可以分为三步,首先获取i的值,其次对i的值进行加1,最后将得到的新值写会到缓存中。
首先线程A读取了i的变量的值,这个时候线程切换到了B,线程B同样从主内存中读取i的值,由于线程A没有对 i 做过任何修改,此时线程B获取到的i仍然是100。线程B工作内存中为i执行了加1的操作,但是没有刷新到主内存中,这个时候又切换到了A线程,A线程直接对工作内存中的100进行加1运输(因为A线程已经读取过i的值了),由于线程B并未写入i的最新值,这个时候A线程的工内存中的100不会失效。
最后,线程A将i=101写入主内存中,线程B也将i=101写入主内存中。 始终需要记住,i++ 的操作是3步骤!
所以,我觉得这句话应当换一个理解方式:假设我是一个写操作,你发生在我之前的读操作可以随便执行,各个分解命令先于我还是后于我都无所谓。但是,你发生在我之后的读操作,必须等我把 3 个命令都执行完,才能执行!不许偷偷把一些指令排到我的最后一个指令的前面去。 这才是 “对变量的写操作必须在对该变量的读操作前执行” 的本质。
volatile 的真实实现
那么 Java 是如何利用现有的工具,实现了上述的两个效果的呢?
答案是:它巧妙的利用了 lock
操作的特点,通过 观察对 volatile 变量的赋值操作的反编译代码,我们发现,在执行了变量赋值操作之后,额外加了一行:
lock addl $0x0,(%esp)
这一句的意思是:给 ESP 寄存器 +0
,这是一个无意义的空操作,重点在 lock
上:
- 保证动作发生:
lock
指令会将当前 CPU 的 Cache 写入内存,并无效化其他 CPU 的 Cache,相当于在执行了assign
后,又进行了store
->write
;- 这使得其他 CPU 可以立即看见 volatile 变量的修改,因为其他 CPU 在读取 volatile 变量时,会发现自己的缓存过期了,于是会去主内存中拉取最新的 volatile 变量值,也就被迫在
use
前进行一次read
->load
。
- 保证动作顺序:
lock
的存在相当于一个内存屏障,使得在重排序时,不能把后面的指令排在内存屏障之前。
这个实现是不是十分的巧妙呀~