Java 内存模型与volatile特性深入分析

​Java Memory Model操作规则及特性,以及JMM中volatile的特殊规则

2018年拍摄于京都智积院,千利休最喜欢的庭院之一。

微信公众号

王皓的GitHub:https://github.com/TenaciousDWang

 

这一回主要讲讲Java的内存模型JMM(Java Memory Model)及其特性和规则以及volatile关键字相对于JMM的特殊规则,这个关键字平时虽然用的很少,但是却很重要。需要注意本回说的JMM对内存的划分概念与Java堆栈方法区对内存的划分不在一个维度上他们之间基本上是没有关系的,为了避免混淆,这里先说明一下。

 

现在我们计算机的性能越来越强,CPU的处理速度非常快,我现在主力机使用一颗AMD 2700,已经8核16线程,默频小超至4.0GHz稳定使用(之前一直Intel,瞧不起AMD,现在AMD真香警告),大家可能发现其实CPU不用太好,只需要换一个固态,速度立马就提升一大截,其实操作系统并不怎么吃CPU,我的NAS使用一颗IntelG4620,装上固态一样可以起飞,老的笔记本台机换上固态也能第二春,其实我们说电脑慢大部分原因主要是因为时间浪费在IO操作上,也就是数据在存储设备上的读写,CPU的计算能力和数据处理都是纳秒级别,也许说的有些不严谨,但是基本上是这个意思,CPU处理能里也是有高有底,这里不作为因素来说明。

 

CPU从存储设备读取数据运算,运算结束后写入存储设备,一进一出,会花费大量的时间,于是有了内存,内存的速度比现在的机械与固态硬盘快了好几个数量级,我们把一部分数据放入内存缓存供CPU运算读写,速度提升了起来,但是CPU运算速度实在太快了,人们为了进一步压榨CPU的运算能力,又创造了一种能够基本跟上CPU速度的高速缓存,介于CPU与内存之间,其实这个高速缓存直接封装到了CPU里与核心在一起,告诉缓存是分级的,这里以我的AMD 2700举例,其共有三级缓存。

 

L1 Cache(一级缓存)。集成在CPU内部中,用于CPU在处理数据过程中数据的暂时保存。由于缓存指令和数据与CPU同频工作,L1级高速缓存缓存的容量越大,存储信息越多,可减少CPU与内存之间的数据交换次数,提高CPU的运算效率。但因高速缓冲存储器均由静态RAM组成,结构较复杂,在有限的CPU芯片面积上,L1级高速缓存的容量不可能做得太大。

 

L2 Cache(二级缓存)是CPU的第二层高速缓存,分内部和外部两种芯片。内部的芯片二级缓存运行速度与主频相同,而外部的二级缓存则只有主频的一半。L2高速缓存容量也会影响CPU的性能,原则是越大越好,现在家庭用CPU容量最大的是4MB,而服务器和工作站上用CPU的L2高速缓存普遍大于4MB,有的高达8MB或者19MB。二级缓存是CPU性能表现的关键之一,在CPU核心不变化的情况下,增加二级缓存容量能使性能大幅度提高。而同一核心的CPU高低端之分往往也是在二级缓存上有差异,由此可见二级缓存对于CPU的重要性。

 

L3 Cache(三级缓存)是为读取二级缓存后未命中的数据设计的—种缓存,在拥有三级缓存的CPU中,只有约5%的数据需要从内存中调用,这进一步提高了CPU的效率。

 

每一个型号CPU核心架构是不一样的,不同的CPU为了特定性能都有着不同的解决方案,好像跑题了......总之有个高速缓存这个玩意,很好的解决了CPU与内存在速度上的矛盾。

 

这样就可以起飞了吗?对不起还不能,虽然加了高速缓存,但是结构更加复杂了。

 

 

当多个逻辑处理器的运算任务都涉及到同一块主内存区域时,可能导致缓存数据不一致,那么将这些高速缓存同步回主内存时,如何保证数据的一致性呢。为了解决这个缓存一致性问题,所有高速缓存都需要遵循一些协议,在读写时需要根据协议来操作,这类协议有MSI,MESI,MOSI,其他的大家可以自行百度,这里说一个最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

 

接下来我们来说JMM,JMM的主要目标是定义程序中各个变量的访问规则,就是向内存中存取变量的规则,这里变量指的共享变量,线程私有变量不会被共享,所以不会存在竞争问题。

 

前面花了很多篇幅说了如何解决CPU与内存交互效率的问题,中间添加了高速缓存来提高IO速度,在JMM中,JVM虚拟机的开发团队在主内存与Java线程相当于处理器之间加入了工作内存。这里我们可以简单的吧主内存当做Java的堆,里面存储了实例变量,静态变量等,工作内存可以简单当做Java的栈,虽然这样对应不太对,但是便于理解,一般情况下系统硬件或JVM虚拟机会将工作内存放入到CPU的高速缓存或寄存器中以优化速度,因为程序访问内存主要是访问的工作内存,处理完成后再又工作内存写入主内存。

 

 

之前我们提到过缓存一致性问题,那么这个问题JVM是如何解决的呢?首先我们来看一下工作内存与主内存交互操作的实现细节,根据周志明《深入理解JVM虚拟机》中所说目前JVM文档中已经放弃对于工作内存与主内存交互的八种基本原子性操作的描述,但是JMM并没有改变,为了便于理解,我们还是根据这八种基本操作为基础来说一下规则。首先看一下八种基本原子性操作的定义。

 

lock锁定,在主内存中锁定一个变量为线程独占。

 

unlock解锁,在主内存中,释放一个锁定变量上的锁,使其可以被其他线程锁定。

 

read读取,在主内存中,读取变量值到工作内存中。

 

load载入,在工作内存中,把read操作从主内存中读取的值,放入到工作内存中的副本中。

 

use使用,在工作内存中,把一个变量值传递给执行引擎,JVM中遇到使用变量的字节码执行时使用。

 

aasign赋值,在工作内存中,把从执行引擎接收的值赋给工作变量中,JVM中遇到给变量赋值的字节码指令时调用。

 

store存储,与read相反,这个是在工作变量中把内存的值转送到主内存。

 

write写入,在主内存中把store传来的值写入主内存中。

 

接下来我们来看一下JVM定义的规则是如何来保证多线程下内存访问是安全的。

 

1、变量从主内存中复制到工作内存,需要顺序执行read与load操作,反之必须顺序执行store与write操作,要保证按顺序但是不用保证连续执行例如a,b两个变量,顺序可以是read a,read b,load b,load a。

 

2、不允许read、load、store、write里面某个操作单独出现,read了就一定要load,store了就一定要write。

 

3、不允许线程放弃assign操作,赋值操作必须执行且比如同步到主内存。

 

4、不允许在无assign操作的情况下把工作内存的值同步到主内存中。

 

5、工作内存中的变量必须执行load与assign后才能执行use与store。

 

6、一个变量允许一个线程lock,可以重复操作但是unlock时要执行同样次数才能解锁变量。

 

7、如果一个线程执行lock操作,所有工作内存中该变量的值清空,必须从主内存中重新load或assign新的值。

 

8、unlock前该线程必须执行过lock,不允许unlock其他线程的lock。

 

9、在执行unlock前必须把该变量从工作内存中同步到主内存中。

 

在说完了JMM对于内存交互操作的规则之后,我们接下来开始说volatile关键字,volatile用来修饰变量,该类型的变量具备一些特殊的访问规则,总共有两种特性。

 

第一种,保证此变量对多有线程可见,我们之前讲过当一个线程对一个变量进行操作时,属于黑盒操作,对于其他线程不可见,只对当前持有锁的线程可见,这里被volatile定义之后,则对所有变量可见,当该变量被修改时,新的值对于其他线程是可以立刻得知的,普通的变量是做不到这一点的。

 

volatile解决的是多线程共享变量的可见性问题,类似于synchronized,但是不具备其互斥性,所以说对volatile变量的操作并非都是原子性,所以说它并不是多线程安全的,读取没问题,但是多写场景必定出现线程安全问题。

 

volatile只能保证可见性,如果不符合一下场景,则必须使用synchronized与JUC里的Lock或者atomic类来保证原子性和线程安全,atomic类可以保证基本数据类型的自增和自减,加法与减法属于原子性操作,其实就是保证读取变量的原始值、进行操作、写入工作内存这三个过程处于一个不可分割的操作,避免多线程情况下由于上述三步分开导致执行结果与预期值不符的情况。

 

1、运算结果并不依赖volatile变量的当前值,或者确保单线程操作。

 

2、volatile变量不需要与其他的状态变量共同参与不变约束。

 

下面的场景非常适合volatile的使用,其他线程可见的情况下,可以停止所有线程,这是多读场景。

 

 

第二种,volatile关键字能禁止指令重排序,所以volatile能在一定程度上保证有序性。首先我们先看一下什么是指令排序。

 

指令1把变量A的值增加10,指令2把变量A的值乘5,指令三把变量B的值加8,这里面指令1与2之间是有依赖的他们之间不能重排,因为(A+10)*5与A*5+10显然是不对等的,但是指令3可以放到指令1与指令2中间,或其他位置。

 

指令排序主要还是编译器以及CPU为了优化代码或者执行的效率而执行的优化操作;应用条件是单线程场景下,对于并发多线程场景下,指令重排会产生不确定的执行效果。如何禁用指令重排呢,这里我们就用到了volatile关键字来设置内存屏障阻止关于此变量的指令重排。

 

volatile关键字禁止指令重排序有两层意思:

 

  1、当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行。

 

  2、在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。

 

举个例子就明白了。

 

 

由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会讲语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。

 

并且volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。

 

对volatile型flag变量做操作时,字节码指令里多执行了一个“lock addI $ 0x0, (%esp)”,这个操作就是一个内存屏障(Memory Barrier)。

 

最后我们总结一下JMM中volatile的特殊规则。

 

1、工作内存中,每次使用volatile型变量都必须从主内存内重刷新值,用于保证当前线程对其他线程修改后的值可见。

 

2、工作内存中,每次修改volatile型变量都必须立刻同步写入主内存,以保证其他线程对于当前线程修改的可见行。

 

3、A对volatile型变量a执行use,F对volatile型变量a执行与A动作关联load,P对volatile型变量a执行与F动作相关联的read,B对volatile型变量b执行use,G对volatile型变量b执行与B动作关联load,Q对volatile型变量b执行与G动作相关联的read,如果A先于B,那么P肯定先于Q,基于禁止指令重排特性。

 

根据上面各类规则,最后我们来看一下JMM模型的整体特征。

 

1、原子性(Atomicity)

 

由JMM直接保证的原子性操作有read、load、assign、use、store、write,这些对基本数据类型的访问读写是具备原子性的long与double的特殊非原子性可以忽略现在的商用JVM不会发生,非复合操作可以保证原子性,复合操作JVM使用lock与unlock来保证内存操作的原子性,这两个操作没有开放给用户使用,但是提供了monitorenter与monitorexit这两个字节码指令,对应的就是synchronized,所以我们可以使用synchronized来实现原子性操作。

 

2、可见性(Visibility)

 

当一个线程修改了共享变量的值,其他线程能够立刻得知,这里主要使用了volatile的特殊规则,上面已经说过原理了,在此不再赘述。同步代码块也可以实现可见性,利用了unlock操作之前,必须把值同步回主内存中实现可见。final也可以实现可见性,final型变量初始化完成就可以被其他线程看见。

 

3、有序性(ordering)

 

在Java中,如果在本线程内观察,所有操作都是有序的,在一个线程中观察其他线程所有操作都是无序的,前半句指“线程内表现为串行的语意”(Within-Thread As-If-Serial Semantics),后面句指指令重排与工作内存与主内存同步规则。

 

Java提供了volatile与synchronized来保证线程之间的有序性,volatile本身具备禁止指令重排特性,synchronized是一个同步块可以保证一个共享变量同时只能一个线程访问,可以保证线程间的有序性。

 

除此之外,Java语言还有一个“先行发生”(hanppen-before)原则,这个是天然的无需任何同步器协助就已经存在的有序性,如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序,以下这8条原则摘自《深入理解Java虚拟机》。

 

1、程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作,准确来说是控制流程顺序,还要考虑分支和循环操作。

 

2、锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作。

 

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

 

4、传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C。

 

5、线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作。

 

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

 

7、线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行。

 

8、对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始。

 

以上就是关于JMM与JMM操作规则和特性。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章