深入理解Java内存模型
目录
(emmm....简书现在好像还不支持)
本文为《Java并发编程的艺术》一书以及一些相关文章的学习笔记。因这一块知识相互交叉,比较难理出一个清晰的结构,第一次接触学习时会感觉很混乱。遂整理出此文。如有错误,欢迎指正,谢谢。
并发编程的关键问题
在并发编程中,需要处理两个关键问题:线程之间如何通信、同步。
在命令式编程中,有两种通信机制:共享内存并发模型和消息传递并发模型。
-
共享内存
线程之间共享程序的公共状态,通过读-写内存中的公共状态进行隐式通信。 -
消息传递
线程之间没有公共状态,必须通过发送消息来显示进行通信。
在消息传递并发模型中,因为消息的发送肯定在消息的接收之前,所以同步是隐式进行的。但在共享内存并发模型中,同步是显式的。程序员必须明确指定某个方法或代码段需要在线程之间互斥执行。
Java的并发采用的是共享内存模型,如果不理解线程之间的通信机制,可能会遇到很多问题,这时候JMM的存在和对JMM的理解就非常重要了。
Java内存模型
Java内存模型,即JMM(Java Memory Model),是一个抽象的概念,描述了一组规范,来控制Java线程之间的通信。JMM决定一个线程对共享变量的写入何时对另一个线程可见——也就是定义了线程和主内存之间的抽象关系。
线程之间的共享变量储存在主内存中,每个线程都有一个私有的本地内存。线程不能直接操作主内存变量,必须通过本地内存来处理。线程首先将变量从主内存拷贝到自己的本地内存,然后对变量进行操作,再将变量写回主内存。
注意:本地内存是抽象概念,并不实际存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化
如果主内存中有一个变量x=0,线程AB各对其进行一次+1操作。正常情况下,线程A先拷贝x=0到本地内存,然后+1后再写回主内存,此时x=1。B线程再从主内存读取到已经更新的变量,拷贝x=1到本地内存,+1后写回主内存,最终x=2。可以看到,线程A写回主内存和线程B从主内存读取实质上是线程A向线程B发送消息(看清楚了啊,我已经+1了,现在x是1不是0了,你别加错了)。
上边只是理想状态下,现实中当两个线程同时读取到了主内存的x=0,+1后写回主内存,最终的结果是x=1,这显然是不对的。这也是我们学习JMM的意义,JMM会通过控制主内存与每个线程的本地内存之间的交互,来为我们提供内存可见性保证。(内存可见性:一个线程对共享变量的修改,能够及时被其他线程看到)
顺序一致性模型
顺序一致性模型是一个理论参考模型,为程序员提供了极强的内存可见性保证。在这个理论模型下,(不管是单线程还是多线程)程序永远按照程序员看到的顺序依次执行。在设计的时候,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参照。它有两大特征:
- 一个线程中的所有操作必须按照程序的顺序来执行。
- (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。每个操作都必须原子执行且立刻对所有的线程可见。
也就是说,在顺序一致性模型中,有一个唯一的全局内存,同一时间只能由一个线程使用。并且每个线程必须按照程序的顺序执行内存读写操作。
重排序
在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下,尽可能提高并行度,来提高性能。编译器和处理器常常会对指令进行重排序。但上文说到,编译器和处理器都要参照顺序一致性模型,所以需要as-if-serial语义,来保证程序的执行结果不会被改变。
as-if-serial语义
无论怎么重排序,(单线程)程序的执行结果不会改变。为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序。
数据依赖性
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。如表所示:
名称 | 代码示例 | 说明 |
---|---|---|
写后读 | a = 1; b = a; |
写一个变量之后,再读该变量 |
写后写 | a = 1; a = 2; |
写一个变量之后,再写该变量 |
读后写 | a = b; b = 1; |
读一个变量之后,再写该变量 |
可以看到,这三种情况,如果重排序两个操作的执行顺序,程序的执行结果会发生改变。
编译器和处理器重排序时不会改变存在数据依赖关系的两个操作的执行顺序。
- 注意,这里只针对单个处理器、单个线程中执行的操作。不同处理器、线程之间的数据依赖性不被考虑。
如果操作间不存在数据依赖性,则会被重排序,例如下面计算圆的面积例子中,操作1和2被重排序后,不会改变执行结果:
1 double pi = 3.14;
2 double r = 1.0;
3 double area = pi * r * r;
与
1 double r = 1.0;
2 double pi = 3.14;
3 double area = pi * r * r;
结果相同,但操作3和1,2之间都存在数据依赖性,所以3不能被重排序到1或2之前。
控制依赖性
if (flag) {
int i = a * a;
}
上边这样存在控制依赖关系的操作会影响指令序列的并行度。因此编译器和处理器会采用“猜测执行”来应对。执行程序的线程可以提前读取并计算a * a
,然后把结果临时保存到重排序缓冲中,到if判断为真时,在把结果写入变量i中。可能的执行顺序如下:
重排序对多线程的影响
重排序会对多线程程序造成什么影响,看下边的例子。
// flag 用于标记变量a是否已经被写入
class ReorderExample {
int a = 0;
boolean flag = false;
public void writer() {
a = 1; // 1
flag = true; // 2
}
Public void reader() {
if (f?lag) { // 3
int i = a * a; // 4
……
}
}
}
假设有两个线程A和B,A首先执行writer()方法,随后B线程接着执行reader()方法。线程B在执行操作4时,能否看到线程A在操作1对共享变量a的写入呢?
答案是:不一定能看到。为什么呢?
操作1和2之间没有数据依赖关系,可以被重排序(同样3和4也可以)
- 当操作1和2重排序时
顺序 | 线程A | 线程B |
---|---|---|
1 | flag = true; | |
2 | if (flag) | |
3 | int i = a * a; | |
4 | a = 1; |
可以看到,当线程B判断flag为真,读取变量a时,变量a还没有被线程A写入。程序执行结果是错误的。
- 当操作3和4重排序时
顺序 | 线程A | 线程B |
---|---|---|
1 | temp = a * a | |
2 | a = 1; | |
3 | flag = true; | |
4 | if (flag) | |
5 | int i = temp; |
可以看到重排序后,线程B先计算出 a * a
的值并临时存储之后(上文中控制依赖性),线程A才给变量a赋值,程序执行结果当然是错误的。
JMM存在的意义和作用
JMM的保证
在单线程的Java程序中,编译器和处理器在重排序时已经做了顺序一致性的保证,程序总是按顺序依次执行的。同样也不存在内存可见性问题,因为我们上一个操作对变量的任何修改,之后的操作都能读取到被修改的新值。
但在多线程的情况下就不一样了。由于重排序的存在,一个线程观察另外一个线程,所有的操作都是无序的。而由于工作内存的存在,也会存在内存可见性问题。
针对这些情况,JMM向我们保证:如果程序是正确同步的,程序的执行将具有顺序一致性——程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。这里的同步是广义上的同步,包括对常用同步原语(synchronized、volatile和final)的正确使用。
内部手段:happens-before原则
happens-before是JMM最核心的概念。
在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。注意,这里所说既可以是单线程,也可以是多线程。(A happens-before B 也就是 A 发生于 B 之前)主要规则如下:
- 程序顺序规则:一个线程中的每个操作,必须发生于该线程中的任意后续操作(也就是单线程下程序按照代码顺序执行)。
- 监视器锁规则:对一个锁的解锁,必须发生于随后对这个锁的加锁之前。
- volatile变量规则:对一个volatile域的写,发生于对该域的读之前。(volatile:简单来讲,被volatile修饰的变量每次被读取时都会强制从主内存中读取,而对它的写,会强制将新值刷新到主内存。)
- 线程启动规则:线程的start()方法先于它的其他任一动作。(在线程A执行start()方法之前其他线程修改了共享变量,该修改在线程A执行start()方法时对线程A可见)
- 线程终止规则:线程的所有操作先于线程的终结。
- 对象终结规则:对象构造函数的执行,先于finalize()方法。
- 传递性规则:如果A先于B,B先于C,那么A一定先于C。
注:上述规则为JMM内部保证,即使在多线程环境下也不需要我们添加任何同步手段。
但两个具有happens-before关系的操作,并不意味着前一个操作必须在后一个操作之前执行。happens-before只要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在后一个操作之前。这是为什么呢?接着往下看。
JMM在设计时,需要考虑两个方面。一是程序员希望内存模型更易于理解、易于编程(强内存模型)。另一方面,编译器和处理器希望内存模型更自由,已进行更多的优化(弱内存模型)。JMM设计的目标就是找到这两个方面的平衡点。
再来看之前计算圆面积的例子,
1 double pi = 3.14;
2 double r = 1.0;
3 double area = pi * r * r;
可以看到这里存在3个happens-before关系:①>②,②>③,①>③。但其实②>③,①>③是必要的,而①>②是不必要的。
JMM将happens-before规则要求禁止的重排序分为两类:
- 会改变程序执行结果的重排序
- 不会改变程序执行结果的重排序
而JMM只会要求编译器和处理器禁止第一类重排序。JMM让程序员认为程序是按照①>②>③的顺序执行的,但实则不然。
JMM实际上遵循的是顺序一致性的基本原则,只要执行结果不变,随你怎么优化都行。这样一来,既给了编译器和处理器最大的自由,又通过happens-before规则给了程序员最清晰简单的保证。
本质上来讲,happens-before与as-if-serial是一回事,他们存在的意义是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。
外部手段:volatile、锁、final域、
除了happens-before规则,JMM还提供了volatile、synchronize、final、锁这些机制来同步线程,保证程序在多线程环境下的正确执行,这部分内容繁多,在此不再赘述。
结语
OK,关于Java内存模型的分享到这里就结束了。一句话总结:JMM就是一组规则,这组规则意在解决在并发编程可能出现的线程安全问题,并提供了内置解决方案(happen-before原则)及其外部可使用的同步手段(synchronized/volatile等),确保了程序执行在多线程环境中的应有的原子性,可视性及其有序性。