Java的内存区域和内存模型是不一样的东西,内存区域是指 JVM 运行时将数据分区域存储,强调对内存空间的划分。
而内存模型(Java Memory Model,简称 JMM )是定义了线程和主内存之间的抽象关系,即 JMM 定义了 JVM 在计算机内存(RAM)中的工作方式,如果我们要想深入了解Java并发编程,就要先理解好Java内存模型。
1、 由于计算机的存储设备与处理器的运算能力之间有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样一来处理器就无需等待缓慢的内存读写了。
2、基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是引入了一个新的问题:缓存一致性(Cache Coherence)。在多处理器系统中,每个处理器都有自己的高速缓存,而他们又共享同一主存。如下图所示:
多个处理器运算任务都涉及同一块主存,需要一种协议可以保障数据的一致性,这类协议有MSI、MESI、MOSI及Dragon Protocol等。Java虚拟机内存模型中定义的内存访问操作与硬件的缓存访问操作是具有可比性的。(看下面第二点介绍)
3、除此之外,为了使得处理器内部的运算单元能尽可能被充分利用,处理器可能会对输入代码进行乱序执行(Out-Of-Order Execution)优化,处理器会在计算之后将对乱序执行的代码进行结果重组,保证结果准确性。与处理器的乱序执行优化类似,Java虚拟机的即时编译器中也有类似的指令重排序(Instruction Recorder)优化。看下面第三点介绍。
因为CPU的频率太快了,快到主存跟不上,这样在处理器时钟周期内,CPU常常需要等待主存,浪费资源。CPU往往需要重复处理相同的数据、重复执行相同的指令,如果这部分数据、指令CPU能在CPU缓存中找到,CPU就不需要从内存或硬盘中再读取数据、指令,从而减少了整机的响应时间,所以cache的出现,是为了缓解CPU和内存之间速度的不匹配问题
(结构:cpu -> cache -> memory)
在程序执行的过程中就变成了:
当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,
那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,
当运算结束之后,再将高速缓存中的数据刷新到主存当中。
在Intel官网上产品-处理器界面内对缓存的定义为:CPU高速缓存是处理器上的一个快速记忆区域。英特尔智能高速缓存(SmartCache)是指可让所有内核动态共享最后一级高速缓存的架构。这里就提及到了最后一级高速缓存的概念,即为CPU缓存中的L3(三级缓存),那么我们继续来解释一下什么叫三级缓存,分别又是指哪三级缓存。
- 三级缓存(L1一级缓存、L2二级缓存、L3三级缓存)都是集成在CPU内的缓存
- 它们的作用都是作为CPU与主内存之间的高速数据缓冲区
- L1最靠近CPU核心,L2其次,L3再次
运行速度方面:L1最快、L2次快、L3最慢
容量大小方面:L1最小、L2较大、L3最大- CPU会先在最快的L1中寻找需要的数据,找不到再去找次快的L2,还找不到再去找L3,L3都没有那就只能去内存找了。
- 单核CPU只含有一套L1,L2,L3缓存;如果CPU含有多个核心,即多核CPU,则每个核心都含有一套L1(甚至和L2)缓存,而共享L3(或者和L2)缓存。
在单线程环境下,cpu核心的缓存只被一个线程访问。缓存独占,不会出现访问冲突等问题
在多线程场景下,在CPU和主存之间增加缓存,就可能存在缓存一致性问题,也就是说,在多核CPU中,每个核的自己的缓存中,关于同一个数据的缓存内容可能不一致,这也就是我们上面提到的缓存一致性的问题
从java源码到最终实际执行的指令序列,会经历下面3种重排序:
举个重排序的例子:
Java内存模型即Java Memory Model,简称JMM。JMM定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。JVM是整个计算机虚拟模型,所以JMM是隶属于JVM的。
Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。可以避免像c++等直接使用物理硬件和操作系统的内存模型在不同操作系统和硬件平台下表现不同,比如有些c/c++程序可能在windows平台运行正常,而在linux平台却运行有问题。这即使得Java程序能够 “一次编写,到处运行”。
Java内存模型是共享内存的并发模型,线程之间主要通过读-写共享变量(堆内存中的实例域,静态域和数组元素)来完成隐式通信。
Java 内存模型(JMM)控制 Java 线程之间的通信,决定一个线程对共享变量的写入何时对另一个线程可见。
主内存:Java内存模型规定了所有变量都存储在主内存(Main Memory)中(此处的主内存与介绍物理硬件的主内存名字一样,两者可以互相类比,但此处仅是虚拟机内存的一部分)。
工作内存:每条线程都有自己的工作内存(Working Memory,又称本地内存,可与前面介绍的处理器高速缓存类比),线程的工作内存中保存了该线程使用到的变量的主内存中的共享变量的副本拷贝。工作内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。
从上图来看,线程A与线程B之间如要通信的话,必须要经历下面2个步骤:
关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,Java内存模型中定义了下面介绍8种操作来完成。
JMM在执行前面介绍8种基本操作时,为了保证内存间数据一致性,JMM中规定需要满足以下规则:
规则1:如果要把一个变量从主内存中复制到工作内存,就需要按顺序的执行 read 和 load 操作,如果把变量从工作内存中同步回主内存中,就要按顺序的执行 store 和 write 操作。但 Java 内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
规则2:不允许 read 和 load、store 和 write 操作之一单独出现。
规则3:不允许一个线程丢弃它的最近 assign 的操作,即变量在工作内存中改变了之后必须同步到主内存中。
规则4:不允许一个线程无原因的(没有发生过任何 assign 操作)把数据从工作内存同步回主内存中。
规则5:一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load 或 assign )的变量。即就是对一个变量实施 use 和 store 操作之前,必须先执行过了 load 或 assign 操作。
规则6:一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。所以 lock 和 unlock 必须成对出现。
规则7:如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行 load 或 assign 操作初始化变量的值。
规则8:如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作;也不允许去 unlock 一个被其他线程锁定的变量。
规则9:对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中(执行 store 和 write 操作)
看起来这些规则有些繁琐,其实也不难理解:
规则1、规则2 工作内存中的共享变量作为主内存的副本,主内存变量的值同步到工作内存需要read和load一起使用,工作内存中的变量的值同步回主内存需要store和write一起使用,这2组操作各自都是是一个固定的有序搭配,不允许单独出现。
规则3、规则4 由于工作内存中的共享变量是主内存的副本,为保证数据一致性,当工作内存中的变量被字节码引擎重新赋值,必须同步回主内存。如果工作内存的变量没有被更新,不允许无原因同步回主内存。
规则5 由于工作内存中的共享变量是主内存的副本,必须从主内存诞生。
规则6、7、8、9 为了并发情况下安全使用变量,线程可以基于lock操作独占主内存中的变量,其他线程不允许使用或unlock该变量,直到变量被线程unlock。
Java内存模型是围绕着在并发过程中如何处理这3个特性来建立的,即原子性、有序性、可见性。它是如何保证这三种特性的呢?
原子性:
即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。即使在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程所干扰。
JMM保证的原子性变量操作包括read、load、assign、use、store、write,而long、double非原子协定导致的非原子性操作基本可以忽略。如果需要对更大范围的代码实行原子性操作,则需要JMM提供的lock、unlock、synchronized等来保证。
可见性:
是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
JMM在变量修改后将新值同步回主内存,依赖主内存作为媒介,在变量被线程读取前从内存刷新变量新值,保证变量的可见性。普通变量和volatile变量都是如此,只不过volatile的特殊规则保证了这种可见性是立即得知的,而普通变量并不具备这种严格的可见性(不知道何时写到主存,线程读取到可能为旧值)。除了volatile外,synchronized和final也能保证可见性。
有序性:
JMM的有序性规则表现在以下两种场景: 线程内和线程间
- 线程内 :从某个线程的角度看方法的执行,指令会按照一种叫“串行”(as-if-serial)的方式执行,此种方式已经应用于顺序编程语言。
- 线程间 :这个线程“观察”到其他线程并发地执行非同步的代码时,由于指令重排序优化,任何代码都有可能交叉执行。唯一起作用的约束是:对于同步方法,同步块(synchronized关键字修饰)以及volatile字段的操作仍维持相对有序。
可见性:
保证了不同线程对该变量操作的内存可见性。
当一个共享变量被volatile修饰时,它会保证当前线程修改的值立即被更新到主存。
其他线程工作内存中的变量会强制立即失效,当其他线程需要读取时,会去主内存中读取最新值。
但是如果多个线程同时把更新后的变量值同时刷新回主内存,可能导致得到的值不是预期结果:
举个例子:定义volatile int count = 0,2个线程同时执行count++操作,每个线程都执行500次,最终结果小于1000,原因是每个线程执行count++需要以下3个步骤:
- 步骤1 线程从主内存读取最新的count的值
- 步骤2 执行引擎把count值加1,并赋值给线程工作内存
- 步骤3 线程工作内存把count值保存到主内存
有可能某一时刻2个线程在步骤1读取到的值都是100,执行完步骤2得到的值都是101,最后刷新了2次101保存到主内存
有序性:
原理:volatile的可见性和有序性都是通过加入内存屏障来实现。
会在写之后加入一条store屏障指令,将本地内存中值刷新到主内存。
会在读之前加入一条load屏障指令,从主内存中读取共享变量。
具体一点:
当程序执行到 volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
在进行指令优化时,不能将在对 volatile 变量访问的语句放在其后面执行,也不能把 volatile 变量后面的语句放到其前面执行。
synchronized能够在同一时刻最多只有一个线程执行该代码,已达到并发线程同步安全的效果,
synchronized能保证线程的原子性,可见性和有序性
synchronized能保证线程的原子性原理:synchronized保证只有一个线程拿到锁,即在同一时刻只有一个线程执行同步代码块或同步方法。
synchronized能保证可见性的原理:执行synchronized的方法对应的是lock原子操作会刷新线程工作内存中的共享变量,从而使其得到最新值。
synchronized能保证有序性原理:加入synchronized后,依然会发生重排序,只不过,同步代码块,可以保证只有一个线程执行同步代码块中代码,从而保证有序性。
在执行程序时为了提高性能,编译器和处理器经常会对指令进行重排序。从硬件架构上来说,指令重排序是指CPU采用了允许将多条指令不按照程序规定的顺序,分开发送给各个相应电路单元处理,而不是指令任意重排。重排序分成三种类型:
- 编译器优化的重排序。编译器在不改变单线程程序语义放入前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从Java源代码到最终实际执行的指令序列,会经过三种重排序。但是,为了保证内存的可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。对于编译器的重排序,JMM会根据重排序规则禁止特定类型的编译器重排序;对于处理器重排序,JMM会插入特定类型的内存屏障,通过内存的屏障指令禁止特定类型的处理器重排序。这里讨论JMM对处理器的重排序,为了更深理解JMM对处理器重排序的处理,先来认识一下常见处理器的重排序规则:
其中的N标识处理器不允许两个操作进行重排序,Y表示允许。其中Load-Load表示读-读操作、Load-Store表示读-写操作、Store-Store表示写-写操作、Store-Load表示写-读操作。可以看出:常见处理器对写-读操作都是允许重排序的,并且常见的处理器都不允许对存在数据依赖的操作进行重排序(对应上面数据转换那一列,都是N,所以处理器不允许这种重排序)。
那么这个结论对我们有什么作用呢?比如第一点:处理器允许写-读操作两者之间的重排序,那么在并发编程中读线程读到可能是一个未被初始化或者是一个NULL等,出现不可预知的错误,基于这点,JMM会在适当的位置插入内存屏障指令来禁止特定类型的处理器的重排序。内存屏障指令一共有4类:
LoadLoad屏障:
对于这样的语句 Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
StoreStore屏障:
对于这样的语句 Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
LoadStore屏障:
对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被执行前,保证Load1要读取的数据被读取完毕。
StoreLoad屏障:
对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的(冲刷写缓冲器,清空无效化队列)。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。
根据上面的表格,处理器不会对存在数据依赖的操作进行重排序。这里数据依赖的准确定义是:如果两个操作同时访问一个变量,其中一个操作是写操作,此时这两个操作就构成了数据依赖。常见的具有这个特性的如i++、i—。如果改变了具有数据依赖的两个操作的执行顺序,那么最后的执行结果就会被改变。这也是不能进行重排序的原因。例如:
写后读:a = 1; b = a;
写后写:a = 1; a = 2;
读后写:a = b; b = 1;
重排序遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。但是这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
as-if-serial语义的意思指:管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守as-if-serial语义。
as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。
如果代码中存在控制依赖的时候,会影响指令序列执行的并行度(因为高效)。也是为此,编译器和处理器会采用猜测(Speculation)执行来克服控制的相关性。所以重排序破坏了程序顺序规则(该规则是说指令执行顺序与实际代码的执行顺序是一致的,但是处理器和编译器会进行重排序,只要最后的结果不会改变,该重排序就是合理的)。
在单线程程序中,由于as-ifserial语义的存在,对存在控制依赖的操作重排序,不会改变执行结果;但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。
happens-before关系:用于描述下2个操作的内存可见性:如果操作A happens-before 操作B,那么A的结果对B可见
happens-before关系的分析需要分为单线程和多线程的情况:
单线程下的 happens-before
字节码的先后顺序天然包含happens-before关系:因为单线程内共享一份工作内存,不存在数据一致性的问题。
在程序控制流路径中靠前的字节码 happens-before 靠后的字节码,即靠前的字节码执行完之后操作结果对靠后的字节码可见。然而,这并不意味着前者一定在后者之前执行。实际上,如果后者不依赖前者的运行结果,那么它们可能会被重排序。
多线程下的 happens-before
多线程由于每个线程有共享变量的副本,如果没有对共享变量做同步处理,线程1更新执行操作A共享变量的值之后,线程2开始执行操作B,此时操作A产生的结果对操作B不一定可见。
倘若在开发中,仅靠synchronized和volatile来保证顺序性、原子性、可见性。那么编写并发程序会十分麻烦。在Java内存模型中,还提供了happens-before原则来辅助保证程序执行的原子性、可见性、有序性的问题。它是判断数据是否存在竞争,线程是否安全的依据。
Java内存模型实现了下述支持happens-before关系的操作:
- 程序次序规则
一个线程内,按照代码顺序,书写在前面的操作 happens-before 书写在后面的操作。
- 锁定规则
一个unLock操作 happens-before 后面对同一个锁的lock操作。
- volatile变量规则
对一个volatile变量的写操作 happens-before 后面对这个变量的读操作。
- 传递规则
如果操作A happens-before 操作B,而操作B又 happens-before 操作C,则可以得出操作A happens-before 操作C。
- 线程启动规则
Thread对象的start()方法 happens-before 此线程的每个一个动作。
- 线程中断规则
对线程interrupt()方法的调用 happens-before 被中断线程的代码检测到中断事件的发生。
- 线程终结规则
线程中所有的操作都 happens-before 线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行。
- 对象终结规则
一个对象的初始化完成 happens-before 他的finalize()方法的开始
注意:不同操作时间先后顺序与先行发生原则之间没有关系,二者不能相互推断,衡量并发安全问题不能受到时间顺序的干扰,一切都要以happens-before原则为准
示例代码1:
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条语句。
我们知道,final成员变量必须在声明的时候初始化或者在构造器中初始化,否则就会报编译错误。
final关键字的可见性是指:被final修饰的字段在声明时或者构造器中,一旦初始化完成,那么在其他线程无须同步就能正确看见final字段的值。这是因为一旦初始化完成,final变量的值立刻回写到主内存。
作者:浪里小白龙
链接:https://www.jianshu.com/p/d87e9b6747b0
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
Java内存模型要求lock、unlock、read、load、assign、use、store、write这8种操作都具有原子性,但是对于64位的数据类型(long和double),在模型中特别定义相对宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作分为2次32位的操作来进行。也就是说虚拟机可选择不保证64位数据类型的load、store、read和write这4个操作的原子性。由于这种非原子性,有可能导致其他线程读到同步未完成的“32位的半个变量”的值。
不过实际开发中,Java内存模型强烈建议虚拟机把64位数据的读写实现为具有原子性,目前各种平台下的商用虚拟机都选择把64位数据的读写操作作为原子操作来对待,因此我们在编写代码时一般不需要把用到的long和double变量专门声明为volatile。
看这篇
参考文章:
3. 深入理解JVM: Java内存模型JMM
4. JVM(七)JMM内存模型
5. Java内存区域(运行时数据区域) 和 内存模型(JMM)
6. JVM内存模型
7. Java内存模型