2.3.1什么是Java内存模型
在介绍Java内存模型(JMM)前,我要打消读者一个错误的认知,那就是JMM与JVM到底是什么关系,现在告诉大家,Java虚拟机模型(JVM)与Java内存模型(JMM)没有本质上的联系。为什么这么说,我来解释一下:想必我的读者大部分都是Java开发工程师,成为一名Java开发工程师必备的两点,就是要了解Java的语法,以及使用Java API,拥有这两点你就可以编写Java代码,编写后的代码需要在Java虚拟机上运行,其实上面我已经把JDK的组成说了出来。JDK(Java Development Kit)就是由Java程序设计语言、Java API类库、Java虚拟机这三部分组成的,是Java程序开发的最小环境(如图2-6所示)。也就是说想要开发Java程序,必备的就是JDK。我们还可以继续把Java API类库分成Java SE API子集和Java虚拟机两部分统称JRE(Java Runtime Environment),JRE是Java程序运行的标准环境。所以说Java虚拟机模型(JVM)是将Java文件编译成class文件并运行class文件的软件,而Java内存模型(JMM)主要定义了线程与内存之间的细节,现在看来两者并没有直接的关系。
图 2-6 Java技术体系
介绍了Java组成的基本知识后,就让我们聊一聊什么是JMM。Java能摆脱硬件的束缚,可以“一次编写,到处运行”,这不仅是因为虚拟机的功劳,也是因为提供了相对安全的内存管理和访问机制,让Java程序在不同平台下都能达到一致的内存访问效果,这种可以屏蔽各种硬件和操作系统的内存访问差异,我们称是Java内存模型。
在并发编程中存在一个最重要的问题就是,线程之间是如何通信的。在Java并发中,线程通信采用共享内存模型机制的,在共享内存模型中,线程间通过读、写内存中公共的状态进行隐式通信。采用内存共享的优点是,数据的共享使线程间的数据不用传送,而是直接访问内存,也加快了程序的效率,当然有利也有弊,共享内存没有提供同步的机制,这使得我们在使用共享内存进行进程间通信时,往往要借助其他的手段来进行进程间的同步工作。
在Java运行程序中有一些内存是线程共享的,例如Java中几乎所有的对象都存储在堆内存中,还有一些静态的常量,这些数据我们统称为共享变量,共享变量存储在主内存中。还有一些变量是每个线程独有的,存在在本地内存中,例如局部变量,方法内定义的参数还有异常处理器参数,这些数据不会在线程之间共享,我们所以不会存在可见性问题,也不受JMM影响,本地内存是JMM抽象的概念,实际并不存在,实际情况与主内存之间还存在来高速缓存、写缓冲区等。如图2-7是抽象出来的JMM结构示意图。
图2-7 JMM抽象结构图
JMM就是通过控制主内存与其他线程的本地内存之间通信,来保证共享变量的内存可见性。
2.3.2重排序
我们在Java编辑器,例如Idea里编写的代码,在执行时,程序真的能像我们所写的顺序执行吗?其实并非如此。在任何系统中处理器常常对指令进行重排序,而达到提高性能。Java的重排序分为三种,如图2-8源码到最终指令顺序图。
1.编译器优化的重排序:编译器在不改变单线程程序语义前提下,可对语句的执行进行重排序。
2.指令级并行的重排序:处理器的指令级并行技术将多条指令重叠执行,如果不存在数据的依赖性将会改变语句对应机器指令的执行顺序。
3.内存系统的重排序,因为使用了读写缓存区,使得看起来并不是顺序执行的。
图2-8 源码到最终指令顺序图
上述3种重排序中,1属于编译器重排序,2、3属于处理器重排序。重排序可能会导致内存可见性问题。JMM的编译器重排序会禁止类型类型的编译器重排序。JMM的处理器重排序则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)指令,内存屏障用来禁止特定类型的处理器重排序。JMM把内存屏障分为4类,如表2-3所示。
表2-3 内存屏障指令分类
屏障类型 | 指令示例 | 说明 |
---|---|---|
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重排序规则在程序的背后帮助我们做了太多的事情,要不是他禁止了一些类型的重排序,我们程序在多线程的运行结果就没办法控制了,即使这种操作会降低一些效率,但是安全比效率更重要。
演示重排序的代码理论上可以用两个存放在内存中的变量,用两个方法控制读和写就能实现,但是运行结果存在不确定性,所以这里笔者也是写了一个伪代码来说明。
代码清单2-6 Reorder.java
public class Reorder {
int a = 0; // int a
volatile boolean flag = false; //共享变量 boolean b
public void writer() {
flag = true;
a = 1;
}
public void read() {
if (flag) {
int i = a * a;
}
}
}
上面代码对于单线程执行视角看来,重排序不存在任何问题,重排序不改变执行结果。
但是我们现在模拟多线程执行如上代码,在并发的某一时刻,赋值有可能发生顺序上的变化,正如代码所示,flag赋值先于a的赋值,那么此时flag的值已经被刷入主内存中,对读线程是可见的,此时另一个线程刚好进入if里给i进行赋值,结果i的值一定是1吗?答案是不一定的,重排序后的结果是不确定的,因此上面代码是其中的一种情况而已。
2.3.3 happens-before
上一节我们知道了重排序,如果重排序是随意性的,任凭处理器按照自己的优化指令进行重排序,那我们程序岂不是到处都是bug?当然不是了,任何事物都有一定的规则。JMM为程序所有的操作定义了一个偏序关系(偏序关系是数学中的序列理论,详细可自行查阅。我简单的说,集合中xyz 和zyx,对于x和z的顺序是无所谓的,不要求每个元素之间都能比较大小,不保证所有元素的互相可比较性,例如我们更喜欢吃草莓而不是葡萄,更喜欢喜悦而不喜欢悲伤,但是我们不必在葡萄和悲伤作出选择。)这种偏序关系称为happen-before。
happen-before是JMM最核心的概念。JMM模型对于程序员来说最重要就是帮助我们提供一个简单使用、易于理解的强内存模型。对于编译器和处理器来说要尽量对其束缚性越小越好,因为束缚性越强,损耗的性能就会越多,介于这两点,在设计JMM模型时就要去平衡这两点,因此提出了happen-before规则。
在JSR-133 中使用happen-before关系来保证内存可见性,说的是在JMM中一个操作的执行结果要对另一个操作可见,这两个操作就存在happen-before关系,反过来,如果两个操作不存在happen-before关系,那么JVM可以对他们的顺序任意排序。有了happen-before后,所有的并发代码再也不担心重排序导致的问题了。
happen-before的规则如下。
n 程序次序规则:在一个单独的线程中,按照程序代码的执行流顺序,(时间上)先执行的操作happen-before(时间上)后执行的操作。
n 监视器锁定规则:在一个监视器上解锁操作必须在同一个监视器加锁之前操作。
n volatile变量规则:对一个volatile变量的写操作必须对该变量的读操作之前执行。线程启动规则:Thread对象的start()方法happen-before此线程的每一个动作。
n 线程终止规则:线程的所有操作都happen-before对此线程的终止检测,可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。线程中断规则:对线程interrupt()方法的调用happen-before发生于被中断线程的代码检测到中断时事件的发生。
n 对象终结规则:一个对象的初始化完成(构造函数执行结束)happen-before它的finalize()方法的开始。
n 传递性:如果操作A happen-before操作B,操作B happen-before操作C,那么可以得出A happen-before操作C。
规则都是很官方的,下面我用自己的话对照上面的规则分别解释happen-before:
第一条
单线程情况下,happen-before原则告诉你,你放心的认为代码写在前面的就是先执行就可以了,不会有任何问题的。
第二条
对于一个解锁操作后的变量 随后后要对这个锁加锁。
第三条
volatile关键字修饰的变量的写先行发生与对这个变量的读
第四条、第五条、第六条我们一起解释
例:a = 1;
thread1.start();
a =1 先行发生 thread.start()前,thread1启动后,才可以读取到a的值为1的。线程终止前的所有操作先行发生于终止方法的返回。这就保障了一个线程结束后,其他线程一定能感知到线程所做的所有变更。
第七条
对象被垃圾回收调用finalize时,对象的构造一定已经先行发生。
第八条
传递性,只是具有简单的传递性。
2.3.4 as-if-serial
happen-before的规则是规定了两个操作之间的关系,我们指的这两个操作并非在代码编辑器里面的书写顺序,书写顺序决定不了实际操作顺序。在编译器或处理器重排序后的执行结果如果与happen-before关系执行的结果一直,我们才会认为这样的重排序是正确的,也就是无论编译器和处理器用什么方式进行重排序,只要不影响happen-before的结果都是被允许的,我们把对于编译器和处理器这样的约束原则称为as-if-serial,as-if-serial翻译中文的意思可以是:犹如连续的,我们可以理解为:高速编译器和处理器,无论这么排序,都不能影响程序的执行结果。
因此as-if-serial的语义本质上或者在宏观上和happen-before是一回事。
编译器和处理器可以对程序中很多操作进行随意排序,只要不影响程序执行结果,那么你知道什么样的操作不会随意排序吗?只有一种关系的数据关系,就是有数据依赖关系。
我们举两个个简单的例子来说明数据依赖关系:
double pi = 3.14; //A圆周率
double r = 1.0; //B 半径
double area = pi * r * r; //C 面积
如上述代码所描述的,圆的面积计算与A、B两个操作的顺序是无关的,但是C与A、B两个操作是有关系的。
图2-9 无依赖关系执行顺序
从图上,我们可以看出,A和C、B和C是存在税局依赖关系的,A、B是不存在数据依赖关系的,因此圆的面积示例代码中存在4个happen-before关系。
1. A happen-before B。
2. B happen-before A。
3. B happen-before C。
4. A happen-before C。
到这里,我们对于什么样的操作会存在数据依赖关系已经有了简单的认识,这里做了一个分类,一下3种类型就是存在数据依赖关系的操作,如表2-4所示。
表2-4 3种数据依赖关系类型
类别 | 代码示例 | 说明 |
---|---|---|
写后读 | a=2;b=a; | 写这个变量后再读这个变量 |
写后写 | a=1;a=2; | 写这个变量后再写这个变量 |
读后写 | a=b;b=1; | 读这个变量后再写这个变量 |
以上3种操作,如果编译器和处理器重排序后不按照happen-before规则,程序的执行结果就会不同,因此存在数据依赖关系的操作,编译器和处理器在重排序时不会改变其执行顺序。