【并发编程】(八)跨线程可见性保证——happens-before规则
文章目录
1.对happens-before的理解
happens-before是JMM定义的提供跨线程的内存可见性保证的规则。
在前面的文章中提到过内存的可见性问题主要是处理器对指令执行的优化导致的,包括缓存、指令重排序这些优化方式。这些优化造成了多个线程对共享变量的操作结果可能与预期的结果不一致,于是我们就想,可不可以在某些特殊的场景下禁止处理器对指令的优化。
这种禁止指令优化的场景,如果交由业务开发者来实现,在开发的同时还需要考虑与操作系统的交互,对业务开发者来说就过于复杂了,所以就需要JMM来作为开发者和操作系统之间沟通的桥梁。
1.1.JMM对happens-before的设计
对于开发者来说,希望Java内存模型尽可能的易于编程,最好在做业务开发的时候什么额外的操作都不做也能保证线程安全,这就需要JMM对编译器和处理器限制的死死的。
但是JMM对编译器和处理器限制的越死,程序执行的效率就越低,编译器和处理器又希望JMM能尽可能的放松限制。
对于这两个矛盾的需求,JMM就需要找到两者之间的平衡。
在Java应用中,重排序的类型有两种:
- 会影响程序执行结果的重排序。
- 不会影响程序执行结果的重排序。
JMM禁止了会影响程序执行的重排序,同时放开了对不影响程序执行结果的重排序的限制。也就是说,只要不影响程序运行的结果,编译器和处理器想怎么优化都可以。例如,在单线程的环境下,编译器会做锁消除的优化,JMM对此并没有限制。
1.2.as-if-serial语义
as-if-serial语义是在单线程环境下,编译器和处理器都不会对会影响程序结果的操作重排序,举个简单的例子:
public void asIfSerial() {
int a = 3;//1
int b = 4;//2
int c = a + b;//3
}
例子中的操作1、2之间没有依赖关系,可以重排序,1、3和2、3都存在依赖关系,不可以重排序。最终操作的执行顺序只能是:
1→2→3
2→1→3
1.3.happens-before语义
如果说as-if-serial语义是保证单线程环境下,程序的执行结果不会改变,那happens-before就是的指的在多线程环境下,程序的执行结果不会改变。
在单线程的环境下,对共享变量的操作具有天然的可见性,只需要注意有依赖关系的操作不要重排序就好。而在多线程的情况下,由于处理器的缓存优化等机制,会存在可见性问题,所以需要在某些特殊的时候,禁止这部分优化。
为此,JMM在某些特殊的操作中做出了限制,定义出了happens-before的六种规则,承诺业务开发者,在Java开发中只要遵守这些规则,就一定不会存在可见性问题。
注:有两个操作A和B,A happens-before B,表示的是A对内存的修改对B可见,并不是说A一定比B先执行。
2.happens-before的六种规则
- 程序顺序规则:一个线程中的每个操作,happens-before这个线程中任意后续操作。
- 监视器锁规则:同一个锁的解锁操作happens-before于随后的加锁操作。
- volatile规则:对于同一个volatile的写操作,happens-before后续的读操作。
- 传递性规则:如果A happens-before B,B happens-before C,则A happens-before C
- start规则:一个线程的启动操作happens-before这个线程中的任意操作。
- join规则:一个线程中的所有操作happens-before这个线程的join操作。
2.1.程序顺序规则
和上面说的as-if-serial是一回事,程序应该是按照代码编写的顺序执行,但是JMM允许对于程序执行结果没有影响的操作进行重排序。
2.2.监视器锁规则
这个规则有两个理解:
- 同一个锁的解锁操作对随后的加锁操作可见。
- 解锁时会将工作内存中的共享变量值刷新到主内存中,加锁后会从主内存中同步共享变量值到工作内存中。
首先,加锁与解锁的操作实际上就是对共享变量的修改,前一个线程对于锁状态的修改,要对后一个线程可见,后一个线程才能知道锁已经释放。
对于第二点理解,用一个很常见的多线程对共享值的累加操作例子可以说明:
public class HappensBeforeDemo {
static int value = 0;
/**
* 监视器锁规则
*/
public static void monitorRule() {
synchronized (HappensBeforeDemo.class) {
value++;
}
}
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 100; i++) {
new Thread(HappensBeforeDemo::monitorRule).start();
}
Thread.sleep(1);
System.out.println(value);
}
}
每个线程在进入临界区后,会将主内存中的value值同步到工作内存中,解锁时又将工作内存中的value值刷新到主内存中,这也是synchronized能够保证可见性的原因。
2.3.volatile规则
在上一篇《volatile原理》中写到volatile是如何解决可见性和有序性问题的,详情可以去了解一下。
简答的说就是,JMM对于volatile修饰的变量会在汇编码中加上一个lock指令,这个指令可以触发CPU的缓存一致性协议,将CPU缓存行中的数据同步到内存中,同时将其它持有同一个数据的线程的缓存行置于失效状态,其它线程下次需要使用到这个变量的时候,就会从内存去重新同步数据。
2.4.传递性规则
这个规则没什么好说的,就字面意思。
2.5.start()规则
线程中需要异步执行的操作是写在run()方法中的,这个方法是操作系统对JVM的回调时才会调用的,而需要CPU分配时间片对这个run()方法做调用的前提,一定是这个线程已启动,即调用了start()方法。
详情可以看《并发编程基础和线程的生命周期》2.2.Java线程启动时发生了什么。
2.6.join()规则
在线程A中调用了线程B的join方法,线程A会在join这里阻塞,等待线程B中的run()方法执行完毕后,线程A才会继续从join开始往后执行。
本文地址:https://blog.csdn.net/qq_38249409/article/details/113942629