从Java多线程可见性谈Happens-Before原则
深入理解Java内存模型
java内存模型之:一文说清java线程的Happens-before
如果希望A线程的对volatile变量的修改对B线程可见,那么A线程对volatile的变量的写应该happens-before B线程对这个volatile变量的读
这句话,挺重要的,理解一下:
背景:
1.java的内存模型—每个线程都有自己独享的一个工作内存,所有操作都会使用自己的工作内存,而主内存是各个线程所共享的,各个线程只有通过主内存才能交互,某个线程对共享变量的操作也一定是在自己的工作内存中先进行的,随后才能同步到主内存,而其它线程也是有自己的工作内存的,这样的话,其它线程就不一定(注意是不一定,不是一定不,也就是不确定)能及时的感知到共享变量的最新值,这就是内存可见性。不能及时的意思是说不能保证某个线程对共享变量的改变就一定能被要去读取该共享变量的其它线程所读取到。
好的,这是内存可见性的问题。多线程还存在着另外一个问题,指令重排序
2.java代码被编译到执行,在不改变 在理想的单线程环境下的运行结果 下(as-if-serial原则),允许对java程序的指令进行重排。在单线程环境下,指令的重排对最终的结果并没有影响,但是如果在多线程下,就有可能会导致问题。也就是说,以另外一个线程的角度去看某一个线程的话,那么在另外一个线程看来,这个线程的执行有可能就是乱序的。但是如果另外的这个线程正好和这个线程有用到同样的共享变量的时候,有可能会存在问题,产生 意想不到的结果,究其原因,还是JMM中每个线程都操作的自己的工作内存,与主内存之间的交互没有得到保证。也就是指令重排序的问题。
我们其实并不关心,指令重不重排,我们关心的是在多线程环境下会不会对我们的最终结果产生影响。
那么必须存在某种机制,去在现有的JMM内存模型下 去规范我们的代码执行。Java以Happens-Before为原则,屏蔽各种硬件、系统的复杂实现,向开发承诺只要遵守Happens-Before原则,那么Jvm就能保证可见性
如果希望A线程的对volatile变量的修改对B线程可见,那么A线程对volatile的变量的写应该happens-before B线程对这个volatile变量的读
再来理解上面这句话:
我们知道A线程对volatile变量的写可能会在某个时间点T1,B线程对volatile变量的读取操作也可能在某个时间点T2。我们不确定这两个代码会在什么时间点执行,也就是它们执行的顺序是未知的。但是如果我们保证了,T1 > T2,也就是先改了volatile变量,再去读, 那么Java就能向我们承诺后面的这个读是一定能够读取到最新值的,也就是说,在我们做出保证的情况下,无论指令怎么重排,怎么和主存进行交互,对volatile变量的写一定会写到主内存中去,volatile变量的读一定会从主内存中读取到最新的值。反之,如果我们没有保证T1>T2,那么这个结果就是不确定的,Java将将不会做出此保证。我们写的代码肯定要保证在即使在多线程环境下,肯定不能跑出个不确定的结果出来,所以要先掌握这些Happens-Before原则。
到这里,可能会想,这不是废话么?肯定要先写了,别的线程才能读到数据呀!这一点还是要回到讲的背景,这是基于JMM内存模型下的多线程对共享数据的读写,每个线程读自己的工作内存,那你怎么强迫它从主内存中去读最新的数据,怎么强迫它改了数据就要更新到主内存中去,它们的行为都是不确定的,所以导致结果的不确定性。而这些行为都是由底层硬件复杂的实现,什么总线锁,缓存一致性协议,而且不同的硬件下这些东西还不一定一样。所以java就以Happens-Before为原则,屏蔽底层复杂的实现。只要我们遵守了这些原则,怎么实现,硬件怎么交互都可以不管,我们只关心不会不会对我们的结果产生影响。
再根据单线程规则——在一个线程内部,按照程序代码的书写顺序,书写在前面的代码操作Happens-Before书写在后面的代码操作,也就是前面的代码的操作对后面的代码是可见的。
传递性规则——如果操作A Happens-Before B,B Happens-Before C,那么可以得出操作A Happens-Before C。
结合面一共3个规则,可以推出来,在线程A对volatile的变量的写happens-before 线程B对这个volatile变量的读,那么线程A中对volatile写之前的所有操作将对线程B对这个volatile变量读之后的操作可见(是使用内存屏障实现的)。
再看下下先行发生原则(Happens-Before):
【Action A should have a happens-before relationship with action B, in order to ensure result of action A is visible to action B】
翻译翻译什么tm的叫Happens-Before:为了让A操作的结果对B操作可见,那么A应该和B存在Happens-Before关系。
给老子说人话:
【如果 A Happens Before B,那么A的操作将对B可见。】
也就是说只要A操作和B操作存在Happens-Before关系,那么A操作将对B操作可见。换句话说,如果我们要让B对A的操作可见,那么就要存在这样的Happens-Before关系。
哦,原来这tm叫Happens-Before吖
1、程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生与书写在后面的操作。【保证单线程的有序】
2、锁定规则:一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。
3、volatile 变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作。【先写后读】
4、传递规则:A 先于 B 且 B 先于 C 则 A 先于 C
5、线程启动规则:Thread 对象的 start 方法先行发生于此线程的每一个动作。
6、线程中断规则:对线程 interrupt 方法的调用先行发生于被中断线程的代码检测到中断事件的发生。【先中断,后检测】
7、线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过 Thread.join() 方法结束,Thread.isAlive() 的返回值手段检测线程已经终止执行。
8、对象终结规则:一个对象的初始化完成先行发生于它的 finalize 方法的开始。
再引用下大佬的文章部分内容:
再次思考Happens-Before规则的真正意义
到这里我们已经讨论了线程的可见性问题和导致这个问题的原因,并详细阐述了8条Happens-Before原则和它们是如何帮助我们解决变量可见性问题的。下面我们在深入思考一下,Happens-Before原则到底是如何解决变量间可见性问题的。
我们已经知道,导致多线程间可见性问题的两个“罪魁祸首”是CPU缓存和重排序。那么如果要保证多个线程间共享的变量对每个线程都及时可见,一种极端的做法就是禁止使用所有的重排序和CPU缓存。即关闭所有的编译器、操作系统和处理器的优化,所有指令顺序全部按照程序代码书写的顺序执行。去掉CPU高速缓存,让CPU的每次读写操作都直接与主存交互。
当然,上面的这种极端方案是绝对不可取的,因为这会极大影响处理器的计算性能,并且对于那些非多线程共享的变量是不公平的。
重排序和CPU高速缓存有利于计算机性能的提高,但却对多CPU处理的一致性带来了影响。为了解决这个矛盾,我们可以采取一种折中的办法。我们用分割线把整个程序划分成几个程序块,在每个程序块内部的指令是可以重排序的,但是分割线上的指令与程序块的其它指令之间是不可以重排序的。在一个程序块内部,CPU不用每次都与主内存进行交互,只需要在CPU缓存中执行读写操作即可,但是当程序执行到分割线处,CPU必须将执行结果同步到主内存或从主内存读取最新的变量值。那么,Happens-Before规则就是定义了这些程序块的分割线。下图展示了一个使用锁定原则作为分割线的例子:
如图所示,这里的unlock M和lock M就是划分程序的分割线。在这里,红色区域和绿色区域的代码内部是可以进行重排序的,但是unlock和lock操作是不能与它们进行重排序的。即第一个图中的红色部分必须要在unlock M指令之前全部执行完,第二个图中的绿色部分必须全部在lock M指令之后执行。并且在第一个图中的unlock M指令处,红色部分的执行结果要全部刷新到主存中,在第二个图中的lock M指令处,绿色部分用到的变量都要从主存中重新读取。
在程序中加入分割线将其划分成多个程序块,虽然在程序块内部代码仍然可能被重排序,但是保证了程序代码在宏观上是有序的。并且可以确保在分割线处,CPU一定会和主内存进行交互。Happens-Before原则就是定义了程序中什么样的代码可以作为分隔线。并且无论是哪条Happens-Before原则,它们所产生分割线的作用都是相同的。
最后再来分析下两个例子:
@Slf4j
public class TestVisible {
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
while(run){
// ....
}
});
t1.start();
Thread.sleep(1000);
run = false; // 线程t1不会如预想的停下来
}
}
上面的代码存在可见性的问题,线程t1在主线程修改了共享变量run后,仍然没有停下来,为什么没有停下来?不考虑别的,就是条件不满足嘛,说明在t1眼里(工作内存里),run仍然是false,它没有去主内存去看下run的最新值,其实就是:t1线程不存在与main线程间的Happens-Before关系。所以JMM将不会向我们保证main线程对run共享变量的修改对t1线程可见。
那么问题来了,为什么给run加上volatile修饰之后,t1就能够停止呢?请注意这里不是t1能够停止,是t1必须停止,这是JMM对我们的承诺。也从Happens-Before规则去看下,先看看规则【volatile 变量规则:对一个volatile变量的写操作先行发生于后面对这个volatile变量的读操作。】,这也就是说——对一个volatile变量的写操作如果先行发生于对这个volatile变量的读操作的话,那么JMM将向我们承诺这个volatile变量的写操作将会被后面的volatile读操作看到,说人话就是——后面的读操作将能够看到前面前对volatile变量的写操作。刚开始1s过后main线程对volatile变量run写了对吧,而t1线程在不断的去读这个volatile变量对吧,那么main线程在修改volatile变量后,这个时候t1线程肯定就去读了(先不管t1是怎么读的这个细节),那么根据HappensBefore,JMM这个时候就要保证t1线程是一定能够看到的,前提是对这个volatile的读操作在volatile变量写操作的后面,满足了Happens-Before原则。这个时候,再去体会一下这个例子,为什么之前没能停下来,加上volatile就能停下来。这是从Happens-Before的角度去看的这个问题。那么从实现上来看,t1线程在不断的读取volatile变量的时候,必然只能从主内存中去读(缓存失效),而main线程对volatile变量的修改要立即同步到主内存中去,这样就保证了t1线程读到了volatile变量的最新值。
为了让这个示例更有说服力了,我们做些修改,添加一个共享变量i,如下:
package com.zzhua.test19;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class TestVisible {
static Boolean run = true;
static int i = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (run) {
i++;
}
});
t1.start();
Thread.sleep(1000);
run = false; // 线程t1不会如预想的停下来
}
}
测试发现以上代码执行,线程t1不会停下来。
但是如果我们把int换成Integer(仅仅改这一个地方),那么上面的代码会停下来,代码如下:
package com.zzhua.test19;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class TestVisible {
static Boolean run = true;
static Integer i = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (run) {
i++;
}
});
t1.start();
Thread.sleep(1000);
run = false; // 线程t1会停下来
}
}
从这个示例可以看出t1线程可能会往主内存中读取数据,也可能不会往主内存中读取数据,这个动作是不确定的,也就是主内存中的最新变量对于t1线程来说并不保证可见,因为这里不存在Happens-Before关系,但是我们的代码为了让他如我们预想的执行,而不会产生意料之外的结果,就必须把Happens-Before考虑进去。
为了引出第二个问题,我们先说下,
同时还需要注意的是,根据前面推导出来的那个结论,对volatile变量修改前的对共享变量的所有操作对volatile变量读后的所有操作也都是可见的。可以结合下面的图理解下,第二个操作如果是写的话,那么前面的操作就不能排到写操作指令的后面去,也就是说先执行了前面的指令,才能执行写操作指令,也就验证了我们推出来的这条结论。
再看个例子
int num = 0;
boolean ready = false;
// 线程1 执行此方法
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
// 线程2 执行此方法
public void actor2(I_Result r) {
num = 2;
ready = true;
}
这里存在两个共享变量num和ready,线程2的修改在我们的理解看来就是,num先等于2,然后ready再改为true。如果线程1和线程2并发执行,可能会出现一个诡异的结果:r.r1有可能为0。噫,这怎么可能呢?如果是0的话,那必然是从if里面出来的,也就是说ready是true的这中情况,那么ready如果是true的话,那么看看线程2里面,num就肯定是2了,结果怎么可能是0呢?因为这里面有可能会发生指令重排。线程2这里面的两句代码之间没有存在依赖性,所以在不改变单线程运行结果的前提下,JIT编译器或者后面执行的指令有可能会重排,一旦重排,就有可能先执行ready=true,然后再num=2,再执行ready=true的时候,就把ready=true写到了主内存,而线程1恰好就读到了ready=true,然后这个时候的num还等于0,r.r1结果就是0了。出现了我们意想不到的结果,究其原因是发生了指令重排序导致的。
我们给ready加上volatile修饰,可以发现不会出现结果是0的情况,我们从刚刚推导出来的结论来分析下,对volatile变量修改前的对共享变量的所有操作对volatile变量读后的所有操作也都是可见的(根据传递性规则、程序顺序规则、volatile变量规则推导)。先推导一下吧,在线程2中,按照程序顺序规则(在一个线程内部,按照程序代码的书写顺序,书写在前面的代码操作Happens-Before书写在后面的代码操作)num=2的书写顺序 Happens Before ready = true; 所以num=2对ready=true来说是可见的,而ready是被volatile修饰了,那么根据volatile变量规则(对一个变量的写操作先行发生于后面对这个变量的读操作),也就是如果线程1先执行ready=true,那么线程2再去读,就能推出对线程2 ready变量读的操作对线程1 ready变量写操作前面的操作可见。而从线程2来看,根据程序顺序规则,线程2中对volatile变量的读的操作对读后面的操作来说是可见的。于是线程1中对num变量的操作对线程2中对num变量的读取是可见的。至于里面是怎么实现的,这个不用管,JMM帮我们屏蔽了这些细节。
volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)
- 对 volatile 变量的写指令之后会加入写屏障
- 对 volatile 变量的读指令之前会加入读屏障
保证可见性
-
写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中
-
而**读屏障**(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据
保证有序性
- 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
- 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
为什么要去熟悉Happens-Before规则?
happens-before 规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结。抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见。
并且底层实现实在是有可能太多太复杂了,没有必要花费大量时间去了解,最好是先通过一些例子用Happens-Before规则,熟悉了之后再说。也看到了,就单单volatile就搞死人了,还有其它的锁规则,线程启动规则,中断规则。。。
再来看个例子:
@Slf4j
public class TestVisible {
static boolean run = true;
static Object obj = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
while(true){
synchronized (obj) { // 使用synchronized关键字后,该程序正常结束
if (!run) {
break;
}
// ....
}
}
});
t1.start();
Thread.sleep(1000);
run = false;
}
}
上面的代码中为什么能正确停止?run并没有volatile修饰呀。这里不从步骤原理触发,仅从Happens-Before角度考虑这个问题。线程t1不断的加锁-访问run的值-解锁,然后一直做这样的循环。1s钟之后,主线程把run改成了false,那为什么t1凭啥就能感知到run的最新值?为什么 没加synchronzied就不能感知到run的最新值?这里根据锁规则:对锁M解锁之前的所有操作Happens-Before对锁M加锁之后的所有操作(根据程序顺序规则+传递性规则+锁规则可以推导出来的结论)。既然t1在不断的加锁和解锁,那么对于主线程在某个时间点T1修改了run,那么T1这个时间必然就发生在某个解锁操作(假设时间点为T2)之前,那么对于这个解锁操作的下一个加锁操作(时间点假设为T3)而言,在时间点T1 是不是就 Happens Before 了 T3了,那么根据HappensBefore规则,在T1时间点的这个操作将对T3可见,也就是t1线程对run值的最新值可见。之前没有加synchronized那为什么不行呢?因为不存在happens-before关系呀,JMM将无法向我们保证t1能读到普通变量run的最新值,这就是说如果我们要保证A操作 对B操作 可见,那么必须要找到A Happens Before B的关系。
我感觉这样根据先行发生原则去推导比另外一种说法(比如:synchronized是重量级锁,它能保证原子性,可见性)更容易接受,总得说个道理出来吧,那我觉得推出来的这个结论就是其中的道理,至于底层是怎么实现的,爱怎么实现怎么实现!那么其实要达到这样推论的结果,那么就必须在每次加锁之前,都要清空掉线程的工作内存,然后它就能从主内存中获取值了,而在每次解锁之前,都要把它的修改值更新到主内存中。伊,这样好像也能推出来synchronized不仅保证了原子性,也能保证代码块内变量的可见性。
引入下 一个误区,
警惕误区
网上绝大多数的文章在描述happens-before关系时,关于已经存在还是需要开发者留心维护这个点上上没有说清楚。
官方文档所描述的happens-before并非陈述句,不是在表述一个客观事实,而是一个应该补上一个should,完整的语义应该为: Action A should have a happens-before relationship with action B, in order to ensure result of action A is visible to action B.
如果我们把需要我们花气力维护的happens-before关系当成了天然存在的关系,例如,这样的一条规则:“* A write to a volatile field happens-before every subsequent read of that field.”,这是一条需要开发者维护的规则,如果开发者直接把这条规则当成了固有事实,无论怎样的代码顺序都可以保证“对volatile变量的写一定发生在读之前”,那么线程安全就完全得不到保障了。
因此,happens-before将主要分为两类:即已经存在和开发者自行保证。