一、JMM(java内存模型)
1.1 什么是JMM
1.1.1 JMM是规范
Java Memory Model (java 内存模型) 本身是一种抽象的概念,并不真实的存在,它描述的是一组规则或者是规范。需要各个jvm的实现来遵守这个规范,以便于开发者更好的处理多线程。
如果没有这种规则的约束,那么经过不同的JVM的不同规则的重排序以后,导致不同的虚拟机上运行的结果都不一样会造成很大的问题。
从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。
1.1.2 JMM是工具类和关键字的原理
之前我们使用了各种同步工具和关键字,包括 volatile、synchronized、Lock 等,其实它们的原理都涉及 JMM。正是 JMM 的参与和帮忙,才让各个同步工具和关键字能够发挥作用,帮我们开发出并发安全的程序。
比如我们写了关键字 synchronized,JVM 就会在 JMM 的规则下,“翻译”出合适的指令,包括限制指令之间的顺序,以便在即使发生了重排序的情况下,也能保证必要的“可见性”,这样一来,不同的 JVM 对于相同的代码的执行结果就变得可预期了,我们 Java 程序员就只需要用同步工具和关键字就可以开发出正确的并发程序了,这都要感谢 JMM。
JMM 里最重要 3 点内容,分别是:重排序、原子性、内存可见性
1.2 重排序、原子性、内存可见性
1.2.1 重排序
假设我们写了一个 Java 程序,包含一系列的语句,我们会默认期望这些语句的实际运行顺序和写的代码顺序一致。但实际上,编译器、JVM 或者 CPU 都有可能出于优化等目的,对于实际指令执行的顺序进行调整,这就是重排序。
图中左侧是 3 行 Java 代码,右侧是这 3 行代码可能被转化成的指令。可以看出 a = 100 对应的是 Load a、Set to 100、Store a,意味着从主存中读取 a 的值,然后把值设置为 100,并存储回去,同理, b = 5 对应的是下面三行 Load b、Set to 5、Store b,最后的 a = a + 10,对应的是 Load a、Set to 110、Store a。如果你仔细观察,会发现这里有两次“Load a”和两次“Store a”,说明存在一定的重排序的优化空间
重排序后,a 的两次操作被放到一起,指令执行情况变为 Load a、Set to 100、Set to 110、 Store a。下面和 b 相关的指令不变,仍对应 Load b、 Set to 5、Store b。
可以看出,重排序后 a 的相关指令发生了变化,节省了一次 Load a 和一次 Store a。重排序通过减少执行指令,从而提高整体的运行速度,这就是重排序带来的优化和好处。
重排序的 3 种情况
下面我们来看一下重排序的 3 种情况。
(1)编译器优化
编译器(包括 JVM、JIT 编译器等)出于优化的目的,例如当前有了数据 a,把对 a 的操作放到一起效率会更高,避免读取 b 后又返回来重新读取 a 的时间开销,此时在编译的过程中会进行一定程度的重排。不过重排序并不意味着可以任意排序,它需要需要保证重排序后,不改变单线程内的语义,否则如果能任意排序的话,程序早就逻辑混乱了。
(2)CPU 重排序
CPU 同样会有优化行为,这里的优化和编译器优化类似,都是通过乱序执行的技术来提高整体的执行效率。所以即使之前编译器不发生重排,CPU 也可能进行重排,我们在开发中,一定要考虑到重排序带来的后果。
(3) 内存的“重排序”
内存系统内不存在真正的重排序,但是内存会带来看上去和重排序一样的效果,所以这里的“重排序”打了双引号。由于内存有缓存的存在,在 JMM 里表现为主存和本地内存,而主存和本地内存的内容可能不一致,所以这也会导致程序表现出乱序的行为。
举个例子,线程 1 修改了 a 的值,但是修改后没有来得及把新结果写回主存或者线程 2 没来得及读到最新的值,所以线程 2 看不到刚才线程 1 对 a 的修改,此时线程 2 看到的 a 还是等于初始值。但是线程 2 却可能看到线程 1 修改 a 之后的代码执行效果,表面上看起来像是发生了重顺序。
1.2.2 原子性
在编程中,具备原子性的操作被称为原子操作。原子操作是指一系列的操作,要么全部发生,要么全部不发生,不会出现执行一半就终止的情况。
比如转账行为就是一个原子操作,该过程包含扣除余额、银行系统生成转账记录、对方余额增加等一系列操作。虽然整个过程包含多个操作,但由于这一系列操作被合并成一个原子操作,所以它们要么全部执行成功,要么全部不执行,不会出现执行一半的情况。比如我的余额已经扣除,但是对方的余额却不增加,这种情况是不会出现的,所以说转账行为是具备原子性的。而具有原子性的原子操作,天然具备线程安全的特性。
1.2.3 内存可见性
这是一段很简单的代码,类中有两个方法:
public class Visibility {
int x = 0;
public void write() {
x = 1;
}
public void read() {
int y = x;
}
}
write 方法,作用是给 x 赋值,代码中,把 x 赋值为 1,由于 x 的初始值是 0,所以执行 write 方法相当于改变了 x 的值;
read 方法,作用是把 x 读取出来,读取的时候我们用了一个新的 int 类型变量的 y 来接收 x 的值。
我们假设有两个线程来执行上述代码,第 1 个线程执行的是 write 方法,第 2 个线程执行的是 read 方法。下面我们来分析一下,代码在实际运行过程中的情景是怎么样的,如下图所示:
在图中可以看出,由于 x 的初始值为 0,所以对于左边的第 1 个线程和右边的第 2 个线程而言,它们都可以从主内存中去获取到这个信息,对两个线程来说 x 都是 0。可是此时我们假设第 1 个线程先去执行 write 方法,它就把 x 的值从 0 改为了 1,但是它改动的动作并不是直接发生在主内存中的,而是会发生在第 1 个线程的工作内存中,如下图所示。
那么,假设线程 1 的工作内存还未同步给主内存,此时假设线程 2 开始读取,那么它读到的 x 值不是 1,而是 0,也就是说虽然此时线程 1 已经把 x 的值改动了,但是对于第 2 个线程而言,根本感知不到 x 的这个变化,这就产生了可见性问题。
结论:内存可见性是因为JMM的内存模型,线程中有自己的工作内存,每个线程的运算是通过自己的工作内存完成的,而线程间的同步是通过同步&读取主内存完成的,若是没有同步最新的变量到主内存,那么其他线程读取只能读取未同步之前的。
1.2.4 主内存与工作内存
由于 CPU 的处理速度很快,相比之下,内存的速度就显得很慢,所以为了提高 CPU 的整体运行效率,减少空闲时间,在 CPU 和内存之间会有 cache 层,也就是缓存层的存在。虽然缓存的容量比内存小,但是缓存的速度却比内存的速度要快得多,其中 L1 缓存的速度仅次于寄存器的速度。结构示意图如下所示:
在图中,从下往上分别是内存,L3 缓存、L2 缓存、L1 缓存,寄存器,然后最上层是 CPU 的 4个核心。从内存,到 L3 缓存,再到 L2 和 L1 缓存,它们距离 CPU 的核心越来越近了,越靠近核心,其容量就越小,但是速度也越快。正是由于缓存层的存在,才让我们的 CPU 能发挥出更好的性能。
其实,线程间对于共享变量的可见性问题,并不是直接由多核引起的,而是由我们刚才讲到的这些 L3 缓存、L2 缓存、L1 缓存,也就是多级缓存引起的:每个核心在获取数据时,都会将数据从内存一层层往上读取,同样,后续对于数据的修改也是先写入到自己的 L1 缓存中,然后等待时机再逐层往下同步,直到最终刷回内存。
假设 core 1 修改了变量 a 的值,并写入到了 core 1 的 L1 缓存里,但是还没来得及继续往下同步,由于 core 1 有它自己的的 L1 缓存,core 4 是无法直接读取 core 1 的 L1 缓存的值的,那么此时对于 core 4 而言,变量 a 的值就不是 core 1 修改后的最新的值,core 4 读取到的值可能是一个过期的值,从而引起多线程时可见性问题的发生。
JMM的抽象:主内存和工作内存
什么是主内存和工作内存
Java 作为高级语言,屏蔽了 L1 缓存、L2 缓存、L3 缓存,也就是多层缓存的这些底层细节,用 JMM 定义了一套读写数据的规范。我们不再需要关心 L1 缓存、L2 缓存、L3 缓存等多层缓存的问题,我们只需要关心 JMM 抽象出来的主内存和工作内存的概念。为了更方便你去理解,可参考下图:
每个线程只能够直接接触到工作内存,无法直接操作主内存,而工作内存中所保存的正是主内存的共享变量的副本,主内存和工作内存之间的通信是由 JMM 控制的。
主内存和工作内存的关系
JMM 有以下规定:
(1)所有的变量都存储在主内存中,同时每个线程拥有自己独立的工作内存,而工作内存中的变量的内容是主内存中该变量的拷贝;
(2)线程不能直接读 / 写主内存中的变量,但可以操作自己工作内存中的变量,然后再同步到主内存中,这样,其他线程就可以看到本次修改;
(3) 主内存是由多个线程所共享的,但线程间不共享各自的工作内存,如果线程间需要通信,则必须借助主内存中转来完成。
从图中可以看出,每个工作内存中的变量都是对主内存变量的一个拷贝,相当于是一个副本。而且图中没有一条线是可以直接连接各个工作内存的,因为工作内存之间的通信,都需要通过主内存来中转。
正是由于所有的共享变量都存在于主内存中,每个线程有自己的工作内存,其中存储的是变量的副本,所以这个副本就有可能是过期的,我们来举个例子:如果一个变量 x 被线程 A 修改了,只要还没同步到主内存中,线程 B 就看不到,所以此时线程 B 读取到的 x 值就是一个过期的值,这就导致了可见性问题。
二、happens-before原则
2.1 什么是happen-before
Happens-before 关系是用来描述和可见性相关问题的:如果第一个操作 happens-before 第二个操作(也可以描述为,第一个操作和第二个操作之间满足 happens-before 关系),那么我们就说第一个操作对于第二个操作一定是可见的,也就是第二个操作在执行时就一定能保证看见第一个操作执行的结果。
2.1Happens-before 关系的规则有哪些
如果分别有操作 x 和操作 y,用 hb(x, y) 来表示 x happens-before y。
(1)单线程规则:
在一个单独的线程中,按照程序代码的顺序,先执行的操作 happen-before 后执行的操作。也就是说,如果操作 x 和操作 y 是同一个线程内的两个操作,并且在代码里 x 先于 y 出现,那么有 hb(x, y),正如下图所示:
这一个 happens-before 的规则非常重要,因为如果对于同一个线程内部而言,后面语句都不能保证可以看见前面的语句的执行结果的话,那会造成非常严重的后果,程序的逻辑性就无法保证了。
这里有一个注意点,我们之前讲过重排序,那是不是意味着 happens-before 关系的规则和重排序冲突,为了满足 happens-before 关系,就不能重排序了?
答案是否定的。其实只要重排序后的结果依然符合 happens-before 关系,也就是能保证可见性的话,那么就不会因此限制重排序的发生。比如,单线程内,语句 1 在语句 2 的前面,所以根据“单线程规则”,语句 1 happens-before 语句 2,但是并不是说语句 1 一定要在语句 2 之前被执行,例如语句 1 修改的是变量 a 的值,而语句 2 的内容和变量 a 无关,那么语句 1 和语句 2 依然有可能被重排序。当然,如果语句 1 修改的是变量 a,而语句 2 正好是去读取变量 a 的值,那么语句 1 就一定会在语句 2 之前执行了。
(2)锁操作规则(synchronized 和 Lock 接口等):
如果操作 A 是解锁,而操作 B 是对同一个锁的加锁,那么 hb(A, B) 。正如下图所示:
从上图中可以看到,有线程 A 和线程 B 这两个线程。线程 A 在解锁之前的所有操作,对于线程 B 的对同一个锁的加锁之后的所有操作而言,都是可见的。这就是锁操作的 happens-before 关系的规则。
(3)volatile 变量规则:
对一个 volatile 变量的写操作 happen-before 后面对该变量的读操作。
这就代表了如果变量被 volatile 修饰,那么每次修改之后,其他线程在读取这个变量的时候一定能读取到该变量最新的值。
(4)线程启动规则:
Thread 对象的 start 方法 happen-before 此线程 run 方法中的每一个操作。如下图所示:
在图中的例子中,左侧区域是线程 A 启动了一个子线程 B,而右侧区域是子线程 B,那么子线程 B 在执行 run 方法里面的语句的时候,它一定能看到父线程在执行 threadB.start() 前的所有操作的结果。
(5)线程 join 规则:
我们知道 join 可以让线程之间等待,假设线程 A 通过调用 threadB.start() 启动了一个新线程 B,然后调用 threadB.join() ,那么线程 A 将一直等待到线程 B 的 run 方法结束(不考虑中断等特殊情况),然后 join 方法才返回。在 join 方法返回后,线程 A 中的所有后续操作都可以看到线程 B 的 run 方法中执行的所有操作的结果,也就是线程 B 的 run 方法里面的操作 happens-before 线程 A 的 join 之后的语句。如下图所示:
(6)中断规则:
对线程 interrupt 方法的调用 happens-before 检测该线程的中断事件。
也就是说,如果一个线程被其他线程 interrupt,那么在检测中断时(比如调用Thread.interrupted 或者 Thread.isInterrupted 方法)一定能看到此次中断的发生,不会发生检测结果不准的情况。
(7)并发工具类的规则:
线程安全的并发容器(如 HashTable)在 get 某个值时一定能看到在此之前发生的 put 等存入操作的结果。也就是说,线程安全的并发容器的存入操作 happens-before 读取操作。
信号量(Semaphore)它会释放许可证,也会获取许可证。这里的释放许可证的操作 happens-before 获取许可证的操作,也就是说,如果在获取许可证之前有释放许可证的操作,那么在获取时一定可以看到。
Future:Future 有一个 get 方法,可以用来获取任务的结果。那么,当 Future 的 get 方法得到结果的时候,一定可以看到之前任务中所有操作的结果,也就是说 Future 任务中的所有操作 happens-before Future 的 get 操作。
线程池:要想利用线程池,就需要往里面提交任务(Runnable 或者 Callable),这里面也有一个 happens-before 关系的规则,那就是提交任务的操作 happens-before 任务的执行。