为了更好的理解 Java 是如何实现 按需禁用缓存和编译优化 的,我们首先需要对 Java 的内存模型有一个初步的了解。
Java 内存模型主要由以下三部分构成:1 个主内存、n 个线程、n 个工作内存(与线程一一对应),数据就在它们三者之间来回倒腾。那么怎么倒腾呢?靠的是 Java 提供给我们的 8 个原子操作:lock
、unlock
、read
、load
、use
、assign
、store
、write
,其操作流程示意图如下:
一个变量从主内存拷贝到工作内存,再从工作内存同步回主内存的流程为:
|主内存| -> read -> load -> |工作内存| -> use -> |Java线程| -> assign -> |工作内存| -> store -> write -> |主内存|
Java 内存模型中的 8 个原子操作
lock
:作用于主内存,把一个变量标识为一个线程独占状态。read
:作用于主内存,把一个变量的值从主内存传输到线程工作内存中,供之后的load
操作使用。load
:作用于工作内存,把read
操作从主内存中得到的变量值放入工作内存的变量副本中。use
:作用于工作内存,把工作内存中的一个变量传递给执行引擎,虚拟机遇到使用变量值的字节码指令时会执行。assign
:作用于工作内存,把一个从执行引擎得到的值赋给工作内存的变量,虚拟机遇到给变量赋值的字节码指令时会执行。store
:作用于工作内存,把工作内存中的一个变量传送到主内存中,供之后的write
操作使用。write
:作用于主内存,把store
操作从工作内存中得到的变量值存入主内存的变量中。unlock
:作用于主内存,释放一个处于锁定状态的变量。
8 个原子操作的执行规则
有关变量拷贝过程的规则
- 不允许
read
和load
,store
和write
单独出现 - 不允许线程丢弃它最近的
assign
操作,即工作内存变化之后必须把该变化同步回主内存中 - 不允许一个线程在没有
assign
的情况下将工作内存同步回主内存中,也就是说,只有虚拟机遇到变量赋值的字节码时才会将工作内存同步回主内存 - 新的变量只能从主内存中诞生,即不能在工作内存中使用未被
load
和assign
的变量,一个变量在use
和store
前一定先经过了load
和assign
有关加锁的规则
- 一个变量在同一时刻只允许一个线程对其进行
lock
操作,但是可以被一个线程多次lock
(锁的可重入) - 对一个变量进行
lock
操作会清空这个变量在工作内存中的值,然后在执行引擎使用这个变量时,需要通过assign
或load
重新对这个变量进行初始化 - 对一个变量执行
unlock
前,必须将该变量同步回主内存中,即执行store
和write
操作 - 一个变量没有被
lock
,就不能被unlock
,也不能去unlock
一个被其他线程lock
的变量
可见性问题 -> 有序性问题
通过上图可以发现,Java 线程只能操作自己的工作内存,其对变量的所有操作(读取、赋值等)都必须在工作内存中进行,不能直接读写主内存中的变量。这就有可能会导致可见性问题:
- 因为对于主内存中的变量 A,其在不同的线程的工作内存中可能存在不同的副本 A1、A2、A3。
- 不同线程的
read
和load
、store
和write
不一定是连续执行的,中间可以插入其他命令。Java 只能保证read
和load
、store
和write
的执行对于一个线程而言是连续的,但是并不保证不同线程的read
和load
、store
和write
的执行是连续的,如下图:
假设有两个线程 A 和 B,其中线程 A 在写入共享变量,线程 B 要读取共享变量,我们想让线程 A 先完成写入,线程 B 再完成读取。此时即便我们是按照 “线程 A 写入 -> 线程 B 读取” 的顺序开始执行的,真实的执行顺序也可能是这样的:storeA -> readB -> writeA -> loadB
,这将导致线程 B 读取的是变量的旧值,而非线程 A 修改过的新值。也就是说,线程 A 修改变量的执行先于线程 B 操作了,但这个操作对于线程 B 而言依旧是不可见的。
那么如何解决这个问题呢?通过上述的分析可以发现,可见性问题的本身,也是由于不同线程之间的执行顺序得不到保证导致的,因此我们也可以将它的解决和有序性合并,即对 Java 一些指令的操作顺序进行限制,这样既保证了有序性,有解决了可见性。
于是乎,Java 给出了一些命令执行的顺序规范,也就是大名鼎鼎 Happens-Before 规则。