解决并发编程中的可见性和有序性问题最直接的方法就是禁用CPU缓存和编译器的优化。可是,禁用这二者又会影响程序性能。因而咱们要作的是按需禁用CPU缓存和编译器的优化。html
如何按需禁用CPU缓存和编译器的优化就须要提到Java内存模型。Java内存模型是一个复杂的规范。其中最为重要的即是Happens-Before
规则。下面咱们先介绍如何利用Happens-Before
规则解决可见性和有序性问题,而后咱们再扩展简单介绍下Java内存模型以及咱们前篇文章提到的重排序概念。java
在前一篇文章介绍编译优化带来的有序性问题时,给出的一个解决办法时将共享变量使用volatile
关键字修饰。volatile关键字的做用能够简单理解为①禁用重排序,保证程序的有序性;②禁用缓存,保证程序的可见性。程序员
volatile关键字不是Java语言中的特产,C语言中也有,其最原始的意义就是禁用CPU缓存,使得每次访问均须要直接从内存中读写。
若是咱们声明一个volatile变量,那么也就会让编译器不能从CPU缓存中去读取这个变量,而必须从内存中读取。编程
class VolatileExample { int x = 0; // 1 volatile boolean v = false; //2 public void writer() { //3 x = 42; v = true; } public void reader() { //4 if (v == true) { // 这里 x 会是多少呢? } } }
Happends-Before规则表达的是:前面一个操做的结果对以后操做是可见的,描述的是两个操做的内存可见性。
Happens-Before约束了编译器的优化行为,虽容许编译器优化,可是要求编译器遵循必定的Happens-Before规则进行优化。数组
Happens-Before规则包括:缓存
程序顺序规则多线程
在一个线程中,前面的操做Happens-Before于后续的任意操做。架构
volatile变量规则并发
对volatile变量的写操做相对于以后对该volatile变量的读操做是可见的。(这个语义可等价适用于原子变量)
对volatile变量的写操做 Happens-Before 对该volatile变量的读操做app
传递性
若是操做A Happens-Before 操做B而且操做B Happens-Before 操做C, 那么操做A Happens-Bofore 操做C。
利用程序顺序规则、volatile变量规则、传递性规则说明例子
根据程序顺序规则,在一个线程中,以前的操做是Happens-Before后续的操做,因此x=42;
Happens-Before v=true;
;根据volatile变量规则,对volatile变量的写操做相对于以后对该volatile变量的读操做是可见的,因而写变量v=ture;
Happens-Before读变量v==true;
;根据传递性,得出x=42;
Happens-Before读变量v==true;
因而,最终读出的x值会是42。
管程中的锁规则
对同一个锁的解锁 Happens-Before 后续对这个锁的加锁。
(管程:是一种同步原语,在Java中就是指synchronized。)
线程启动规则
线程的启动操做(即Thread.start()) Happens-Before 该线程的第一个操做。
主线程A启动子线程B,那么子线程B可以看到线程A在启动B以前的任意操做。
Thread B = new Thread(()->{ // 主线程调用 B.start() 以前 // 全部对共享变量的修改,此处皆可见 // 此例中,var==77 }); // 此处对共享变量 var 修改 var = 77; // 主线程启动子线程 B.start();
线程结束规则
线程的最后一个操做 Happens-Before 它的终止事件。
主线程A等待子线程B完成(A调用B.join())。当B完成以后(主线程A中的join()返回),主线程A能够看见子线程的操做。看到针对的是对共享变量。
Thread B = new Thread(()->{ // 此处对共享变量 var 修改 var = 66; }); // 例如此处对共享变量修改, // 则这个修改结果对线程 B 可见 // 主线程启动子线程 B.start(); B.join() // 子线程全部对共享变量的修改 // 在主线程调用 B.join() 以后皆可见 // 此例中,var==66
中断规则
线程对其余线程的中断操做 Happens-Before被中断线程所收到中断事件。
一个线程在另外一个线程上调用interrupt,必须在被中断线程检测到interrupt调用以前执行。(被中断线程的InterruptedException异常,或者第三个线程针对被中断线程的Thread.interrupted或者Thread.isInterrupted调用)
析构器规则
构造器中的最后一个操做 Happens-Before 析构器的第一个操做
或者说,对象的构造器必须在启动该对象的析构器以前执行完成。
须要注意,A操做 Happens-Before B操做,但并不意味着A操做必需要在B操做以前执行。
Happens-Before表达的是前一个操做执行后的结果是对后续一个操做是可见的,且前一个操做按顺序排在第二个操做以前。
共享变量可指代存储与堆内存中的实例域、静态域和数组元素,共享变量是线程间共享的。局部变量、方法定义参数和异常处理器参数不会在线程之间共享,因此,它们不会有内存可见性问题。
Java线程之间的通讯由Java内存模型(Java Memory Model, JMM)控制,JMM决定了一个线程对共享变量的写入什么时候对另外一个线程可见。
从抽象角度看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每一个线程都有一个私有的本地内存(local memory),本地内存存储了该线程以读/写共享变量的副本。
本地内存是JMM的一个抽象概念,实际并不存在,它主要是指代缓存、写缓冲区、寄存器以及其余的硬件和编译器优化。
Java内存模型的抽象示意图以下:(图来自程晓明的深刻理解Java内存模型)
从上图来看,若是线程A和线程B要进行通讯,须要进行两步:
线程A和B的通讯过过了主内存,JMM经过控制主内存和每一个线程的本地内存之间的交互,来为Java程序员提供内存可见性的保证。
在执行程序时,为了提升性能,编译器和处理器经常会对指令作重排序处理。加上前面提到的编译器优化,重排序能够分为三种类型:
as-if-serial属性:在单线程状况下,虽然有可能不是顺序执行,可是通过重排序的执行结果要和顺序执行的结果一致。 编译器和处理器须要保证程序可以遵照as-if-serial属性。
数据依赖性:若是两个操做访问同一个变量,且这两个操做中有一个为写操做,此时这两个操做之间就存在数据依赖性。编译器和处理器不能对“存在数据依赖关系的两个操做”执行重排序。
从Java源代码到最终执行的指令序列,会经历下面的三种重排序:(图来自程晓明的深刻理解Java内存模型)
第一个属于编译器重排序,第二三个属于处理器重排序。这些重排序均可能会致使夺多线程出现内存可见性问题。
针对编译器的重排序,JMM会有编译器重排序规则禁止特定类型的编译器重排序,不会禁止全部类型的编译器重排序。
针对处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers)指令,来禁止特定类型的处理器重排序。
JMM属于语言级的内存模型,它确保在不一样的编译器和处理器平台上,经过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
处理器架构提供了一些特殊的指令(称为内存屏障)用来在须要共享数据时实现存储协调。JMM使编译器在适当的位置插入内存屏障指令来禁止特定类型的处理器重排序。
内存屏障指令可分为下列四类:
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad Barriers | Load1;LoadLoad;Load2 | 确保Load1数据的装载,以前于Load2及全部后续装载指令的装载。 |
StoreStore Barriers | Store1;StoreStore;Store2 | 确保Store1数据对其余处理器可见(刷新到内存),以前于Store2及全部后续存储指令的存储。 |
LoadStore Barriers | Load1;LoadStore;Store2 | 确保Load1数据装载,以前于Store2及全部后续的存储指令刷新到内存。 |
StoreLoad Barriers | Store1;StoreLoad;Load2 | 确保Store1数据对其余处理器变得可见(刷新到内存),以前于Load2及全部后续装载指令的装载。StoreLoad Barriers会使该屏障以前的全部内存访问指令(存储和装载指令)完成以后,才执行该屏障以后的内存访问指令。 |
(图来自程晓明的深刻理解Java内存模型)
看图中的归纳,一个Happens-Before规则对应于一个或者多个编译器和处理器重排序规则。
对Java程序员来讲,只须要熟悉Happens-Before规则,就可使程序避免遭受内存可见性问题,而且不用为了理解JMM提供的内存可见性保证而学习复杂的重排序规则以及这些规则的具体实现。
为了避免打乱前面的行文思路,因而就在后面补充关于volatile的知识。
volatile变量是Java语言提供的一种较弱的同步机制,用来确保将变量的更新操做都通知到其余线程。将变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,不会将该变量上的操做与其余内存操做一块儿重排序,即咱们前面所说的保证程序有序性。volatile变量不会被缓存在寄存器或者CPU缓存中对其余处理器不可见,读取volatile类型的变量时总会返回最新写入值,即咱们前面说的保证程序可见性。然而,频繁地访问 volatile 字段也会由于不断地强制刷新缓存而严重影响程序的性能。
从内存可见性角度来看,写入volatile变量至关于退出同步代码块,而读取volatile变量至关于进入同步代码块。然而,并不建议过分依赖volatile变量提供的可见性。若是在代码中依赖volatile变量来控制状态的可见性,一般比使用锁的代码更脆弱也更加难以理解。(下一篇文章将介绍Java并发中的同步机制)
仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用。
volatile变量的正确使用方式包括:确保自身状态的可见性,确保它们所引用对象的状态的可见性以及标识一些重要的程序生命周期事件的发生(例如,初始化或者关闭)。
下面的例子是volatile变量的一种典型用法:检查某个状态标记以判断是否退出循环。
volatile boolean asleep; ... while(!asleep) countSomeSheep();
为了能使这个程序正确执行,alseep必需要为volatile变量。不然,当asleep被另一个线程修改时,执行判断的线程却发现不了。后面也会讲用锁操做也能够确保asleep更新操做的可见性,可是这将会使代码变得复杂。
须要注意,尽管volatile变量常常用于表示某种状态信息如某个操做完成、发生中断或者标记,可是volatile的语义是不足以确保递增操做(count++)的原子性 ,除非确保只有一个线程对变量执行写操做。后面将要介绍的同步机制中的加锁机制既能够确保可见性又能够确保原子性,而volatile变量只能确保可见性。
行文思路整体看起来有点乱ε(┬┬﹏┬┬)3,不过这也不是有意为之。本打算是重点介绍Happens-Before规则,而后稍微介绍一点Java内存模型。可奈何中途瞥见了一个网友力推程晓明的深刻理解Java内存模型,因而就去拜读了一遍。看完发现仍是要补充介绍一些东西,因而补着补着就乱了。唉,也怪我这深刻浅出介绍知识的能力不够,各位看官择其所需看看就好。
参考: [1]极客时间专栏王宝令《Java并发编程实战》 [2]Brian Goetz.Tim Peierls. et al.Java并发编程实战[M].北京:机械工业出版社,2016 [3]程晓明.深刻理解Java内存模型.https://www.infoq.cn/article/java_memory_model