Java虚拟机--内存模型

  内存模型同并发息息相关,熟悉内存模型将对虚拟机、多线程及线程安全问题有更深入的了解。

1.什么是内存模型?

  给出定义之前,让我们先来了解一下物理计算机中的并发问题。我们都知道,处理器运行时必然要和内存交互,而且这个I/O操作是很难消除的,但由于计算机存储设备和处理器的运算速度有几个数量级的差距,所以在两者之间加入了一层读写速度尽可能接近处理器运算速度的高速缓存,这样处理器就不用等待缓慢的内存读写了。但是这样引出了一个新的问题:缓存一致性。如图:

             

  当多个处理器运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。为了解决一致性问题,需要各个处理器访问缓存时都要遵守一些协议,在读写时要根据协议来操作。所以,内存模型可以理解为:在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象

2.什么是Java内存模型?

  Java内存模型即JMM(Java Memory Model),它的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节,这里的变量指的是共享变量(如实例字段、静态字段和构成数组对象的元素,不包括局部变量与方法参数,因为是线程私有);在并发编程中,JMM决定一个线程对共享变量的写入何时对另外一个线程可见。JMM规定了所有的变量都存储在主内存中,每个线程都有自己的工作内存,线程对变量的所有操作都必须在工作内存中进行

                   

  注:此处主内存和结构中的堆、栈等是不同层次上的划分,两者基本没有关系;如果硬要扯上关系,可以理解为主内存主要对应于Java堆中对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。从更低层次上说,主内存直接对应于物理内存,为了获取更好的运行速度,虚拟机可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问读写的是工作内存。

3.内存间的交互操作:

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

    

  如果要把一个变量从主内存复制到工作内存,那就要顺序地执行read和load操作,反之,就要顺序地执行store和write操作。注:Java内存模型只要求上述两个操作必须顺序执行,没有说必须连续执行。除此之外,JMM还规定了在执行上述8种基本操作时必须满足如下规则:

  (1).不允许read和load、store和write操作之一单独出现。

  (2).不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后,必须把该变化同步回主内存。

  (3).不允许一个线程无原因地把数据从工作内存同步回主内存。

  (4).一个新的变量只能诞生在主内存中,即use、store之前一定要先assign、load。

  (5).一个变量同一时刻只允许一条线程对其lock,但lock操作可以被同一线程重复多次,然后只有执行同样次数的unlock才会被解锁。

  (6).如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需重新load或assign,初始化变量的值。

  (7).如果一个变量事先没有被lock,那将不被允许unlock,也不允许去unlock一个被其他线程锁定住的变量。

  (8).对一个变量unlock前,必须把变量同步回主内存中。

  注:JMM要求这8个操作都具有原子性,但对于64位数据的long和double类型来说,允许划分为两次的32位的操作来进行。多线程环境下,理论上可能有取到半个变量值的可能性,不过不用担心,目前商用Java虚拟机允许把这些操作视为原子性操作,所以不用担心这种情况的出现。

  总结:以上就是处理器、工作内存、主内存之间变量交互的操作;边读边理解,脑海里有一副交互图,再来读这几个命令,就容易记住了。

4.JMM--并发编程模型的两个问题

  上面我们清楚了共享变量读出内存和写入内存的交互操作是怎样的一个流程,把它想象成单线程的一条线的操作,就比较好理解。BUT,我们想过没有,如果是并发环境下呢?会产生什么问题?

  问题(1):线程之间如何通信? 问题(2).线程之间如何同步?

  在命令式编程中,线程通信机制有两种:共享内存消息传递。Java的并发采用的是共享内存模型在此模型里,线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信,整个过程对程序员是完全透明的;同步是指程序中用于控制不同线程间操作发生相对顺序的机制,在共享模型里,同步是显式进行的,即程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行

5.JMM--抽象结构

  从抽象角度看,JMM定义了线程和主内存之间的抽象关系:每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的抽象概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。

            

  从图上来看,如果线程A和线程B要进行通信,必须经历两个步骤:

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

    (2).线程B到主内存中读取线程A已经更新过的共享变量。

  从整体上看,实质是线程A向线程B发送消息,JMM就是控制主内存与每个线程本地内存间的交互,来提供内存可见性保证。

6.JMM--原子性、可见性、有序性

  介绍完JMM的相关操作和规则,再来总结一下JMM的特征。JMM是围绕着在并发中如何处理原子性可见性有序性这三个特征来建立的。

  原子性:由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write,我们大致可以认为基本数据类型的访问读写是具备原子性的(64位的long和double的非原子协议,知道就可,几乎不会发生)。如需要更大范围的原子性保证,可用同步块--synchronized关键字。

  可见性:是指当一个线程修改了共享变量的值,其他线程可以立即得知这个修改。volatile关键字可以保证多线程操作时变量的可见性,而普通变量不能。除此之外,synchronized和final也保证了可见性。

  有序性:Java程序中天然的有序性可以总结为一句话:“如果在本线程内观察,所有的操作都是有序的;如果一个线程观察另一个线程,所有的操作都是无序的”。前半句指“线程内表现为串行的语义”,后半句指“指令重排序现象”和“工作内存和主内存同步延迟现象”。Java提供两个关键字来保证线程间操作的有序性:volatilesynchronized。前者本身就包含了指令重排序的语义;后者则是由“一个变量在同一时刻只允许一条线程对其进行lock操作”这条规则来实现,决定了持有同一个锁的两个同步块只能串行执行。

7.happens-before(先行发生原则)

  Java语言中有一个“先行发生”原则,这个原则非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据。那happens-before指什么呢?让我们来看一下:

  happens-before是Java内存模型中定义的两项操作之间的偏序关系,即操作A发生在操作B之前,操作A产生的影响内被操作B观察到,“影响”包括改变共享变变量值、发送消息、调用方法等。让我们看下示例:

        

  如果A操作先与操作B发生,变量j一定等于1,原因:根据happens-before原则,A的改变可以被B观察到;C还没有被执行。现在来考虑C操作,A还是先于B发生,但C出现在A和B中间,但是C和B没有先行发生关系,那j会是多少?1还是2?答案不确定,因为C操作对i的改变,可能会被B观察到,也可能不会,所以不具备线程安全性。Java内存模型存在一些天然的happens-before关系:

  程序次序规则:在一个线程内,按照代码程序顺序,书写在前面的操作先发生于书写在后面的操作。准确的说,应该是控制流顺序而不是代码顺序,因为要考虑分支、循环等结构。

  管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。“后面”指时间上的先后顺序。

  volatile变量规则对一个volatile变量的写操作先发生于后面对这个变量的读操作。

  线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作。“后面”指时间上的先后顺序。

  线程终止规则:线程中所有操作都先发生于此线程的终止检测。可以通过Thread.join()方法结束、Thread.isAlive()的返回值等检测线程是否终止。

  线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。

  对象终结规则:一个对象初始化完成(构造函数执行完毕)先行发生于它的finalized()方法的开始。

  传递性:A操作先行发生于B操作,B操作先于C,则A先于C。

  那如何根据这些规则来判断操作间是否具有顺序性?对于读写共享变量的操作来说,就是是否线程安全?请看如下示例:

        

  这是一组普通的getter/setter方法,假设存在线程A和线程B,线程A先(时间上的先后)调用了setValue(1),然后线程B调用了同一个对象的getValue(),那线程B收的返回值是什么?

  我们发现没有一个规则与之匹配,所以返回结果是不确定的,故线程不安全。解决办法:把getter/setter方法都定义为synchronized方法,套用管程锁定规则;定义为volatile变量,由于setter方法的修改不依赖value原值,符合只用场景由此我们得出了结论:一个操作“时间上的先发生”不代表这个操作会先行发生。同样,反之亦不成立(指令重排序)。一句话就是时间先后顺与先行发生基本没有太大关系,一切以先行发生原则为准。

8.重排序

  在执行程序时,为了提高性能编译器处理器常常会对指令做重排序。重排序分三种类型:

      

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

  (2).指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应的机器指令的执行顺序。

  (3).内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这是的加载和存储看上去可能是在乱序中执行。

  ●数据依赖性:编译器和处理器在重排序时,会遵守数据依赖性,不会改变存在数据依赖关系的两个操作的执行顺序。这里只对单个处理器的指令序列和单个线程中执行的操作有效。

  ●as-if-serial:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。as-if-serial语义把单线程保护了起来,便造成了一个幻觉:单线程程序按程序的顺序来执行的。

  ●程序顺序规则:

    double pi = 3.14;       // A                         A happens-before B   

    double r = 1.0;        // B        ====》对应三个happens-before关系:  B happens-before  C

    double area = pi * r * r;   // C                         A happens-before  C

  这里A happens-before B,但实际执行时B却可以在A之前执行。JVM不要求A一定要先于B执行,仅仅要求A的操作结果对B可见,并且A的操作顺序先于B操作。这里A不需要对B可见,且重排的结果(BAC)与之前(ABC)一致,则JMM认为这种排序并不非法,并允许。

  ●重排序对多线程的影响:在多线程中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

8.synchronized实现可见性 

        JMM关于synchronized的两条规定:

  (1)线程解锁前,必须把共享变量的最新值刷新到主内存中

  (2)线程加锁时,清空工作内存中共享变量的值,从而需要共享变量时需要从主内存中读取最新的值。

    注意:加锁与解锁需要是同一把锁,线程解锁前对共享变量的修改对其他线程不可见。

            

            

  我们先假设几种情况:执行顺序为1.1->1.2->1.3->1.4,执行结果为6.过程:先执行write()方法,变量的改变能够及时写入主内存,然后执行ready()方法,可以在主内存读取到最新的变量值;1.1->2.1->2.2->1.2,result值为3;1.2->2.1->2.2->1.1,result值为0

  导致共享变量在线程间不可见的原因:(1)线程的交叉执行  (2)重排序结合线程交叉执行  (3)共享变量更新后的值没有在工作内存与主内存及时更新  

  解决办法:在保证写线程先执行的前提下,用write、ready方法用synchronized修饰。首先阻止了线程的交叉执行,其次单线程的重排序不影响结果,最后对变量的改变可见。

9.volatile实现可见性

  volatile关键字:保证变量的可见性,不能保证变量复合操作的原子性。

  如何保证可见性?深入来说:通过加入内存屏障和禁止重排序优化来实现的。

  ●对volatile变量执行写操作时,会在写操作后加入一条store屏障指令,它会把写缓存强制刷新到主内存中去,这样主内存中就是变量的最新值,同时防止处理器把volatile前面的变量重排序到写变量之后。

  对volatile变量执行读操作时,会在写操作后加入一条load屏障指令,它会使缓存区的变量失效。

 

posted @ 2018-06-27 17:21  柠檬柚子冰糖茶  阅读(212)  评论(0编辑  收藏  举报