上面一篇已经讲了一下指令重排序与内存屏障究竟是怎么回事
下面就来研究一下重排序与内存一致性
happens-before
happens-before是一种关系,在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系,注意,这里的两个操作既可以是不同线程,也可以是同一个线程
那happens-before有什么规则了
- 程序顺序规则:一个线程中的每个操作,该线程中的任意后续动作都必须可以看到前面操作的结果,所以happens-before于该线程的任意后续动作
- 监视器锁规则:当一个锁解锁后,后面的加锁动作都要可以看到解锁动作,所以happens-before于随后对这个锁的加锁
- volatile变量规则:volatile实现了变量的线程可见性,所以对这个变量的操作都要被后续可见,所以happens-before于任意后续对这个volatile域的读
- 传递性:如果B可见A,即A可以happens-before于B,如果此时,C又可见B,即B可以happens-before于C,那么对于A和C,A可以happens-before于C
其实happens-before只是一个规则,抽象了JMM提供的内存可见性而已,也就是不用去认识透彻前面提到过的各种重排序,而happens-before的实现其实也就是JMM禁止了各种重排序
重排序
前面我们已经简单了解过重排序
重排序是指:编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段,关键在于是为了优化性能,而且要注意,每个线程都会发生重排序,因为处理器每次执行都只是执行一个线程
数据依赖性
数据依赖性是指:有两个操作访问同一个变量,并且这些操作至少有一个为写操作,那么此时这两个操作就存在数据依赖性,也因此数据依赖性根据两个操作的顺序会分为三种
- 写后读:写入一个变量之后,再进行读取
- 读后写:读一个变量之后,再进行写入,注意这里是写入而不是修改,比如,a = b;b = 1就是一个读后写
- 写后写:写一个变量之后,再重新进行写入
上面三种数据依赖性,只要发生操作的重排序,程序的执行结果都会被改变,而前面已经提到过,编译器和处理器是会对操作进行重排序的,所以为了防止执行结果发生改变,编译器和处理器要辨别出操作是否存在数据依赖性,如果存在数据依赖性是不会进行重排序的,但这种自动禁止重排序操作仅仅出现在单线程和单处理器,也就是仅仅只会考虑单线程和单处理器的数据依赖性,对于不同线程和不同处理器之间的数据依赖性是不会被考虑的
as-if-serial语义
as-if-serial语义是指:不管怎么进行重排序,程序的执行结果都不能改变,当然这也只是针对单线程,也就是单线程的执行结果都不能改变,不保证多线程是否发生了改变
举个栗子
double pi = 3.14; //A操作
double r = 1; //B操作
double area = pi * r * r; //C操作
在上面的三个操作,产生数据依赖性的有A与C、B与C,而且产生的都是写后写数据依赖性,那么A与B是没有数据依赖性的,这两个操作发生重排序是不会违反as-if-serial语义,所以这两个操作允许发生重排序,但是C操作就不可以随便发生重排序了,必须要满足A-happensbefore-C与B-happensbefore-C
总的来说,as-if-serial语义是将单线程程序保护了起来,不用去考虑重排序导致的问题,让开发者可以认为程序就是按顺序执行的,重排序不会干扰
as-if-serial也允许对存在控制依赖的操作进行重排序
控制依赖就是指:逻辑判断操作,即if那些判断语句,那些判断语句也是一个操作,具体来说就是,允许先执行if里面的代码块,然后再判断if的条件是否为True或者False
因为控制依赖会影响指令序列执行的并行度,本可以执行多个命令的,偏偏要先去执行判断命令,等判断完再去执行其他命令,这会降低了指令序列的并行度,所以干脆就一起并行执行,判断条件后再考虑结果是否保留即可,即允许发生重排序
重排序对多线程的影响
重排序是针对单线程进行的,单线程发生重排序是没有任何问题的,因为有着as-if-serial语义的保证,但是多线程各自线程发生重排序,组合起来就会产生多线程的语义错误,把程序的执行结果给改变
举个栗子
假如A线程修改了一个flag变量,而B线程去获取这个flag变量,那么由于A的重排序,将修改flag变量的操作提前或者延后了,B线程获取的flag变量可能为修改前的,也可能为修改后的
顺序一致性
程序一致性是用来形容多线程同步执行的,规则如下
- 一个线程中的所有操作必须按照程序的顺序来执行
- 所有线程都只能看到一个单一的操作执行顺序,不管是同步还是不同步,每个操作都必须是原子执行且立刻对所有线程可见
举个栗子
有一个线程A,拥有三个操作,A1、A2、A3;另外一个线程B,也有三个操作,B1、B2、B3
那么在同步的时候,这2个线程共6个操作的执行顺序如下所示(假设A线程先执行)
可以看见,每个线程的三个操作都必须是按顺序执行的
下面是不同步的时候,这2个线程共6个操作的执行顺序可能会有多种,下面只是其中一种情况
可以看到,即使是不同步的情况下,虽然整体上是无序的,但顺序一致性保证每个线程里面的操作是顺序执行的
实现顺序一致性的前提保证是每个操作必须立即对任意线程可见,就这样就可以后面的操作不会受影响,可以立即执行
但在JMM中,并不能实现顺序一致性,每个操作不是立即对任意线程可见的,前面提到过,每个线程都有自己的缓存,操作是先对缓存操作,然后再对主存操作的,所以对于不同步的多线程来说,不但整体的执行顺序是乱序的,而且所有线程看到的操作执行顺序也可能不一致,因为可能会发生重排序;如果是同步的话,也可能不是一致的,因为重排序,不过由于as-if-serial语义,外界可以视为顺序一致的
下面就来分析一下JMM同步和不同步情况下与顺序一致性的区别
同步程序
在顺序一致性中,所有操作完全按程序的顺序串行执行的,而在JMM中,对于临界区的代码是可能会发生重排序的,具体一点就是加锁的代码会发生重排序
这种重排序可以提高执行效率,而且没有改变执行的结果
总的来说,JMM在不改变同步程序执行结果的前提下,会尽可能地使用编译器和处理器的优化
不同步程序
而对于不同步的程序,JMM只会提供最小的安全性,只会保证读出来的值不会无中生有,读取的值要么是前面线程写入的值,要么就是默认值(0,False,Null)
而这个最小的安全性是由JVM在对象内存分配上实现的,在堆上分配内存的时候,首先会对分配的内存进行清空,然后才在上面分配对象(这两个操作是原子的),在分配对象时,就是默认值了
从性能上考虑,为了不禁止大量的处理器和编译器的优化,所以JMM不支持程序一致性,而且未同步程序不仅整体上无序,个别线程里面也是无序的(与同步程序一样)