深刻理解JVM:Java内存模型JMM

多任务和高并发的内存交互

多任务和高并发是衡量一台计算机处理器的能力重要指标之一。通常衡量一个服务器性能的高低好坏,使用每秒事务处理数(Transactions Per Second,TPS)这个指标比较能说明问题,它表明着一秒内服务器平均能响应的请求数,而TPS值与程序的并发能力有着很是密切的关系。物理机的并发问题与虚拟机中的状况有不少类似之处,物理机对并发的处理方案对于虚拟机的实现也有至关大的参考意义。java

因为计算机的存储设备与处理器的运算能力之间有几个数量级的差距,因此现代计算机系统都不得不加入一层读写速度尽量接近处理器运算速度的高速缓存(cache)来做为内存与处理器之间的缓冲:将运算须要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无需等待缓慢的内存读写了。程序员

基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,可是引入了一个新的问题:缓存一致性(Cache Coherence)。在多处理器系统中,每一个处理器都有本身的高速缓存,而他们又共享同一主存,以下图所示:多个处理器运算任务都涉及同一块主存,须要一种协议能够保障数据的一致性,这类协议有MSI、MESI、MOSI及Dragon Protocol等。编程

物理机内存交互关系

除此以外,为了使得处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行(Out-Of-Order Execution)优化,处理器会在计算以后将对乱序执行的代码进行结果重组,保证结果准确性。与处理器的乱序执行优化相似,Java虚拟机的即时编译器中也有相似的指令重排序(Instruction Recorder)优化。数组

Java内存模型

内存模型能够理解为在特定的操做协议下,对特定的内存或者高速缓存进行读写访问的过程抽象,不一样架构下的物理机拥有不同的内存模型,Java虚拟机也有本身的内存模型,即Java内存模型(Java Memory Model, JMM)。在C/C++语言中直接使用物理硬件和操做系统内存模型,致使不一样平台下并发访问出错。而JMM的出现,可以屏蔽掉各类硬件和操做系统的内存访问差别,实现平台一致性,是的Java程序可以“一次编写,处处运行”。缓存

主内存和工做内存

Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样底层细节。此处的变量与Java编程时所说的变量不同,指包括了实例字段、静态字段和构成数组对象的元素,可是不包括局部变量与方法参数,后者是线程私有的,不会被共享。安全

Java内存模型中规定了全部的变量都存储在主内存中,每条线程还有本身的工做内存(能够与前面讲的处理器的高速缓存类比),线程的工做内存中保存了该线程使用到的变量到主内存副本拷贝,线程对变量的全部操做(读取、赋值)都必须在工做内存中进行,而不能直接读写主内存中的变量。不一样线程之间没法直接访问对方工做内存中的变量,线程间变量值的传递均须要在主内存来完成,线程、主内存和工做内存的交互关系以下图所示,和上图很相似。服务器

虚拟机内存交互关系

注意:这里的主内存、工做内存与Java内存区域的Java堆、栈、方法区不是同一层次内存划分,这二者基本上没有关系。多线程

内存交互操做

由上面的交互关系可知,关于主内存与工做内存之间的具体交互协议,即一个变量如何从主内存拷贝到工做内存、如何从工做内存同步到主内存之间的实现细节,Java内存模型定义了如下八种操做来完成:架构

  • lock(锁定):做用于主内存的变量,把一个变量标识为一条线程独占状态。
  • unlock(解锁):做用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才能够被其余线程锁定。
  • read(读取):做用于主内存变量,把一个变量值从主内存传输到线程的工做内存中,以便随后的load动做使用
  • load(载入):做用于工做内存的变量,它把read操做从主内存中获得的变量值放入工做内存的变量副本中。
  • use(使用):做用于工做内存的变量,把工做内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个须要使用变量的值的字节码指令时将会执行这个操做。
  • assign(赋值):做用于工做内存的变量,它把一个从执行引擎接收到的值赋值给工做内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操做。
  • store(存储):做用于工做内存的变量,把工做内存中的一个变量的值传送到主内存中,以便随后的write的操做。
  • write(写入):做用于主内存的变量,它把store操做从工做内存中一个变量的值传送到主内存的变量中。

若是要把一个变量从主内存中复制到工做内存,就须要按顺寻地执行read和load操做,若是把变量从工做内存中同步回主内存中,就要按顺序地执行store和write操做。Java内存模型只要求上述两个操做必须按顺序执行,而没有保证必须是连续执行。也就是read和load之间,store和write之间是能够插入其余指令的,如对主内存中的变量a、b进行访问时,可能的顺序是read a,read b,load b, load a。Java内存模型还规定了在执行上述八种基本操做时,必须知足以下规则:并发

  • 不容许read和load、store和write操做之一单独出现
  • 不容许一个线程丢弃它的最近assign的操做,即变量在工做内存中改变了以后必须同步到主内存中。
  • 不容许一个线程无缘由地(没有发生过任何assign操做)把数据从工做内存同步回主内存中。
  • 一个新的变量只能在主内存中诞生,不容许在工做内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操做以前,必须先执行过了assign和load操做。
  • 一个变量在同一时刻只容许一条线程对其进行lock操做,lock和unlock必须成对出现
  • 若是对一个变量执行lock操做,将会清空工做内存中此变量的值,在执行引擎使用这个变量前须要从新执行load或assign操做初始化变量的值
  • 若是一个变量事先没有被lock操做锁定,则不容许对它执行unlock操做;也不容许去unlock一个被其余线程锁定的变量。
  • 对一个变量执行unlock操做以前,必须先把此变量同步到主内存中(执行store和write操做)。

这8种内存访问操做很繁琐,后文会使用一个等效判断原则,即先行发生(happens-before)原则来肯定一个内存访问在并发环境下是否安全。

volatile变量规则

关键字volatile是JVM中最轻量的同步机制。volatile变量具备2种特性:

  • 保证变量的可见性。对一个volatile变量的读,老是能看到(任意线程)对这个volatile变量最后的写入,这个新值对于其余线程来讲是当即可见的。
  • 屏蔽指令重排序:指令重排序是编译器和处理器为了高效对程序进行优化的手段,下文有详细的分析。

volatile语义并不能保证变量的原子性。对任意单个volatile变量的读/写具备原子性,但相似于i++、i–这种复合操做不具备原子性,由于自增运算包括读取i的值、i值增长一、从新赋值3步操做,并不具有原子性。

因为volatile只能保证变量的可见性和屏蔽指令重排序,只有知足下面2条规则时,才能使用volatile来保证并发安全,不然就须要加锁(使用synchronized、lock或者java.util.concurrent中的Atomic原子类)来保证并发中的原子性。

  • 运算结果不存在数据依赖(重排序的数据依赖性),或者只有单一的线程修改变量的值(重排序的as-if-serial语义)
  • 变量不须要与其余的状态变量共同参与不变约束

由于须要在本地代码中插入许多内存屏蔽指令在屏蔽特定条件下的重排序,volatile变量的写操做与读操做相比慢一些,可是其性能开销比锁低不少。

long/double非原子协定

JMM要求lock、unlock、read、load、assign、use、store、write这8个操做都必须具备原子性,但对于64为的数据类型(long和double,具备非原子协定:容许虚拟机将没有被volatile修饰的64位数据的读写操做划分为2次32位操做进行。(与此相似的是,在栈帧结构的局部变量表中,long和double类型的局部变量可使用2个能存储32位变量的变量槽(Variable Slot)来存储的,关于这一部分的详细分析,详见详见周志明著《深刻理解Java虚拟机》8.2.1节)

若是多个线程共享一个没有声明为volatile的long或double变量,而且同时读取和修改,某些线程可能会读取到一个既非原值,也不是其余线程修改值的表明了“半个变量”的数值。不过这种状况十分罕见。由于非原子协议换句话说,一样容许long和double的读写操做实现为原子操做,而且目前绝大多数的虚拟机都是这样作的。

原子性、可见性、有序性

原子性

JMM保证的原子性变量操做包括read、load、assign、use、store、write,而long、double非原子协定致使的非原子性操做基本能够忽略。若是须要对更大范围的代码实行原子性操做,则须要JMM提供的lock、unlock、synchronized等来保证。

可见性

前面分析volatile语义时已经提到,可见性是指当一个线程修改了共享变量的值,其余线程可以当即得知这个修改。JMM在变量修改后将新值同步回主内存,依赖主内存做为媒介,在变量被线程读取前从内存刷新变量新值,保证变量的可见性。普通变量和volatile变量都是如此,只不过volatile的特殊规则保证了这种可见性是当即得知的,而普通变量并不具有这种严格的可见性。除了volatile外,synchronized和final也能保证可见性。

有序性

JMM的有序性表现为:若是在本线程内观察,全部的操做都是有序的;若是在一个线程中观察另外一个线程,全部的操做都是无序的。前半句指“线程内表现为串行的语义”(as-if-serial),后半句值“指令重排序”和普通变量的”工做内存与主内存同步延迟“的现象。

重排序

在执行程序时为了提升性能,编译器和处理器常常会对指令进行重排序。从硬件架构上来讲,指令重排序是指CPU采用了容许将多条指令不按照程序规定的顺序,分开发送给各个相应电路单元处理,而不是指令任意重排。重排序分红三种类型:

  • 编译器优化的重排序。编译器在不改变单线程程序语义放入前提下,能够从新安排语句的执行顺序。
  • 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。若是不存在数据依赖性,处理器能够改变语句对应机器指令的执行顺序。
  • 内存系统的重排序。因为处理器使用缓存和读写缓冲区,这使得加载和存储操做看上去多是在乱序执行。

重排序

JMM的重排序屏障

从Java源代码到最终实际执行的指令序列,会通过三种重排序。可是,为了保证内存的可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。对于编译器的重排序,JMM会根据重排序规则禁止特定类型的编译器重排序;对于处理器重排序,JMM会插入特定类型的内存屏障,经过内存的屏障指令禁止特定类型的处理器重排序。这里讨论JMM对处理器的重排序,为了更深理解JMM对处理器重排序的处理,先来认识一下常见处理器的重排序规则:

重排序屏障

其中的N标识处理器不容许两个操做进行重排序,Y表示容许。其中Load-Load表示读-读操做、Load-Store表示读-写操做、Store-Store表示写-写操做、Store-Load表示写-读操做。能够看出:常见处理器对写-读操做都是容许重排序的,而且常见的处理器都不容许对存在数据依赖的操做进行重排序(对应上面数据转换那一列,都是N,因此处理器不容许这种重排序)。

那么这个结论对咱们有什么做用呢?好比第一点:处理器容许写-读操做二者之间的重排序,那么在并发编程中读线程读到多是一个未被初始化或者是一个NULL等,出现不可预知的错误,基于这点,JMM会在适当的位置插入内存屏障指令来禁止特定类型的处理器的重排序。内存屏障指令一共有4类:

  • LoadLoad Barriers:确保Load1数据的装载先于Load2以及全部后续装载指令
  • StoreStore Barriers:确保Store1的数据对其余处理器可见(会使缓存行无效,并刷新到内存中)先于Store2及全部后续存储指令的装载
  • LoadStore Barriers:确保Load1数据装载先于Store2及全部后续存储指令刷新到内存
  • StoreLoad Barriers:确保Store1数据对其余处理器可见(刷新到内存,而且其余处理器的缓存行无效)先于Load2及全部后续装载指令的装载。该指令会使得该屏障以前的全部内存访问指令完成以后,才能执行该屏障以后的内存访问指令。

数据依赖性

根据上面的表格,处理器不会对存在数据依赖的操做进行重排序。这里数据依赖的准肯定义是:若是两个操做同时访问一个变量,其中一个操做是写操做,此时这两个操做就构成了数据依赖。常见的具备这个特性的如i++、i—。若是改变了具备数据依赖的两个操做的执行顺序,那么最后的执行结果就会被改变。这也是不能进行重排序的缘由。例如:

  • 写后读:a = 1; b = a;
  • 写后写:a = 1; a = 2;
  • 读后写:a = b; b = 1;

重排序遵照数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操做的执行顺序。可是这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操做,不一样处理器之间和不一样线程之间的数据依赖性不被编译器和处理器考虑。

as-if-serial语义

as-if-serial语义的意思指:管怎么重排序(编译器和处理器为了提升并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵照as-if-serial语义。

as-if-serial语义把单线程程序保护了起来,遵照as-if-serial语义的编译器,runtime 和处理器共同为编写单线程程序的程序员建立了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担忧重排序会干扰他们,也无需担忧内存可见性问题。

重排序对多线程的影响

若是代码中存在控制依赖的时候,会影响指令序列执行的并行度(由于高效)。也是为此,编译器和处理器会采用猜想(Speculation)执行来克服控制的相关性。因此重排序破坏了程序顺序规则(该规则是说指令执行顺序与实际代码的执行顺序是一致的,可是处理器和编译器会进行重排序,只要最后的结果不会改变,该重排序就是合理的)。

在单线程程序中,因为as-ifserial语义的存在,对存在控制依赖的操做重排序,不会改变执行结果;但在多线程程序中,对存在控制依赖的操做重排序,可能会改变程序的执行结果。

先行发生原则(happens-before)

前面所述的内存交互操做必需要知足必定的规则,而happens-before就是定义这些规则的一个等效判断原则。happens-before是JMM定义的2个操做之间的偏序关系:若是操做A线性发生于操做B,则A产生的影响能被操做B观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。若是两个操做知足happens-before原则,那么不须要进行同步操做,JVM可以保证操做具备顺序性,此时不可以随意的重排序。不然,没法保证顺序性,就能进行指令的重排序。

happens-before原则主要包括:

  • 程序次序规则(Program Order Rule):在同一个线程中,按照程序代码顺序,书写在前面的操做先行发生于书写在后面的操纵。准确的说是程序的控制流顺序,考虑分支和循环等。
  • 管理锁定规则(Monitor Lock Rule):一个unlock操做先行发生于后面(时间上的顺序)对同一个锁的lock操做。
  • volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操做先行发生于后面(时间上的顺序)对该变量的读操做。
  • 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每个动做。
  • 线程终止规则(Thread Termination Rule):线程的全部操做都先行发生于对此线程的终止检测,能够经过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
  • 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断时事件的发生。Thread.interrupted()能够检测是否有中断发生。
  • 对象终结规则(Finilizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()的开始。
  • 传递性(Transitivity):若是操做A 先行发生于操做B,操做B 先行发生于操做C,那么能够得出A 先行发生于操做C。

注意:不一样操做时间前后顺序与先行发生原则之间没有关系,两者不能相互推断,衡量并发安全问题不能受到时间顺序的干扰,一切都要以happens-before原则为准

示例代码1:欢迎点击连接加入群【Java并发编程交流组】:https://jq.qq.com/?_wv=1027&k=5mOvK7L

private int value = 0;

public void setValue(int value) {
    this.value = value;
}

public int getValue() {
    return this.value;
}
  • 对于上面的代码,假设线程A在时间上先调用setValue(1),而后线程B调用getValue()方法,那么线程B收到的返回值必定是1吗?

按照happens-before原则,两个操做不在同一个线程、没有通道锁同步、线程的相关启动、终止和中断以及对象终结和传递性等规则都与此处没有关系,所以这两个操做是不符合happens-before原则的,这里的并发操做是不安全的,返回值并不必定是1。

对于该问题的修复,可使用lock或者synchronized套用“管程锁定规则”实现先行发生关系;或者将value定义为volatile变量(两个方法的调用都不存在数据依赖性),套用“volatile变量规则”实现先行发生关系。如此一来,就能保证并发安全性。

示例代码2

// 如下操做在同一个线程中
int i = 1;
int j = 2;

上面的代码符合“程序次序规则”,知足先行发生关系,可是第2条语句彻底可能因为重排序而被处理器先执行,时间上先于第1条语句,欢迎点击连接加入群【Java并发编程交流组】:https://jq.qq.com/?_wv=1027&k=5mOvK7L

相关文章
相关标签/搜索