干货推荐!阿里p7大佬由浅入深讲解Java并发,看完大厂面试稳了

·  阅读 250
干货推荐!阿里p7大佬由浅入深讲解Java并发,看完大厂面试稳了

今日分享开始啦,请大家多多指教~

本篇文章是给大家研究一下重排序与内存一致性和volatile的内存语义,正文开始啦~

happens-before

happens-before是一种关系,在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系,注意,这里的两个操作既可以是不同线程,也可以是同一个线程。

那happens-before有什么规则了

  1. 程序顺序规则:一个线程中的每个操作,该线程中的任意后续动作都必须可以看到前面操作的结果,所以happens-before于该线程的任意后续动作。
  2. 监视器锁规则:当一个锁解锁后,后面的加锁动作都要可以看到解锁动作,所以happens-before于随后对这个锁的加锁。
  3. volatile变量规则:volatile实现了变量的线程可见性,所以对这个变量的操作都要被后续可见,所以happens-before于任意后续对这个volatile域的读。
  4. 传递性:如果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线程先执行)

image.png

可以看见,每个线程的三个操作都必须是按顺序执行的。

下面是不同步的时候,这2个线程共6个操作的执行顺序可能会有多种,下面只是其中一种情况

image.png

可以看到,即使是不同步的情况下,虽然整体上是无序的,但顺序一致性保证每个线程里面的操作是顺序执行的。

实现顺序一致性的前提保证是每个操作必须立即对任意线程可见,就这样就可以后面的操作不会受影响,可以立即执行。

但在JMM中,并不能实现顺序一致性,每个操作不是立即对任意线程可见的,前面提到过,每个线程都有自己的缓存,操作是先对缓存操作,然后再对主存操作的,所以对于不同步的多线程来说,不但整体的执行顺序是乱序的,而且所有线程看到的操作执行顺序也可能不一致,因为可能会发生重排序;如果是同步的话,也可能不是一致的,因为重排序,不过由于as-if-serial语义,外界可以视为顺序一致的。

下面就来分析一下JMM同步和不同步情况下与顺序一致性的区别:

  • 同步程序

在顺序一致性中,所有操作完全按程序的顺序串行执行的,而在JMM中,对于临界区的代码是可能会发生重排序的,具体一点就是加锁的代码会发生重排序。

这种重排序可以提高执行效率,而且没有改变执行的结果。

总的来说,JMM在不改变同步程序执行结果的前提下,会尽可能地使用编译器和处理器的优化。

  • 不同步程序

而对于不同步的程序,JMM只会提供最小的安全性,只会保证读出来的值不会无中生有,读取的值要么是前面线程写入的值,要么就是默认值(0,False,Null)。

而这个最小的安全性是由JVM在对象内存分配上实现的,在堆上分配内存的时候,首先会对分配的内存进行清空,然后才在上面分配对象(这两个操作是原子的),在分配对象时,就是默认值了。

从性能上考虑,为了不禁止大量的处理器和编译器的优化,所以JMM不支持程序一致性,而且未同步程序不仅整体上无序,个别线程里面也是无序的(与同步程序一样)。

volatile可见性实验

image.png

我这里开了两个线程,后面的线程去修改volatile变量,前面的线程不断获取volatile变量,

结果是会一致卡在死循环,控制台没有任何输出。

假如将flag让volatile来进行修饰

image.png

结果是:三秒后,就不会不断打印出信息出来。

注意,Thread.sleep是会刷新线程内存的,所以不要使用Thread.sleep来分别让一个线程获取两次volatile变量。

volatile的特性

volatile其实相当于对变量的单词读或写操作加了锁、做了同步。

由于是加了锁,所以就有前面提到的锁的语义,即锁的happens-before,锁的happens-before规定了释放锁的操作对于后续获得锁操作是可见的,所以释放锁的线程对于后续获得锁的线程是可见的,意味着volatile修饰的变量的最后写入是可以被后面获得锁的线程读取的。

32位的操作系统去操作64位的变量时,会分成高32位和低32位去执行,但由于锁,会导致这个操作也是具有原子性的,因为锁的语义决定了临界区代码的执行具有原子性,即必须要整个代码块执行完,如果没有锁,那么就不是原子性的,可能会被分成不连续的两步来执行。

所以,volatile变量自身是具有下面特性的

  1. 原子性:无论多大的变量,对其单词读或写操作都是具有原子性的,但如果类似于i++这种操作就不具备原子性了,因为这本来就是两条命令。
  2. 可见性:操作volatile变量的线程是可以获取前一个线程对其的修改,即当前线程总是可以看到volatile变量最后的写入。

volatile 写与读的内存语义

我们先来研究一下什么依赖关系需要volatile

前面提到过总共有三种依赖关系

  • 读后写
  • 写后读
  • 写后写

volatile是实现可见性的,所以写后写就不用考虑了,而且读后写是不需要可见性的,所以需要可见性的是写后读。

写语义

volatile写的内存语义如下:

当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存(即不仅修改了本地内存,而且还刷新到了主内存),注意,这个刷新是按缓存行的形式(64字节)。

两个线程,A线程修改flag与A,flag与A原本为默认值

image.png

所以volatile的写是有两个操作的,然后这两个操作会合成一个原子操作。

读语义

volatile的读内存语义为:当读一个volatile变量时,JVM会把线程对应的本地内存置为无效,接下来重新去主内存中读取共享变量,并且更新本地内存,注意:是读的时候会置为无效,假如不读就不会置为无效然后重新获取。

还是上面的例子,不过多了一个线程B,线程B一开始读的是默认值,后来再进行了一次读取。

image.png

读写语义

读写语义对应的其实就是volatile的变量修饰后,会进行怎样的过程。

其实volatile的读写语义,就是线程之间的通信,所以volatile也是实现了线程之间的通信,来提供可见性。

线程A去写volatile变量,实质上是线程A对其他要操控该volatile变量的其他线程发出了消息,该消息表明了线程A已经把该变量修改了,其他线程需要重新去获取。

线程B去读volatile变量时,实质上是线程B接收到了之前某个线程发出的消息(可能没有消息,不过也认为接收到),知道这个变量改了,需要去重新获取。

所以A写B读,就实现了两个线程之间的通信,虽然不太严谨,因为可能A不写,B也要读。

volatile的实现

前面已经提到过volatile的实现,字节码上加了acc_volatile修饰符,然后指令层面上是使用了内存屏障,下面就来再详细研究。

volatile的内存语义实现

volatile还有一个功能就是可以防止命令重排序,也就是volatile的内存语义。

为了实现volatile内存语义,JMM会限制重排序,因为重排序会让语义出现变化,也就是会打断与别的线程的通信,前面提到过,重排序总共有三种,而JMM会限制编译器重排序与处理器重排序,并不会限制内存重排序。

单纯看表,很难去辨别为什么,所以下面只看不发生重排序的部分。

  • 当第二个操作是volatile写时,无论第一个操作是什么,都不能发生重排序,保证了volatile写之前的操作不会被重排序到写后面。
  • 当第一个操作是volatile读的时候,无论第二个操作是什么,都不能发生重排序,保证了volatile读之后的操作不会被重排序到读之前。
  • 当第一个操作为volatile写的时候,且第二个操作是volatile读的时候,是不可以发生重排序。

第三个比较容易理解,因为volatile写会影响后面volatile读的嘛,先写后读跟线读后写是完全不一样的,所以两次操作分别为volatile读和volatile写或volatile写和volatile读都是不允许重排序的。

关键在于前两条怎么理解

其实都是因为volatile的读语义,每次volatile读都会使缓存行失效,需要去重新获取缓存行,缓存行中不仅有volatile变量,还有其他共享变量。

现在回到第二条

当第一个操作为volatile读的时候,后面也是普通读,重排序是没有问题,但如果后面是普通写,普通写后续可能是会刷新进主存中的,此时volatile读是会出现问题的。

当第一个操作为volatile读的时候,第二个操作也为volatile读的时候,会形成两次新的缓存行,而每次缓存行相同变量对应的值都可能不一样,此时如果发生重排序,就会出现不一致,比如,不发生重排序时,从第一次新的缓存行里面读A,从第二次新的缓存行里面读B,发生了重排序后,就是从第一次新的缓存行里面读B2,从第二次新的缓存行里面读A2,B与B2是不一样的,A于A2也是不一样的,所以不可以重排序。

现在回到第一条

  • 当第一个操作为volatile写的时候,会直接修改主存,影响后面的volatile读,所以对于第二个操作为volatile读是不可以重排序的。
  • 当第一个操作为volatile写的时候,会直接修改主存,是会对其他线程造成影响的,同时重排序的话,会造成结果不一致,所以也不可以重排序volatile写。
  • 当第一个操作为volatile写的时候,可以普通读,但不可以普通写,因为普通写后面也会更新到主存中去,重排序也是会导致结果不一致的。

接下来关于不需要重排序的

  • 普通读写和普通读写之前没有volatile要求,所以可以重排序,当然这会导致并发问题。
  • 普通读写和volatile读之间,只有一个volatile读要求,这个读要求不会被普通读写影响,所以也是可以重排序,不过对于普通读写部分会产生并发问题。

为了实现内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序,也就是上面提到的限制重排序的类型,对于执行效率来说,屏障数越少越好,但让JMM去动态发现最优的屏障布置是不可能的,所以采用了保守策略的JMM内存屏障和插入策略。

  1. 在每一个volatile写操作的前面插入一个StoreStore屏障,保证了在volatile写操作之前,上面的所有写操作已经执行完成,并且都刷新到主存中。
  2. 在每一个volatile写操作的后面插入一个StoreLoad屏障,保证了必须执行完volatile写操作,下面的读操作才可以执行。
  3. 在每一个volatile读操作的后面插入一个LoadLoad屏障,保证了在volatile读之前,上面的所有读操作都要完成。
  4. 在每一个volatile读操作的后面插入一个LoadStore屏障,保证了下面的写操作,必须要等待volatile读操作完成才可以继续。

由于第一次操作为普通读,第二次操作为volatile读是允许发生重排序的,所以volatile读前面不需要加内存屏障。

今日份分享已结束,请大家多多包涵和指点!

分类:
后端
标签:
分类:
后端
标签:
收藏成功!
已添加到「」, 点击更改