1. 前言
学习happens-before的目的不是只限于知道这些规则的存在,而是要进一步知道如何实现和维护这些happens-before关系,在代码中加以注意。
Happens-before 规则是从java代码设计层面保证有序性和可见性的机制。本文将会以图示、样例代码和解释相结合的方式,力图阐述清楚happens-before的原理,为理解如何保证线程安全性打下扎实的基础。
关于happens-before关系,java语言说明中有如下的描述:
Two actions can be ordered by a happens-before relationship. If one action happens-before another, then the first is visible to and ordered before the second. --Java Language Specification Ch 17.4.5. Happens-before Order
java代码编译成计算机指令后,计算机在执行指令时会进行一定程度的重排序。happens-before规则相当于java设计者和java开发者之间的约定,屏蔽了计算机底层的细节,从java层面和开发者建立约定,约定的内容是如下这样:
java语言的设计者建立了一套happens-before规则,如果开发者在相应的场景下确保两个操作之间遵从了happens-before规则,那java会为开发者保证,遵从了happens-before规则的多线程操作具有实际的happens-before关系,进而可以保证共享变量的可见性。
这些happenns-before关系大多需要开发者自己保证,而于此同时,java内置就已经实现了一系列happens-before关系,这些关happens-before 关系天然存在,而不需要开发者自行维护。
警惕误区
网上绝大多数的文章在描述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介绍将主要分为两类:即已经存在和开发者自行保证。
2. Happens-before规则
Happens-before关系可传递性
在学习下面的happens-before规则之前,我们需要了解到的一个已有规则是,happens-before关系具有可传递性。用hb(a,b)表示a happens-before b,而如果 hb(b,c),则我们可以得知hb(a,c)。可传递性这一固有特性将帮助我们顺利理解后面的诸多规则。
已经存在(需要了解)
这一部分happens-before规则已经由java语言设计者实现,故对于开发者而言它们是固有存在的事实,了解他们有助于我们清楚代码为什么可以保证可见性,同时在复杂的情况有分析可见性问题的能力。
1. 单线程规则
2. Thread start规则
3. Thread join规则
开发者自行保证(需要了解并清楚如何实现)
这一部分的规则需要开发者额外关注,因为java语言没有内置机制保证这些happens-before规则,要实现这些场景下的可见性,需要开发者自行保证这一关系的存在。如果缺少这些关系,则java无法保证一个线程的操作结果对另一个线程的可见性。
4. volatile变量规则
如果希望A线程的对volatile变量的修改对B线程可见,那么A线程对volatile的变量的写应该happens-before B线程对这个volatile变量的读。
即如果保证线程A对一个volatile变量的写发生在另一个线程B对于这个变量的读之前,在A对这个变量的操作对B可见。 下面通过一个简单的代码样例验证这一规则。
public class TestVolatileHappensBefore {
static volatile int shared = 0;
static class WriteTask implements Runnable {
@Override
public void run() {
shared = 5; //1. write to the volatile shared variable
}
}
static class ReadTaslk implements Runnable {
@Override
public void run() {
System.out.println(shared);//2. read to the volatile shared variable
}
}
public static void main (String[] args) {
Thread tWrite = new Thread(new WriteTask());
Thread tRead = new Thread(new ReadTaslk());
tWrite.start();
try {
Thread.sleep(100);
} catch (InterruptedException e) {
//just wait to ensure order
}
tRead.start();
}
}
这段代码的运行结果显而易见,会打印shared的值为5。因为遵从了volatile变量的happens-before规则,故注释中1的操作结果对2可见。如果仅展示正例则无法证明这一规则存在的必要性。而下面的这个反例则可以证明我们必须确保这一 happens-before 关系。
public static void main (String[] args) {
Thread tWrite = new Thread(new WriteTask());
Thread tRead = new Thread(new ReadTaslk());
tRead.start();
tWrite.start();
}
反面例子中仅更改了对于volatile变量读写线程的开始顺序,即我们没有严格遵守volatile变量的happens-before规则,那这样的代码运行结果会如何呢?这里附上数次运行结果的截图:
5. 锁操作规则(synchronized及实现了Lock接口实现)
如果A线程是解锁操作,之后另一个B线程执行加锁操作,如果希望A的操作结果对B可见,那么 A应该happens-before B
同样通过一段样例代码展示该规则的正面案例,依然是线程先读后写的情况,同步机制依赖显示锁ReentrantLock:
public class TestLockHappensBefore {
static int a = 0;
static ReentrantLock lock = new ReentrantLock();
public static void modify1() {
lock.lock();
try {
System.out.println("a= " + a + " with " + Thread.currentThread().getName());
} finally {
a = 10;
lock.unlock();
}
}
public static void modify2() {
lock.lock();
try {
System.out.println("a= " + a + " with " + Thread.currentThread().getName());
} finally {
a = 5;
lock.unlock();
}
}
public static void main(String[] args) {
Thread thread0 = new Thread() {
@Override
public void run() {
modify1();
}
};
Thread thread1 = new Thread() {
@Override
public void run() {
modify2();
}
};
thread0.start();
thread1.start();
}
}
thread0打印当前a的值并将a赋值为10,thread1同样打印当前a的值并将其赋值为5。
因为thread0 符合happens-before thread1的规则,所以thread0对变量a的修改对thread1可见,故运行结果为如图:
而如果将启动顺序倒置
thread1.start();
thread0.start();
则关系变成了 thread1 happens-before thread0,则thread1对a的修改对thread0可见,运行结果如图:
6. 各式各样的组合场景下的happens-before规则
这就是一个无穷无尽的话题了,在组合的场景下,只有符合了happens-before的线程两两之间能够保证可见性,而不一定可以保证所有线程的互相可见性,要处理好这种情况则需要开发者对 各种场景的happens-before规则烂熟于胸,利用好规则的可传递性,梳理清楚线程交互逻辑,才有可能处理好所有线程间的可见性问题。
一个小彩蛋 笔者在撰写样例代码的时候就遇到了这种组合的情况,happens-before关系并没有形成传递链,故出现了意想不到的结果,仔细分析才得了原因,与各位简单分享一下。代码如下:
public class VolatileUnexpectedHappensBefore {
static int a = 0;
public static synchronized void read() {
System.out.println(a);
}
public static synchronized void write() {
a = 5;
}
public static void main(String[] args) {
Thread write = new Thread(){
public void run() {
write();
}
};
write.start();
read();
}
}
看到这个代码,不知道各位认为打印出来的a的值会是多少,我一开始下意识认为一定是5,然后结果是初始值0。 之所以会有这样的结果,是因为尽管write 线程的start方法 happens-before 主线程调用read()方法,但因为创建线程和线程执行需要时间,write线程的run()方法并不被保证 happens-before 主线程的write()方法。 在这种情况下write线程的unlcok不被保证 happens-before 于 主线程的read(),主线程自然没法看到write线程的操作结果。而如果我们想要read的结果打印a = 5,方法也很简单,运用我们之前的几个固有的happens-before规则。 例如,应用 thread join规则,将代码改为:
write.start();
write.join();
read();
则write线程和主线程满足happens-before规则,具有实际的happens-before关系,在结合happens-before关系的传递性,happens-before关系从符合thread jion规则传递到符合锁的规则,故write线程的修改一定对主线程可见。仅仅是这样一个简单的样例尚且容易出错,在更复杂的开发场景下,要想处理好可见性问题,只能靠开发人员自己不断实践和总结,才能得到真知。