Java 并发学习之 JMM
顺序一致性模型与 JMM
顺序一致性模型是一种理想的内存模型,在这个模型下,指令是严格按照代码的编写顺序执行,同时所有线程只能看到同一个内存区且对内存区的操作都是互斥的,内存对所有线程都是可见的。
JMM 中,由于每个线程有自己的工作内存,很多情况下,只是对工作内存中的变量副本进行修改而未真正同步到主内存中,因此每个线程对内存的更改对其他线程都是不可见的,同时出于对性能的优化,指令的顺序经过编译器和处理器的重排,其执行的顺序跟代码的编写顺序很有可能不一样。所以导致了 JMM 不可能像顺序一致性模型那样具有极强的内存可见性保证,在 JMM 中如果要一个操作执行的结果对另一个操作可见,那么这两个操作必须满足 happens-before 原则。
什么是指令重排?
指令重排是编译器和处理器用于优化程序性能的手段,举个例子,指令 B 的执行依赖指令 A 的结果,此时有一些对内存的访问操作就可以插入指令 B 和指令 A 之间,以提高 IO 的效率。指令重排有一个基本原则,就是在单线程中遵守数据的依赖关系(写写、写读、读写指令间都是有依赖关系,不允许重排),从而保证单线程中执行结果的一致性。
本文将从happens-before 原则出发,分析 JMM 中用于提供内存可见性的设计。
happens-before
volatile
volatile 变量具有可见性和原子性。可见性是指对一个 volatile 变量的读总是能够看到对这个 volatile 变量的最后写入;原子性是指对 volatile 变量的读操作和写操作具有原子性,复合操作(volatile++)不具有。
为了保证 volatile 变量的两个特性,JMM 在对 volatile 变量的读操作和写操作进行以下的设计:
- 当写一个 volatile 变量时,JMM 会将线程工作内存的变量刷新到主内存。
- 当读一个 volatile 变量时,JMM 会将工作内存的变量置为无效,直接从主内存中获取。
由于指令的重排序导致多个线程下,变量的读写依赖无法被满足,JMM 对 volatile 变量的指令重排做了限制:
- volatile 写之前的操作不能被重排到 volatile 写之后。
- volatile 读之后的操作不能被重排到 volatile 读之前。
- 当第一个操作是 volatile 写,第二个操作是 volatile 读时,不能重排。
这些限制都是通过 JMM 内存屏障来实现的。内存屏障的细节本文不做详细介绍。
锁
针对锁的获取到释放过程遵循以下三个 happens-before 关系:
- 程序次序规则:先获取锁,再执行临界区代码
- 监视器规则:先释放锁,再获取锁
- happens-before 传递性:先获取锁的临界区代码先执行
为了保证内存的可见性,JMM 在锁的释放和获取操作进行以下的设计:
- 当线程释放锁时,会把该线程对应的工作内存的变量刷新到主内存。
- 当线程获取锁时,会把线程对应的工作内存的变量置为无效,从而使得临界区代码必须从主内存中读取变量。
JMM 锁的释放和获取操作的可见性实际上是通过对 volatile 变量的操作来实现的。(参考 AQS 的实现)
final 域
JMM 对 final 域的重排序也做了限制:
- 在构造函数内对 final 域的写入,与随后将被构造对象的引用赋值给一个引用对象,这两个操作不能重排。
- 初次读包含 final 域对象的引用,与随后初次读这个 final 域,这两个操作不能重排。
这些限制也是通过 JMM 内存屏障实现的。
线程
在线程 A 中执行操作 ThreadB.start(),A 线程中的 ThreadB.start() 操作 happens-before 线程 B 中的任意操作。在线程 A 中执行操作 ThreadB.join(),B 线程中的任意操作 happens-before 线程 A 从 Thread.join() 操作返回。