面试题:happen-before的八个基本规则你知道吗?
Happens-Before真正要表达的是:前面一个操作的结果对后续操作是可见的
。
就像有心灵感应的两个人,虽然远隔千里,一个人心之所想,另一个人都看得到。Happens-Before 规则就是要保证线程之间的这种“心灵感应”。
Happens-Before原则它是判断数据是否存在竞争
、线程是否安全
的主要依据,依靠这个原则,我们解决在并发环境下两操作之间是否可能存在冲突的所有问题
所以比较正式的说法是:Happens-Before约束了编译器的优化行为
,虽允许编译器优化,但是要求编译器优化后一定遵守 Happens- Before 规则。
在java内存模型(JMM)中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。
Happens-Before程序员相关的规则一共有如下八项,都是关于可见性
的。
这条规则是指在一个线程
中,按照程序顺序,前面的操作 Happens-Before于后续的任意操作
。
比如下面的示例代码,按照程序的顺序,“x = 42;” Happens-Before 于代码 v = x;”,这就是规则1的内容,也比较符合单线程里面的思维:程序前面对某个变量的修改一定是对后续操作可见的。
class VolatileExample {
int x = 0;
volatile int v = 10;
public void writer() {
x = 42;
v = x;
}
}
}
这条规则是指对一个volatile变量的写操作,Happens-Before 于后续对这个volatile变量的读操作
。
这个就有点费解了,对一个volatile变量的写操作相对于后续对这个 volatile 变量的读操作可见,这怎么看都是禁用缓存的意思啊,貌似和 1.5 版本以前的语义没有变化啊?如果
单看这个规则,的确是这样,但是如果我们关联一下规则 3,就有点不一样的感觉了。
这条规则是指如果 A Happens-Before B,且 B Happens-Before C,那么A Happens-Before C。
下面我们结合具体的代码,我们利用这条规则推导下
public class VolatileExample {
private int a = 0;
private volatile boolean flag = false;
public void writer(){
a = 1; //1
flag = true; //2
}
public void reader(){
if(flag){ //3
int i = a; //4
}
}
}
我们假设这里有线程A和线程B,线程A先执行writer方法,然后再线程B执行reader方法。我们将规则3的传递性和规则2和规则1应用到我们的例子中,会发生什么呢?可以看下面这幅图:
从图中,我们可以看到:
- 根据程序顺序规则推导出来黑色的线条
- 根据volatile变量规则推导出来红色的线条,volatile写happens-before 于任意后续对volatile变量的读,也就是说当线程A将volatile变量 flag更改为true后线程B就能够迅速感知,是可见的
- 根据传递性规则推导出来蓝色的线条,我们得到结果:“a=1” Happens-Before读变量“ int i = a;”,这就是 1.5 版本对volatile 语义的增强,这个增强意义重大,1.5版本的并发工具包(java.util.concurrent)就是靠volatile语义来搞定可见性的,这个在后面的内容中会详细介绍
分析完前三个happens-before关系后我们现在就来进一步分析volatile的内存语义,还是以上面的代码为例,假设线程A先执行writer方法,线程B随后执行reader方法,初始时线程的本地内存中flag和a都是初始状态,下图是线程A执行volatile写后的状态图。
当volatile变量写后,线程中本地内存中共享变量就会置为失效的状态,因此线程B再需要读取从主内存中去读取该变量的最新值。下图就展示了线程B读取同一个volatile变量的内存变化示意图
从横向来看,线程A和线程B之间进行了一次通信,线程A在写volatile变量时,实际上就像是给B发送了一个消息告诉线程B你现在的值都是旧的了,然后线程B读这个volatile变量时就像是接收了线程A刚刚发送的消息。既然是旧的了,那线程B自然而然就只能去主内存去取啦
这条规则是指对一个锁的解锁Happens-Before于后续对这个锁的加锁
。
要理解这个规则,就首先要了解管程指的是什么。管程是一种通用的同步原语,在Java中指的就是 synchronized,synchronized是Java里对管程的实现。管程中的锁在Java里是隐式实现的.
例如下面的代码,在进入同步块之前,会自动加锁,而在代码块执行完会自动释放锁,加锁以及释放锁都是编译器帮我们实现的。当然Lock中也是一样,一个unLock操作先行发生于后面对同一个锁的lock操作
synchronized (this) {
// 此处自动加锁
// x 是共享变量, 初始值 =10
if (this.x < 12) {
this.x = 12;
}
}
// 此处自动解锁
所以结合规则 4管程中锁的规则,可以这样理解:假设 x 的初始值是 10,线程 A 执行完代码块后 x的值会变成 12(执行完自动释放锁),线程B进入代码块时,能够看到线程 A 对 x的写操作,也就是线程B能够看到x==12。这个也是符合我们直觉的,应该不难理解。
这条是关于线程启动的。它是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程B前的操作
。
换句话说就是,如果线程A调用线程B的start()方法(即在线程 A 中启动线程B),那么该start()操作Happens-Before 于线程 B中的任意操作。具体可参考下面示例代码
Thread B = new Thread(()->{
// 主线程调用 B.start() 之前
// 所有对共享变量的修改,此处皆可见
// 此例中,var==77
});
// 此处对共享变量 var 修改
var = 77;
// 主线程启动子线程
B.start();
对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
;
这条是关于线程等待的,线程中所有的操作都先行发生于线程的终止检测
.
我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行。
例如它是指主线程A等待子线程B完成(主线程A通过调用子线程B的join()方法实现),当子线程B完成后(主线程A中join()方法返回),主线程能够看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作。换句话说就是,如果在线程A中,调用线程B的join()并成功返回,那么线程B中的任意操作Happens-Before于该join()操作的返回。具体可参考下面示例代码。
Thread B = new Thread(()->{
// 此处对共享变量 var 修改
var = 66;
});
// 例如此处对共享变量修改,
// 则这个修改结果对线程 B 可见
// 主线程启动子线程 8 B.start();
B.join()
// 子线程所有对共享变量的修改
// 在主线程调用 B.join() 之后皆可见
// 此例中,var==66
一个对象的初始化完成先行发生于他的finalize()方法的开始
上面八条是原生Java满足Happens-before关系的规则,但是我们可以对他们进行推导出其他满足happens-before的规则:
这里再说一遍happens-before的概念:
如果两个操作不存在上述(前面8条+后面6条)任一一个happens-before规则,那么这两个操作就没有顺序的保障,JVM可以对这两个操作进行重排序。如果操作A happens-before操作B,那么操作A在内存上所做的操作对操作B都是可见的。
happen-before原则是JMM中非常重要的原则,它是判断数据是否存在竞争、线程是否安全的主要依据,保证了多线程环境下的可见性。
如果你觉得文章还不错,你的转发、分享、赞赏、点赞、留言就是对我最大的鼓励。 感谢您的阅读,我坚持原创,十分欢迎并感谢您的关注。
原创不易,欢迎转发,关注公众号“码农进阶之路”,获取更多面试题,源码解读资料!