JVM之JMM
Posted 米么骚客
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JVM之JMM相关的知识,希望对你有一定的参考价值。
JVM之JMM
祝你岁月无波澜,愿我余生不悲观
什么是JMM,为什么要了解JMM
Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。
了解JMM的有助于对Java并发的工作机制更深入的理解,特别是理解synchronized和volatile的语义。
JMM解决了什么问题
基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是引入了一个新的问题:缓存一致性(Cache Coherence)。在多处理器系统中,每个处理器都有自己的高速缓存,而他们又共享同一主存,当多个处理器的运算任务都涉及同一块住内存区域时,将可能导致各自的数据缓存不一致,为了解决一致性问题,需要各个处理器访问缓存时都遵循一些协议。如下图所示:
除此之外,为了使得处理器内部的运算单元能尽可能被充分利用,处理器可能会对输入代码进行乱序执行(Out-Of-Order Execution)优化,处理器会在计算之后将对乱序执行的代码进行结果重组,保证结果准确性。与处理器的乱序执行优化类似,Java虚拟机的即时编译器中也有类似的指令重排序(Instruction Recorder)优化。
JMM的目的就是解决上述提到的由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。保证并发编程场景中的原子性、可见性、有序性。
JMM的运行时内存结构
Java 内存模型规定了所有的变量都存储在主内存中。每条线程还有自己的工作内存,线程的工作内存中保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取,赋值等)都必须在工作内存中进行,而不能直接读写住内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程,主内存,工作内存三者的交互如图所示:
注:线程之间的通信
线程的通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:
消息传递:在java中典型的消息传递方式就是 wait() 和 notify()
共享内存:通过共享对象进行通信
从上图来看,线程A与线程B之间如要通信的话,必须要经历下面2个步骤:
1. 线程A把本地内存A中更新过的共享变量既刷新到主内存中去。
2. 线程B到主内存中去读取线程A之前已更新过的共享变量。
从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。
JMM上的操作
Java 内存模型对主内存与工作内存之间的具体交互协议定义了八种操作,具体如下:
lock(锁定):作用于主内存变量,把一个变量标识为一条线程独占状态。
unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
read(读取):作用于主内存变量,把一个变量从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
load(载入):作用于工作内存变量,把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。
use(使用):作用于工作内存变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量值的字节码指令时执行此操作。
assign(赋值):作用于工作内存变量,把一个从执行引擎接收的值赋值给工作内存的变量,每当虚拟机遇到一个需要给变量进行赋值的字节码指令时执行此操作。
store(存储):作用于工作内存变量,把工作内存中一个变量的值传递到主内存中,以便后续 write 操作。
write(写入):作用于主内存变量,把 store 操作从工作内存中得到的值放入主内存变量中。
如果要把一个变量从主内存复制到工作内存,那就要顺序地执行read和load操作,如果要把变量从工作内存同步回主内存,就要顺序的执行store和write操作。注意,java内存模型只要求上述两个操作必须按顺序执行,而没有保证是连续执行。所以实质执行中 read 和 load 间及 store 和 write 间是可以插入其他指令的。
原子性,可见性与有序性
介绍完Java内存模型的相关操作,我们再回顾一下这个模型的特征。Java内存模型是围绕着在并发过程中如果处理原子性,可见性和有序性这3个特征来建立的。
原子性
原子性:即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作(long和double除外),即这些操作是不可被中断的,要么执行,要么不执行。
下面请看一个例子,分析以下哪些操作是原子性操作:
x = 10; //语句1
y = x; //语句2
x++; //语句3
x = x + 1; //语句4
咋一看,有些朋友可能会认为上面的4个语句中的操作都是原子性操作。其实只有语句1是原子性操作,其他三个语句都不是原子性操作。
语句1是直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中。语句2实际上包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及将x的值写入工作内存这2个操作都是原子性操作,但是合起来就不是原子性操作了。同样的,x++和 x = x+1包括3个操作:读取x的值,进行加1操作,写入新的值。所以上面4个语句只有语句1的操作具备原子性。
也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。
不过这里有一点需要注意:在32位平台下,对64位数据的读取和赋值是需要通过两个操作来完成的,不能保证其原子性。虽然JVM规范并没有要求long,double的读取操作必须是原子的,但其实大部分JVM的厂商都间接的保证了long和double的读取和写入操作的原子性。
从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。
可见性
可见性:可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
对于可见性,Java提供了volatile关键字来保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
有序性
有序性:即程序执行的顺序按照代码的先后顺序执行,这里并不是说按代码书写的先后顺序,有可能有分支,既按照流程的先后顺序。
在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
在Java里面,可以通过volatile关键字来保证一定的“有序性”,另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
另外,Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
Happen-before原则
我们无法就所有场景来规定某个线程修改的变量何时对其他线程可见,但是我们可以指定某些规则,这规则就是happens-before,从JDK 5 开始,JMM就使用happens-before的概念来阐述多线程之间的内存可见性。
在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。
happens-before原则非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们解决在并发环境下两操作之间是否可能存在冲突的所有问题。下面我们就一个简单的例子稍微了解下happens-before:
int i =1; //线程A中执行
Int j = i; //线程B中执行
i = 2; //线程C中执行
j 是否等于1呢?假定线程A的操作(i = 1)happens-before线程B的操作(j = i),那么可以确定线程B执行后j = 1 一定成立,得出此结论的依据有两个:一是根据happens-before原则,“i=1”的结果可以被观察到;二是线程C还没有执行,线程A结束后没有其他的线程会修改变量i的值。
现在我们依然保持线程A和线程B的happens-before关系,而线程C出现在线程A和线程B之间,但是线程C与线程B没有先行发生关系,那么J的值回事多少呢,答案并不确定。
happens-before原则定义如下:
1.如果一个操作happens-before于另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
2.两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法。
happens-before原则规则:
程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准备地说,应该是控制流顺序而不是程序代码顺便,因为要考虑分支,循环等机构。
管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序。
volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后顺序。
线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作。
线程终止规则:线程中的所有操作都先行发生于对此线程的终止监测,我们可以通过Thread.join()方法结束、Thread.interrupted()方法检测到是否有中断发生。
线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码监测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得到操作A先行发生于操作C的结论。
JMM对synchronized和volatile的支持
synchronized
锁实现了对临界资源的相互排斥訪问,被synchronized修饰的代码仅仅有一条线程能够通过,是严格的排它锁、相互排斥锁。
没有获得相应锁对象监视器(monitor)的线程会进入等待队列。不论什么线程必须获得monitor的全部权才能够进入同步块,退出同步快或者遇到异常都要释放全部权,JVM规范通过两个内存屏障(memory barrier)命令来实现排它逻辑。内存屏障能够理解成顺序运行的一组CPU指令,全然无视指令重排序。
我们现在来看一看Synchronized的happens-before规则,即监视器锁规则:对同一个监视器的解锁,happens-before于对该监视器的加锁。
该代码的happens-before关系如图所示:
在图中每一个箭头连接的两个节点就代表之间的happens-before关系,黑色的是通过程序顺序规则推导出来,蓝色的为监视器锁规则推导而出:线程A释放锁happens-before线程B加锁,红色的则是通过程序顺序规则和监视器锁规则推测出来happens-before关系,通过传递性规则进一步推导的happens-before关系。现在我们来重点关注2 happens-before 5,通过这个关系我们可以得出什么?
根据happens-before的定义中的一条:如果A happens-before B,则A的执行结果对B可见,并且A的执行顺序先于B。线程A先对共享变量A进行加一,由2 happens-before 5关系可知线程A的执行结果对线程B可见即线程B所读取到的x的值为2。
volatile
volatile是线程同步的轻量级实现,所以volatile性能要比synchronized要好,并且volatile只能修饰于变量,synchronized可以修饰方法,以及代码块。
volatile变量具有2种特性:
1.保证变量的可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入,这个新值对于其他线程来说是立即可见的。
2.屏蔽指令重排序:指令重排序是编译器和处理器为了高效对程序进行优化的手段,下文有详细的分析。
volatile的可见性
先看一段代码,来分析一下volatile的可见性
运行结果:
当线程更改了flag变量的值之后,但是还没来得及写入主存当中,线程转去做其他事情了,那么主程由于不知道线程flag变量的更改,因此不会打印出flag改变的日志。
用volatile修饰之后就变得不一样了,使用volatile关键字会强制将修改的值立即写入主存;使用volatile关键字的话,当线程进行修改时,会导致主程的工作内存中缓存变量flag的缓存行无效;由于主程的工作内存中缓存变量flag的缓存行无效,所以主程再次读取变量flag的值时会去主存读取。
请看修改后的程序:
运行结果:
volatile的非原子性
从上面知道volatile关键字保证了内存的可见性,但是volatile能保证对变量的操作是原子性吗?
下面我们来看一个简单的例子:
运行结果:
从运行的结果看volatile并非线程安全的,我们来分析一下出现非线程安全的原因。
1. read和load阶段,从主存复制变量到当前线程工作内存;
2. use和assign阶段,执行代码,改变共享变量值;
3. store和write阶段,用工作内存数据刷新主存对应变量的值
在多线程环境中,use和assign是多次出现的,但是这一操作并不是原子性,也就是在read和load之后,如果主内存count变量发生修改之后,线程工作内存中的值由于已经加载,不会产生对应的变化,也就是私有内存和公共内存中的变量不同步,所有出现了非线程安全问题。
解决这个问题的一种办法就是使用synchronized关键字
如图所示:
运行结果:
• end •
文青霞
享米java开发工程师,程序媛一枚
以上是关于JVM之JMM的主要内容,如果未能解决你的问题,请参考以下文章
JVM技术专题深入研究JMM的实现原理之Happens-Before原则和As-If-Serial语义「入门篇」