【Java并发基础】Java内存模型解决有序性和可见性问题
前言
解决并发编程中的可见性和有序性问题最直接的方法就是禁用CPU缓存和编译器的优化。但是,禁用这两者又会影响程序性能。于是我们要做的是按需禁用CPU缓存和编译器的优化。
如何按需禁用CPU缓存和编译器的优化就需要提到Java内存模型。Java内存模型是一个复杂的规范。其中最为重要的便是Happens-Before
规则。下面我们先介绍如何利用Happens-Before
规则解决可见性和有序性问题,然后我们再扩展简单介绍下Java内存模型以及我们前篇文章提到的重排序概念。
volatile
在前一篇文章介绍编译优化带来的有序性问题时,给出的一个解决办法时将共享变量使用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 会是多少呢?
}
}
}
在这段代码中,假设线程A执行了3即writer()方法,设置了x=42和v=true。线程B执行了4即reader()方法,线程B可以看见线程A设置的v为true,那么B读到的x值会是多少呢?(想一想再点击我)
这要分Java版本来说,在1.5之前,会出现x=0的情况。由于可见性问题,线程A修改的x可能存储在CPU缓存中对线程B是不可见的,于是线程B获取到的x为0。
在Java1.5之后,线程B获取到的x一定就是42。
这是因为Java内存模型对volatile语义进行了增强。增强体现在Java内存模型中的Happens-Before规则上。
Happens-Before规则
Happends-Before规则表达的是:前面一个操作的结果对之后操作是可见的,描述的是两个操作的内存可见性。
Happens-Before约束了编译器的优化行为,虽允许编译器优化,但是要求编译器遵循一定的Happens-Before规则进行优化。
Happens-Before规则包括:
-
程序顺序规则
在一个线程中,前面的操作Happens-Before于后续的任意操作。
-
volatile变量规则
对volatile变量的写操作相对于之后对该volatile变量的读操作是可见的。(这个语义可等价适用于原子变量)
对volatile变量的写操作 Happens-Before 对该volatile变量的读操作 -
传递性
如果操作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内存模型(Java Memory Model, JMM)控制,JMM决定了一个线程对共享变量的写入何时对另一个线程可见。
从抽象角度看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存存储了该线程以读/写共享变量的副本。
本地内存是JMM的一个抽象概念,实际并不存在,它主要是指代缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。
Java内存模型的抽象示意图如下:(图来自程晓明的深入理解Java内存模型)
从上图来看,如果线程A和线程B要进行通信,需要进行两步:
- 线程A将本地内存A中更新过的共享变量刷新到主内存中
- 线程B从主内存中去读取线程A更新到主内存的共享变量
线程A和B的通信过过了主内存,JMM通过控制主内存和每个线程的本地内存之间的交互,来为Java程序员提供内存可见性的保证。
重排序
重排序分类
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序处理。加上前面提到的编译器优化,重排序可以分为三种类型:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。即在单线程中,重排序指令后执行的结果与未重排序执行的结果一致,那么就可以允许这种优化。
- 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器便可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。因为缓存可能会改变将写入变量提交到主内存的次序。
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会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。 |
JMM、Happens-Before和重排序规则之间的关系
(图来自程晓明的深入理解Java内存模型)
看图中的概括,一个Happens-Before规则对应于一个或者多个编译器和处理器重排序规则。
对Java程序员来说,只需要熟悉Happens-Before规则,就可以使程序避免遭受内存可见性问题,并且不用为了理解JMM提供的内存可见性保证而学习复杂的重排序规则以及这些规则的具体实现。
再谈volatile
为了不打乱前面的行文思路,于是就在后面补充关于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