Java内存模型(JMM Java Memory Model)探究

Java内存模型

咱们常说的JVM内存模式指的是JVM的内存分区;而Java内存模式是一种虚拟机规范,真实并不存在 Java虚拟机规范中定义了Java内存模型(Java Memory Model,JMM),用于屏蔽掉各类硬件和操做系统的内存访问差别,以实现让Java程序在各类平台下都能达到一致的并发效果,JMM规范了Java虚拟机与计算机内存是如何协同工做的:规定了一个线程如何和什么时候能够看到由其余线程修改事后的共享变量的值,以及在必须时如何同步的访问共享变量。html

原始的Java内存模型存在一些不足,所以Java内存模型在Java1.5时被从新修订。这个版本的Java内存模型在Java8中仍然在使用。java

Java内存模型(不只仅是JVM内存分区):调用栈和本地变量存放在线程栈上,对象存放在堆上。程序员

内容模型示意图 硬件角度示意图 编程

  • 一个本地变量多是原始类型,在这种状况下,它老是“呆在”线程栈上。
  • 一个本地变量也多是指向一个对象的一个引用。在这种状况下,引用(这个本地变量)存放在线程栈上,可是对象自己存放在堆上。
  • 一个对象可能包含方法,这些方法可能包含本地变量。这些本地变量仍然存放在线程栈上,即便这些方法所属的对象存放在堆上
  • 一个对象的成员变量可能随着这个对象自身存放在堆上。无论这个成员变量是原始类型仍是引用类型。
  • 静态成员变量跟随着类定义一块儿也存放在堆上。
  • 存放在堆上的对象能够被全部持有对这个对象引用的线程访问。当一个线程能够访问一个对象时,它也能够访问这个对象的成员变量。若是两个线程同时调用同一个对象上的同一个方法,它们将会都访问这个对象的成员变量,可是每个线程都拥有这个成员变量的私有拷贝。

硬件内存架构

现代硬件内存模型与Java内存模型有一些不一样,理解内存模型架构以及Java内存模型如何与它协同工做也是很是重要的。缓存

现代计算机硬件架构的简单图示: 多线程

  • 多CPU:一个现代计算机一般由两个或者多个CPU。其中一些CPU还有多核。从这一点能够看出,在一个有两个或者多个CPU的现代计算机上同时运行多个线程是可能的。每一个CPU在某一时刻运行一个线程是没有问题的。这意味着,若是你的Java程序是多线程的,在你的Java程序中每一个CPU上一个线程可能同时(并发)执行。
  • CPU寄存器:每一个CPU都包含一系列的寄存器,它们是CPU内内存的基础。CPU在寄存器上执行操做的速度远大于在主存上执行的速度。这是由于CPU访问寄存器的速度远大于主存。
  • 高速缓存cache:因为计算机的存储设备与处理器的运算速度之间有着几个数量级的差距,因此现代计算机系统都不得不加入一层读写速度尽量接近处理器运算速度的高速缓存(Cache)来做为内存与处理器之间的缓冲:将运算须要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。CPU访问缓存层的速度快于访问主存的速度,但一般比访问内部寄存器的速度还要慢一点。每一个CPU可能有一个CPU缓存层,一些CPU还有多层缓存。在某一时刻,一个或者多个缓存行(cache lines)可能被读到缓存,一个或者多个缓存行可能再被刷新回主存。
  • 内存:一个计算机还包含一个主存。全部的CPU均可以访问主存。主存一般比CPU中的缓存大得多。
  • 运做原理:一般状况下,当一个CPU须要读取主存时,它会将主存的部分读到CPU缓存中。它甚至可能将缓存中的部份内容读到它的内部寄存器中,而后在寄存器中执行操做。当CPU须要将结果写回到主存中去时,它会将内部寄存器的值刷新到缓存中,而后在某个时间点将值刷新回主存。

一些问题:(尤为是多线程环境下)架构

  • 缓存一致性问题:在多处理器系统中,每一个处理器都有本身的高速缓存,而它们又共享同一主内存(MainMemory)。基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,可是也引入了新的问题:缓存一致性(CacheCoherence)。当多个处理器的运算任务都涉及同一块主内存区域时,将可能致使各自的缓存数据不一致的状况,若是真的发生这种状况,那同步回到主内存时以谁的缓存数据为准呢?为了解决一致性的问题,须要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操做,这类协议有MSI、MESI(IllinoisProtocol)、MOSI、Synapse、Firefly及DragonProtocol,等等: 并发

  • 指令重排序问题:为了使得处理器内部的运算单元能尽可能被充分利用,处理器可能会对输入代码进行乱序执行(Out-Of-Order Execution)优化,处理器会在计算以后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的前后顺序与输入代码中的顺序一致。所以,若是存在一个计算任务依赖另外一个计算任务的中间结果,那么其顺序性并不能靠代码的前后顺序来保证。与处理器的乱序执行优化相似,Java虚拟机的即时编译器中也有相似的指令重排序(Instruction Reorder)优化app

Java内存模型和硬件内存架构之间的桥接

Java内存模型与硬件内存架构之间存在差别。硬件内存架构没有区分线程栈和堆。对于硬件,全部的线程栈和堆都分布在主内存中。部分线程栈和堆可能有时候会出如今CPU缓存中和CPU内部的寄存器中。以下图所示: 优化

  • 从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:
  • 线程之间的共享变量存储在主内存(Main Memory)中
  • 每一个线程都有一个私有的本地内存(Local Memory),本地内存是JMM的一个抽象概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其余的硬件和编译器优化。本地内存中存储了该线程以读/写共享变量的拷贝副本。
  • 从更低的层次来讲,主内存就是硬件的内存,而为了获取更好的运行速度,虚拟机及硬件系统可能会让工做内存优先存储于寄存器和高速缓存中。
  • Java内存模型中的线程的工做内存(working memory)是cpu的寄存器和高速缓存的抽象描述。而JVM的静态内存储模型(JVM内存模型)只是一种对内存的物理划分而已,它只局限在内存,并且只局限在JVM的内存。

JMM模型下的线程间通讯:

线程间通讯必需要通过主内存。 以下,若是线程A与线程B之间要通讯的话,必需要经历下面2个步骤:

1)线程A把本地内存A中更新过的共享变量刷新到主内存中去。

2)线程B到主内存中去读取线程A以前已更新过的共享变量。

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

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

Java内存模型还规定了在执行上述八种基本操做时,必须知足以下规则:

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

Java内存模型解决的问题

当对象和变量被存放在计算机中各类不一样的内存区域中时,就可能会出现一些具体的问题。Java内存模型创建所围绕的问题:在多线程并发过程当中,如何处理多线程读同步问题与可见性(多线程缓存与指令重排序)、多线程写同步问题与原子性(多线程竞争race condition)。

一、多线程读同步与可见性

可见性(共享对象可见性):线程对共享变量修改的可见性。当一个线程修改了共享变量的值,其余线程可以马上得知这个修改

二、线程缓存致使的可见性问题:

若是两个或者更多的线程在没有正确的使用volatile声明或者同步的状况下共享一个对象,一个线程更新这个共享对象可能对其它线程来讲是不可见的:共享对象被初始化在主存中。跑在CPU上的一个线程将这个共享对象读到CPU缓存中,而后修改了这个对象。只要CPU缓存没有被刷新会主存,对象修改后的版本对跑在其它CPU上的线程都是不可见的。这种方式可能致使每一个线程拥有这个共享对象的私有拷贝,每一个拷贝停留在不一样的CPU缓存中。

下图示意了这种情形。跑在左边CPU的线程拷贝这个共享对象到它的CPU缓存中,而后将count变量的值修改成2。这个修改对跑在右边CPU上的其它线程是不可见的,由于修改后的count的值尚未被刷新回主存中去。

解决这个内存可见性问题你可使用:

  • Java中的volatile关键字:volatile关键字能够保证直接从主存中读取一个变量,若是这个变量被修改后,老是会被写回到主存中去。Java内存模型是经过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存做为传递媒介的方式来实现可见性的,不管是普通变量仍是volatile变量都是如此,普通变量与volatile变量的区别是:volatile的特殊规则保证了新值能当即同步到主内存,以及每一个线程在每次使用volatile变量前都当即从主内存刷新。所以咱们能够说volatile保证了多线程操做时变量的可见性,而普通变量则不能保证这一点,虽然它最终也会同步到主存,但这个时候可能有其余线程使用的旧的“脏数据”。
  • Java中的synchronized关键字:同步块的可见性是由“若是对一个变量执行lock操做,将会清空工做内存中此变量的值,在执行引擎使用这个变量前须要从新执行load或assign操做初始化变量的值”、“对一个变量执行unlock操做以前,必须先把此变量同步回主内存中(执行store和write操做)”这两条规则得到的。
  • Java中的final关键字:final关键字的可见性是指,被final修饰的字段在构造器中一旦被初始化完成,而且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其余线程有可能经过这个引用访问到“初始化了一半”的对象),那么在其余线程就能看见final字段的值(无须同步)

三、重排序致使的可见性问题:

Java程序中自然的有序性能够总结为一句话:若是在本地线程内观察,全部操做都是有序的(“线程内表现为串行”(Within-Thread As-If-Serial Semantics));若是在一个线程中观察另外一个线程,全部操做都是无序的(“指令重排序”现象和“线程工做内存与主内存同步延迟”现象)。

Java语言提供了volatile和synchronized两个关键字来保证线程之间操做的有序性:

  • volatile关键字自己就包含了禁止指令重排序的语义
  • synchronized则是由“一个变量在同一个时刻只容许一条线程对其进行lock操做”这条规则得到的,这个规则决定了持有同一个锁的两个同步块只能串行地进入

指令序列的重排序:

1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,能够从新安排语句的执行顺序。

2)指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-LevelParallelism,ILP)来将多条指令重叠执行。若是不存在数据依赖性,处理器能够改变语句对应机器指令的执行顺序。

3)内存系统的重排序。因为处理器使用缓存和读/写缓冲区,这使得加载和存储操做看上去多是在乱序执行。

每一个处理器上的写缓冲区,仅仅对它所在的处理器可见。这会致使处理器执行内存操做的顺序可能会与内存实际的操做执行顺序不一致。因为现代的处理器都会使用写缓冲区,所以现代的处理器都会容许对写-读操做进行重排序:

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

指令重排序对内存可见性的影响

当1和2之间没有数据依赖关系时,1和2之间就可能被重排序(3和4相似)。这样的结果就是:读线程B执行4时,不必定能看到写线程A在执行1时对共享变量的修改。

指令重排序改变多线程程序的执行结果例子:

flag变量是个标记,用来标识变量a是否已被写入。这里假设有两个线程A和B,A首先执行writer()方法,随后B线程接着执行reader()方法。线程B在执行操做4时,可否看到线程A在操做1对共享变量a的写入呢?

答案是:不必定能看到。

因为操做1和操做2没有数据依赖关系,编译器和处理器能够对这两个操做重排序;一样,操做3和操做4没有数据依赖关系,编译器和处理器也能够对这两个操做重排序。

as-if-serial语义:

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

happens before:

从JDK 5开始,Java使用新的JSR-133内存模型,JSR-133使用happens-before的概念来阐述操做之间的内存可见性:在JMM中,若是一个操做执行的结果须要对另外一个操做可见(两个操做既能够是在一个线程以内,也能够是在不一样线程之间),那么这两个操做之间必需要存在happens-before关系:

  • 程序顺序规则:一个线程中的每一个操做,happens-before于该线程中的任意后续操做。
  • 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
  • volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
  • 传递性:若是A happens-before B,且B happens-before C,那么A happens-before C。

一个happens-before规则对应于一个或多个编译器和处理器重排序规则

内存屏障禁止特定类型的处理器重排序:

重排序可能会致使多线程程序出现内存可见性问题。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)指令,经过内存屏障指令来禁止特定类型的处理器重排序。经过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。

StoreLoad Barriers是一个“全能型”的屏障,它同时具备其余3个屏障的效果。现代的多处理器大多支持该屏障(其余类型的屏障不必定被全部处理器支持)。执行该屏障开销会很昂贵,由于当前处理器一般要把写缓冲区中的数据所有刷新到内存中(Buffer Fully Flush)。

多线程写同步与原子性

多线程竞争(Race Conditions)问题:当读,写和检查共享变量时出现race conditions。

若是两个或者更多的线程共享一个对象,多个线程在这个共享对象上更新变量,就有可能发生race conditions。

想象一下,若是线程A读一个共享对象的变量count到它的CPU缓存中。再想象一下,线程B也作了一样的事情,可是往一个不一样的CPU缓存中。如今线程A将count加1,线程B也作了一样的事情。如今count已经被增长了两次,每一个CPU缓存中一次。若是这些增长操做被顺序的执行,变量count应该被增长两次,而后原值+2被写回到主存中去。然而,两次增长都是在没有适当的同步下并发执行的。不管是线程A仍是线程B将count修改后的版本写回到主存中取,修改后的值仅会被原值大1,尽管增长了两次:

解决这个问题可使用Java同步块。一个同步块能够保证在同一时刻仅有一个线程能够进入代码的临界区。同步块还能够保证代码块中全部被访问的变量将会从主存中读入,当线程退出同步代码块时,全部被更新的变量都会被刷新回主存中去,无论这个变量是否被声明为volatile。

使用原子性保证多线程写同步问题:

原子性:指一个操做是按原子的方式执行的。要么该操做不被执行;要么以原子方式执行,即执行过程当中不会被其它线程中断。

实现原子性:

  • 由Java内存模型来直接保证的原子性变量操做包括read、load、assign、use、store、write,咱们大体能够认为基本数据类型变量、引用类型变量、声明为volatile的任何类型变量的访问读写是具有原子性的(long和double的非原子性协定:对于64位的数据,如long和double,Java内存模型规范容许虚拟机将没有被volatile修饰的64位数据的读写操做划分为两次32位的操做来进行,即容许虚拟机实现选择能够不保证64位数据类型的load、store、read和write这四个操做的原子性,即若是有多个线程共享一个并未声明为volatile的long或double类型的变量,而且同时对它们进行读取和修改操做,那么某些线程可能会读取到一个既非原值,也不是其余线程修改值的表明了“半个变量”的数值。但因为目前各类平台下的商用虚拟机几乎都选择把64位数据的读写操做做为原子操做来对待,所以在编写代码时通常也不须要将用到的long和double变量专门声明为volatile)。这些类型变量的读、写自然具备原子性,但相似于 “基本变量++” / “volatile++” 这种复合操做并无原子性。
  • 若是应用场景须要一个更大范围的原子性保证,须要使用同步块技术。Java内存模型提供了lock和unlock操做来知足这种需求。虚拟机提供了字节码指令monitorenter和monitorexist来隐式地使用这两个操做,这两个字节码指令反映到Java代码中就是同步快——synchronized关键字。

JMM对特殊Java语义的特殊规则支持

volatile总结 (保证内存可见性:Lock前缀的指令、内存屏障禁止重排序)

synchronized总结 (保证内存可见性和操做原子性:互斥锁;锁优化)

参考资料:Java内存模型 《Java并发编程的艺术》 《深刻理解Java内存模型》 《深刻理解Java虚拟机》

相关文章
相关标签/搜索