目录
一、物理机内存模型
首先,了解一下物理计算机并发的问题,了解物理机的并发解决方案对虚拟机的并发实现有相当大的参考意义。
(一)缓存一致性
现代处理器在内存与处理器之间加了一层高速缓存(Cache),用于内存读取速度和处理器处理速度之间的调和。将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。
随之带来了一个新问题:缓存一致性,在多处理器系统中,每个处理器都有自己的告诉缓存,它们又共享同一内存,当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的数据不一样,写回主内存区域时以谁的缓存为准也需要确定。为了解决缓存一致性问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI、MOSI、Synapse、Firefly和Dragon Protocol等。
(二)指令重排序
为了使得处理器内部的运算能力能尽量被充分利用,处理器可能会对输入代码进行乱序执行(Out-Of-Order Execution)优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码的顺序一致的。
因此,如果存在一个计算任务依赖另外一个计算的任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证。
二、Java内存模型
Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。
Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。此处的变量包括了实例字段、静态字段和构成数组对象的元素,但不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题。
(一)主内存与工作内存
Java内存模型规定了所有变量都存储在主内存(Main Memory,类比物理机主内存RAM)中。每条线程还有自己的工作内存(Working Memory,类比物理机高速缓存),线程在工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。
Java线程之间使用共享内存进行通信,不同的线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
(二)内存间交互操作
类比物理机的缓存一致性问题,需要主内存与工作内存之间的交互协议。Java内存模型中定义了一下8中操作来完成,虚拟机在实现时必须保证下面提及的每一种操作都是原子的、不可再分的。
但对于64位的数据类型(long和double),在模型中特别定义了一条相对宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现选择可以不保证64位数据类型的load、store、read和write这4个操作的原子性。但在虚拟机实现时,几乎都选择把64位数据的读写操作作为原子操作来对待。
操作 | 描述 |
---|---|
lock(锁定) | 作用于主内存的变量,它把一个变量表识为一条线程独占的状态。 |
unlock(解锁) | 作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。 |
read(读取) | 作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load操作使用。 |
load(载入) | 作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。 |
use(使用) | 作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的字节码指令时将会执行这个操作。 |
assign(赋值) | 作用于工作内存的变量,它把一个执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。 |
store(存储) | 作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。 |
write(写入) | 作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。 |
-
不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起回写了但主内存不接受的情况出现。
-
不允许一个线程丢弃它的最近的assign操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起回写了但主内存不接受的情况出现。
-
不允许一个线程无原因地(没有发生任何assign操作)把数据从线程的工作内存同步回主内存中。
-
一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说,就是对一个变量实施use、store操作之前,必须先执行过了assign和load操作。
-
一个变量在同一时刻只允许一条线程对其进行操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
-
如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。
-
如果一个变量实现没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定住的变量。
-
对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。
(三)从源代码到指令序列的重排序
在不改变程序执行结果的前提下,会尽可能提高并行度,这是编辑器、处理器、JMM共同遵从的目标。所以在执行程序时,编译器和处理器常常会对指令做重排序。
-
编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
-
指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level-Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
-
内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
上述1属于编译器排序,2、3属于处理器排序。对于编译器排序,JMM会禁止特定类型的编译器重排序。对于处理器排序,JMM会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。
内存屏障类型表:
屏障类型 | 指令示例 | 说明 |
---|---|---|
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及所有后续装载指令的装载。 |
JMM属于语言级别的内存模型,它确保在不同的编译器和不同的处理器平台上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
(四)重排序规则
1.数据依赖性
如果两个操作访问同一变量,且这两个操作中有一个为写操作,此时这两个操作就存在数据依赖性。编译器和处理器在重排序时不会改变存在数据依赖关系的两个操作的执行顺序。
注:这里所说的数据依赖性仅针对单个处理器执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
2.as-if-serial语义
as-if-serial语义的意思是:不管怎么重排序,单线程的执行结果不能改变。编译器和处理器都必须遵守as-if-serial语义。
3.先行发生原则(happens-before)
从JDK1.5开始,Java使用新的JSR-133内存模型。JSR-133使用happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。(两个操作可以是一个线程内,也可以在不同线程之间)
happens-before规则
-
程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作happens-before于写在后面的操作。准确的说,应该是按照控制流顺序而不知程序代码顺序,因为要考虑分支、循环等结构。
-
管道锁定规则:一个unlock操作happens-before于后面对同一个锁的lock操作。(时间顺序)
-
volatile变量规则:对一个volatile变量的写操作happens-before于后面对这个变量的读操作。(时间顺序)
-
传递性:如果操作Ahappens-before于操作B,操作Bhappens-before于操作C,那么操作Ahappens-before于操作C。
-
线程启动规则:Thread对象的start()方法happens-before于此线程的每一个动作。
-
线程终止规则:线程中所有操作都happens-before于对此线程的终止检测。
-
线程中断规则:对线程interrupt()方法的调用happens-before于被中断线程的代码检测到中断事件的发生。
-
对象终结规则:一个对象的初始化完成(构造函数执行结束)happens-before于它的finalize()方法的开始。