JMM的诞生契机
在看过无数文章后,博主越来越清晰的了解了JMM的工作机制。但是直到前一阵子才对JMM诞生的意义有了更进一步的了解。所以在你看到这篇文章的时候,希望你也站在这门技术解决了什么问题的角度,再去深入学习JMM。
计算机内存模型
在学习Java内存模型前,我们先来看一下什么是计算机内存模型。
内存模型,英文名Memory Model,他是一个很老的老古董了。他是与计算机硬件有关的一个概念。那么我先给你介绍下他和硬件到底有啥关系。
我们知道,程序在执行的时候,每一条指令都会在CPU中执行。而CPU在处理的过程中又会频繁的产生数据。这些数据的存储的事儿就不归CPU管了,而是放到我们的主内存中,也就是我们所说的计算机的物理内存了。
刚开始,还相安无事的,但是随着CPU技术的发展,CPU的执行速度越来越快。而由于内存的技术并没有太大的变化,所以从内存中读取和写入数据的过程和CPU的执行速度比起来差距就会越来越大,这就导致CPU每次操作内存都要耗费很多等待时间。
随着时代的发展内存和CPU确确实实一直是无可替代的,但总不能因为内存的读写速度慢,就不发展CPU技术了吧,总不能让内存成为计算机处理的瓶颈吧。
所以,人们想出来了一个好的办法,就是在CPU和内存之间增加高速缓存。缓存的概念大家都知道,就是保存一份数据拷贝。他的特点是速度快,内存小,并且昂贵。
那么,程序的执行过程就变成了:
当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。
缓存一致性问题
当然随着CPU缓存技术的诞生,随之而来的便是令我们头疼的缓存一致性问题。我们来分析一下缓存一致性问题的产生过程:
单线程
cpu核心的缓存只被一个线程访问。缓存独占,不会出现访问冲突等问题。
单核CPU,多线程
进程中的多个线程会同时访问进程中的共享数据,CPU将某块内存加载到缓存后,不同线程在访问相同的物理地址的时候,都会映射到相同的缓存位置,这样即使发生线程的切换,缓存仍然不会失效。但由于任何时刻只能有一个线程在执行,因此不会出现缓存访问冲突。
多核CPU,多线程
每个核都至少有一个L1 缓存。多个线程访问进程中的某个共享内存,且这多个线程分别在不同的核心上执行,则每个核心都会在各自的caehe中保留一份共享内存的缓冲。由于多核是可以并行的,可能会出现多个线程同时写各自的缓存的情况,而各自的cache之间的数据就有可能不同。
结论
在CPU和主存之间增加缓存,在多线程场景下就可能存在缓存一致性问题,也就是说,在多核CPU中,每个核的自己的缓存中,关于同一个数据的缓存内容可能不一致。
处理器优化重排序问题
上面提到在在CPU和主存之间增加缓存,在多线程场景下会存在缓存一致性问题。除了这种情况,还有一种硬件问题也比较重要。那就是为了使处理器内部的运算单元能够尽量的被充分利用,处理器可能会对输入代码进行乱序执行处理。这就是处理器优化。
除了现在很多流行的处理器会对代码进行优化乱序处理,很多编程语言的编译器也会有类似的优化,比如Java虚拟机的即时编译器(JIT)也会做指令重排。
重排序带来的问题,我们会在后续的文章中详细介绍。
JMM的诞生,解决各种优化问题
前面提到的缓存一直性问题,处理器优化指令重排序问题,源于硬件不断地升级而导致的。这些升级带给了我们运行效率的大幅提升的同时也对我们的运行结果产生了巨大的影响。
我们为了保证数据的准确性,难道要废除CPU的缓存机制,禁止处理器重排序么?虽然这样可以解决高并发环境下的线程安全问题,但是这很明显违背了我们升级速度的核心理念。
因此,我们的内存模型(JMM属于在这个基础上的进阶)诞生了。
深入理解JMM,什么是JMM?
什么是内存模型?
内存模型定义了共享内存系统中多线程程序读写操作行为的规范。通过这些规则来规范对内存的读写操作,从而保证指令执行的正确性。它与处理器有关、与缓存有关、与并发有关、与编译器也有关。他解决了CPU多级缓存、处理器优化、指令重排等导致的内存访问问题,保证了并发场景下的一致性、原子性和有序性。
内存模型解决并发问题主要采用两种方式:限制处理器优化和使用内存屏障。
那么Java内存模型呢?
Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。
Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。
什么是Java
而JMM就作用于工作内存和主存之间数据同步过程。他规定了如何做数据同步以及什么时候做数据同步。
也就是说,JMM就像一个中间件一样。它提供了许多多线程程序读写操作行为的规范,使得我们的程序员可以通过这些行为规范,对缓存,重排序这些原本不可控的问题进行人为控制。让程序员可以根据业务场景,设置何时使用CPU的缓存机制,何时屏蔽缓存直接去和主内存交互。何时禁止处理器优化指令重排序,何时开启该优化技术。
接下来我们的讲到的MESI(缓存一直性)协议会解决缓存一致性问题。内存屏障专门来处理重排序问题。
这下,我相信你对JMM的理解一定更深了一层。
JMM伪共享问题以及解决方案
主内存载入工作内存的加载单位
首先,我们需要先说明一点。我们从主内存中读取数据到我们的工作内存,是我们的JMM带来的工作机制。可是我们一次读多少数据却还没讲过。
现在告诉你,CPU 缓存系统中是以缓存行(cache line)为单位存储的。目前主流的 CPU Cache 的 Cache Line 大小都是 64Bytes。Cache Line 可以简单的理解为 CPU Cache 中的最小缓存单位,今天的CPU 不再是按字节访问内存,而是以 64 字节为单位的块(chunk)拿取,称为一个缓存行(cache line)。当你读一个特定的内存地址,整个缓存行将从主存转入缓存。
一个缓存行可以存储多个变量(存满当前缓存行的字节数);而 CPU 对缓存的修改又是以缓存行为最小单位的,在多线程情况下,如果需要修改“共享同一个缓存行的变量”,就会无意中影响彼此的性能,这就是伪共享(False Sharing)。
那么,具体是如何影响的呢?我们先来介绍一个叫MESI的东西。
MESI缓存一致性协议
我们发现图中有一个MESI失效的箭头,因此先解释一下什么是MESI。
MESI是四种缓存段状态的首字母缩写,任何多核系统中的缓存段都处于这四种状态之一。我将以相反的顺序逐个讲解,因为这个顺序更合理:
- 独占(exclusive):仅当前处理器拥有该缓存行,并且没有修改过,是最新的值。
- 共享(share):有多个处理器拥有该缓存行,每个处理器都没有修改过缓存,是最新的值。
- 修改(modified):仅当前处理器拥有该缓存行,并且缓存行被修改过了,一定时间内会写回主存,会写成功状态会变为S。
- 失效(invalid):缓存行被其他处理器修改过,该值不是最新的值,需要读取主存上最新的值。
MESI协议的生效前提
MESI协议只对汇编指令中执行加锁操作的变量有效。因此,我们会发现被volatile变量修饰的共享变量进行写操作的时候会多出第二行汇编代码,也就是lock前缀的汇编代码。
MESI协议的失效情况
- CPU不支持缓存一致性协议。(主流CPU已经不存在这个问题了)
- 该变量超过一个缓存行的大小,缓存一致性协议是针对单个缓存行进行加锁,此时,缓存一致性协议无法再对该变量进行加锁,只能改用总线加锁的方式。(造成伪共享的前提)
MESI工作原理
此处统一默认CPU为单核CPU,在多核CPU内部执行过程和一下流程一致
第一步
CPU1从内存中将变量a加载到缓存中,并将变量a的状态改为E(独享),并通过总线嗅探机制对内存中变量a的操作进行嗅探。
第二步
此时,CPU2读取变量a,总线嗅探机制会将CPU1中的变量a的状态置为S(共享),并将变量a加载到CPU2的缓存中,状态为S
第三步
CPU1对变量a进行修改操作,此时CPU1中的变量a会被置为M(修改)状态,而CPU2中的变量a会被通知,改为I(无效)状态,此时CPU2中的变量a做的任何修改都不会被写回内存中(高并发情况下可能出现两个CPU同时修改变量a,并同时向总线发出将各自的缓存行更改为M状态的情况,此时总线会采用相应的裁决机制进行裁决,将其中一个置为M状态,另一个置为I状态,且I状态的缓存行修改无效)
CPU2的变量a被置为I(无效)状态后,只是保证变量a的修改不会被写回内存,但CPU2有可能会在CPU1将变量a置为E(独占)状态之前重新读取内存中的变量a,这个取决于汇编指令是否要求CPU2重新加载内存。(当然此时CPU2读主内存变量也不是最新的数据,最新数据在CPU1中,需要经过后续步骤进行同步)。
第四步
CPU1将修改后的数据写回内存,并将变量a置为E(独占)状态
第五步
此时,CPU2通过总线嗅探机制得知变量a已被修改,会重新去内存中加载变量a,同时CPU1和CPU2中的变量a都改为S状态。
MESI总结
MESI协议只能保证并发编程中的可见性,并未解决原子性和有序性的问题,所以只靠MESI协议是无法完全解决多线程中的所有问题。
MESI协议导致伪共享的发生原因
现在有这么一个场景。
现在假设恰巧有一个缓存行的数据(64比特可以存储8个long类型数据),并且用volatile所修饰,以达到可见性的目的。线程1和线程2都在操作这同一个缓存行。
我的线程1负责更新数据,线程2负责读取数据。假如读写频率都处于一个非常快速的状态。
此时,线程1更新了数组下标为0的数据a。由于MESI协议,这个数据会立刻写回主内存。而与此同时,线程2在不停地读取数组下标为1的数据b。
以我们的角度来看,线程1和线程2压根儿不会有任何冲突产生。但是由于缓存行的操作粒度较大。导致线程2只能判断整个缓存行是否发生了改变。也就是说,线程2本来在全力的读取数据,现在为了保证绝对的可见性,因此必须停下来进行一次完全没意义的缓存行更新。我们知道这次与主内存的IO操作,比我们在缓存中读取b的消耗要大的多。
当线程1操作的越频繁,线程2的无意义更新也会越来越多。当线程1这种角色的线程更多的情况下(比如再来个线程3修改c,线程4修改d。这都与线程2读取b是无关的)。会更加频繁的导致线程2的工作内存失效,被破去主内存中重新加载。这就是我们说的缓存一致性协议是针对单个缓存行进行加锁而导致的。
再举个准确点的例子:假设一个缓存行有8个long类型数据,线程2只使用了其中的1个数据。那么就会有7个其它与自己无关的,却又和自己需要的元素在同一个缓存行的数据令自己进行无效更新。
这个过程就是伪共享。
如何解决伪共享问题
这里,我们提供的方案叫缓存行填充。怎么可填充发呢?
我们之前说,之所以会发生伪共享问题,是因为我们存在多个变量共享一个缓存行,导致线程间出现了大量的无意义操作。
那么针对这个问题,我们是不是可以让一个缓存行中,必然只存放一个变量,其他大部分空间全部填充无效数据。
就好比如,原本公司给我们每个人提供的集体宿舍,带来的不变性。我们依然希望每个人能有自己的房子。这样对于国土的利用率必然会下降,但是提高了我们的生活质量。
换到我们的内存填充逻辑中。原本一个数据行就可以存放8个long类型的字段。现在1个long类型就占用一行。空间利用率下降了8倍之多,但是在这个内存如白菜的年代,用空间利用率能解决伪共享问题,也是非常划算的一件事儿。事实上,有相当相当多的优化策略,都是用空间去换得的。
实战效率测试
不使用填充效率测试
任务类
调用层
运行结果
使用手动填充效率测试
任务类
任务类仅仅此处进行修改,我们按照代码形式,手动加入7个无用数据
调用层
和之前一样,直接测试效率。
运行结果
可以看到,本次只用了8s就结束了所有任务。也就是确确实实有大半的时间都花在了伪共享数据的处理上了。
使用jdk1.8提供的注解填充
修改VM参数
用这个注解就有一个很不爽的地方,就是我们需要在jvm启动时设置-XX:-RestrictContended参数才可以生效
任务类
与第一种不适用填充案例对比,仅仅添加了@sun.misc.Contended注解,看着更加清爽。但是由于要修改VM参数,因此程序如果进行移植,很容易出现与平时运行效率不符的现象。
调用层
运行结果
ConcurrentHashMap1.8中对注解的使用
我们发现,ConcurrentHashMap1.8中,有CounterCell这么一个内部类。
我们发现它的结构中就出现了volatile修饰的long类型变量,导致发生伪共享影响执行效率。此时我们的ConcurrentHashMap1.8选择了使用@sun.misc.Contended注解,处理伪共享导致的效率问题。
可见性问题(缓存一致性导致)
左边CPU中运行的线程从主内存中拷贝对象obj到它的CPU缓存,把对象obj的count变量改为2。但这个变更对运行在右边CPU中的线程不可见,因此这个更改还没有flush到主内存中。
在多线程的环境下,如果某个线程首次读取共享变量,则首先到主内存中获取该变量,然后存入工作内存中,以后只需要在工作内存中读取该变量即可。同样如果该变量执行了修改的操作,则先将新值写入工作内存中,然后再刷新至主内存中。但是什么时候最新的值会被刷新至主内存中是不太确定的。一般来说很快,但具体时间不确定。
要解决共享对象可见性这个问题,我们可以是用volatile关键字或者加锁。
volatile解决可见性问题
被volatile变量修饰的共享变量进行写操作的时候会多出第二行汇编代码,也就是lock前缀的汇编代码。因此volatile关键字遵从MESI协议。
Lock前缀的指令在多核处理器下会引发两件事情::
1、将当前处理器缓存行的数据写回系统内存。
2、将这个写回内存的操作会让其他CPU缓存了改地址的数据无效化。
结合以上结论,可以得出volatile相当于屏蔽了每个线程自己独立的工作内存,直接与主内存进行交互。
这个是为什么呢?因为型中规定,所有变量都存储在主内存中。每条线程还有自己的个工作内存,线程的工作内存中保存了主内存中的变量副本。当读取过一次之内存后并且有副本存在的情况下,处理器不会再对主内存的数据进行访问,而是直接对副本数据进行操作。交互图如下:
如果声明了volatile的变量进行写操作,JVM就会向处理器发送一个Lock前缀的指令,将这个缓存行的数据写回内存。而在读取一个volatile类型的变量时,不会从线程私有数据栈中取得变量的值,而是强制从公共堆栈中取得变量的值。操作如下:
可以看到,volatile关键字相当于屏蔽了工作内存的使用,直接使用公共内存(牺牲了工作内存的执行效率)。
竞争问题
线程A和线程B共享一个对象的obj。假设线程A从主存中读取Obj.count变量到自己的CPU缓存,同时,线程B也读取了Obj.count变量到它的CPU缓存。并且这两个线程都对Obj.cout做了加1的操作。此时,Obj.count加1操作被执行了两次,不过都在不同的CPU缓存中。
如果这两个加1操作是串行执行的,那么Obj.count变量会在原始值上加2,最终主存中的值会是3。然而图中两个加1操作是并行的。不管事线程A还是线程B先flush计算结果到主存,最终主存中的Obj.count只会增加1次变成2。尽管一共有两次加1操作。注意,volatile解决不了竞争问题。要解决上面的问题,我们可以使用synchronized锁。
重排序
重排序带来的问题实例
首先。我们假设x,y的初始值都是0。在执行完线程a,线程b执行完毕后,x,y的值依然有概率均为0。
首先,照我们正常的逻辑来思考。出现x=y=0这种情况应该是不会出现的。
这两个线程,如果线程A先执行,想要得到x=0,那么线程B必然是还未执行状态。但是我们得到了x=0,那么图中的A1,A2必然都已经执行完了。那么在线程B在执行过程中,执行到步骤B2时,结果为y=a=1。
同理,如果反过来思考,先执行线程B,再执行线程A。那么执行到A2时,结果为x=b=2。
诶,就算你跟我说,巧合了!y=a=1,x=b=2同时出现也是有可能的。但是x=y=0这种情况怎么会出现呢?
没错。因为你写的代码欺骗了你的眼睛!
共有两种情况可能导致这种情况:
- 在as-if-serial环境下,ProcessorA和ProcessorB的数据不存在依赖关系,因此是支持重排序的。在重排序之后顺序被打乱,因此会发生x=y=0的情况。也就是A1,A2顺序互换,B1,B2顺序互换。
- 在有我们看不见的A3环节。我们修改操作仅临时修改在缓存区中。直到A3执行完毕,主内存的数据才会更新。
当然,第二种问题我们可以用我们的volatile关键词解决。但是此处我们关心的是第一个问题。重排序发生的条件是什么?as-if-serial是什么?别急,下面我们继续讲解。
数据依赖性
上面三种情况,只要代码上下交换,程序的执行结果将会被改变。(除了读后读)
编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。
注意,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
as-if-serial(对数据依赖性的运用)
as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提供并并行度),(单线程)程序的执行结果不能改变。编译器,runtime和处理器都必须遵守as-if-serial语义。
为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。(强调一下,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,毕竟是serial串行。不同处理器之间和不同线程之间的数据依赖性不会被编译器和处理器考虑)。但是,如果操作之间不存在数据依赖性,这些操作就可能被编译器和处理器重排序。
如图:A和C之间存在数据依赖关系。同时B和C之间也存在数据依赖关系。因此在最终执行的指令序列中,C不能被重排序到A和B之前(C排到A和B之的前面,程序的结果将会被改变)。但A和B之间没有数据依赖关系,因此编译器可以对A和B之间的执行顺序进行重排序。
as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器,runtime和处理器可以让我们感觉到:单线程程序看起来是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。
重排序带来的问题——控制依赖性
class Test{ int a = 0; boolean flag = false; public void writer(){ a = 1; // 1 flag = true; // 2 } public void reader(){ if(flag){ // 3 int i = a * a; // 4 ...... } }
什么是控制依赖性?
考察代码,我们可以看见:
操作1和操作2没有数据依赖关系。编译器和处理器可以对两个操作重排序。同样,操作3和操作4没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。我们重排序的规则仅仅遵守as-if-serial语义。但是操作3和操作4存在着控制依赖关系,也就是操作4的操作取决于操作3的判断结果,被称为控制依赖。
那么在多线程环境中,对1,2重排序,以及堆3,4重排序可能会造成什么问题呢?
这里,我们假设有两个线程,线程A执行writer,线程B执行reader。由于我们想突出的是重排序问题,因此假设我们的例子中不存在可见性问题,结果如下:
步骤1,2重排序
运行图解
假设我们先对1,2进行了重排序。那么结果会怎样呢?
我们发现,线程B可能来不及读取线程A对变量a的修改,导致线程B中的a还是0,i计算结果为0,这违背了在多线程环境下,我们想要得到i=1的想法。
步骤3,4重排序
猜测执行
首先,在讲解3,4排序前,我们会先讲讲为啥编译器和处理器需要对控制依赖进行重排序。
当代码中存在控制依赖行时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程B的处理器可以提前读取并计算a*a,然后把计算结果临时保存到一个名为重排序缓冲的硬件缓存中。当操作3的条件判断为真的时候,就把该结算结果写入到变量i中。
依照这个理论,我们得到的流程图如下:
运行图解
我们发现,根据3,4重排序下来,导致线程B中的a还是0,temp位0,i计算结果也为0,这依然违背了在多线程环境下,我们想要得到i=1的想法。
总结以及处理方案
在单线程程序中,对存在控制依赖的操作进行重排序,不会改变执行结果(这也是as-if-serial 语义允许对存在控制依赖的操作做重排序的原因),但是在多线程的程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。
那么我们很多人就懵了。那这么写代码难道不行么?答案是可以的。加我们的volatile关键字就可以添加内存屏障,防止重排序的发生。
内存屏障的理解
首先是硬件上面的内存屏障
Load屏障,是x86上的”ifence“指令,在其他指令前插入ifence指令,可以让高速缓存中的数据失效,强制当前线程从主内存里面加载数据
Store屏障,是x86的”sfence“指令,在其他指令后插入sfence指令,能让当前线程写入高速缓存中的最新数据更新写入主内存,让其他线程可见。
Java里面的内存屏障
在java里面有4种,就是 LoadLoad,StoreStore,LoadStore,StoreLoad,实际上也能看出来,这四种都是上面的两种的组合产生的
LoadLoad屏障:举例语句是Load1; LoadLoad; Load2(这句里面的LoadLoad里面的第一个Load对应Load1加载代码,然后LoadLoad里面的第二个Load对应Load2加载代码),此时的意思就是在Load2加载代码在要读取的数据之前,保证Load1加载代码要从主内存里面读取的数据读取完毕。
StoreStore屏障:举例语句是 Store1; StoreStore; Store2(这句里面的StoreStore里面的第一个Store对应Store1存储代码,然后StoreStore里面的第二个Store对应Store2存储代码)。此时的意思就是在Store2存储代码进行写入操作执行前,保证Store1的写入操作已经把数据写入到主内存里面,确认Store1的写入操作对其它处理器可见。
LoadStore屏障:举例语句是 Load1; LoadStore; Store2(这句里面的LoadStore里面的Load对应Load1加载代码,然后LoadStore里面的Store对应Store2存储代码),此时的意思就是在Store2存储代码进行写入操作执行前,保证Load1加载代码要从主内存里面读取的数据读取完毕。
StoreLoad屏障:举例语句是Store1; StoreLoad; Load2(这句里面的StoreLoad里面的Store对应Store1存储代码,然后StoreLoad里面的Load对应Load2加载代码),在Load2加载代码在从主内存里面读取的数据之前,保证Store1的写入操作已经把数据写入到主内存里面,确认Store1的写入操作对其它处理器可见。
Volatile关键字里面的内存屏障
在每个volatile写操作前插入StoreStore屏障,这样就能在其他线程修改A变量后,把修改的值对当前线程可见,在写操作后插入StoreLoad屏障,这样就能让其他线程获取A变量的时候,能够获取到已经被当前线程修改的值
在每个volatile读操作前插入LoadLoad屏障,这样就能让当前线程获取A变量的时候,保证其他线程也都能获取到相同的值,这样所有的线程读取的数据就一样了,在读操作后插入LoadStore屏障;这样就能让当前线程在其他线程修改A变量的值之前,获取到主内存里面A变量的的值。
所以volatile能在一定程度上保证有序性,将代码划分为多个区域,区域与区域之间按照顺序执行,各个区域中的代码可以重排序,如下图所示(上面的老忘,只要记住可以达成下面这种效果):
因此,可以用volatile来禁止存在控制依赖关系的代码进行重排序。
临界区(syn代码块中的内容)支持重排
Synchronized代码块中间的区域就是临界区。这里的数据在多线程环境中也能保证串行。
JMM在退出临界区和进入临界区这两个关键时间点做了一些特别的处理,使得多线程在这两个时间点按某种顺序执行。
临界区的代码则可以重排序(但JMM不允许临界区内的代码”逸出”到临界区外,那样会破坏监视器的语义)。虽然线程A在临界区内做了重排序,但由于监视器互斥执行的特性,这里的线程B根本无法”观察”线程A在临界区内的重排序,这种重排序既提高了执行效率,又没有改变程序的执行结果。
happens-before(和as-if-serial语义是一回事)
在Java规范提案中为让大家理解内存可见性这个概念,提出了happens-before的概念来阐述操作之间的内存可见性。对于Java程序员来说,理解happens-before是理解JMM的关键。
JMM这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心。程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。因此,happens-before关系本质上和as-if-serial语义是一回事。as-if-serial语义保证单线程内程序的执行结果不会改变。Happens-before关系保证正确同步的多线程程序执行结果不会被改变。
奇葩的定义
用happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。
两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前。happens-before仅仅要求前一个操作(执行的结果)将对第二个操作可见。且前一个操作按顺序排在第二个操作之前。(最后这两句话看着完全是冲突)
加深理解
这话怎么看都很矛盾,但其实是站在不同角度来说的。
- 站在Java程序员的角度来说(理想效果),JMM保证,如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见。而且第一个操作的执行顺序排在第二个操作之前。然而实际运行的效果却并非如此!
- 站在编译器和处理器的角度来说(实际执行的效果),JMM允许,两个操作之间存在happens-before关系。不要求Java平台的具体实现必须按照happens-before关系指定的顺序来执行。也就是说,在两个存在happens-before关系的代码中,我们编译器和处理器会再次数据依赖性进行检测,如果不存在数据依赖,依旧允许重排序!
案例说明
现在有这么三行代码。以程序员的角度来看:
- A happens-before B
- B happens-before C
- A happens-before C(happens-before具有传递性)
我们以编译器,处理器的角度来看。
可以发现,A,C与B,C之间都存在数据依赖性,因此,它们是不可重排的。但是A,B之间并不存在依赖关系,因此依旧是可以重排的。
那么happens-before这个概念是不是没啥用呢?其实我们的happens-before保证了基本的原则。就是先执行的代码对后执行的代码的可见性!这个被我们使用到觉得再正常不过的概念,实际上就是由它来提供的。(不然你写的代码都不是从上到下直接念了)。后续的数据依赖性判断,都是建立在happens-before的基础上执行的。
happens-before的规则
- 程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作。
- 监视器锁规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁。
- volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
- 传递性:如果 A happens-before B,且 B happens-before C,那么A happens-before C。
- start()规则:如果线程 A 执行操作 ThreadB.start()(启动线程 B),那么 A线程的 ThreadB.start()操作 happens-before 于线程 B 中的任意操作。
- join()规则:如果线程 A 执行操作 ThreadB.join()并成功返回,那么线程 B中的任意操作 happens-before 于线程 A 从 ThreadB.join()操作成功返回后的操作。
- 线程中断规则:对线程 interrupt 方法的调用 happens-before 于被中断线程的代码检测到中断事件的发生后的操作。
仔细读了一下这7点,发现就是我们平时默认的逻辑。当然这些逻辑即使被我们现在用的理所当然,也是基于这些规则才为我们带来的便利。