写点什么

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

发布于: 2021 年 06 月 15 日
干货推荐!阿里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 线程先执行)

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

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

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

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

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

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

  • 同步程序

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

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

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

  • 不同步程序

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

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

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

volatile 可见性实验

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

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

假如将 flag 让 volatile 来进行修饰

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

注意,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 原本为默认值

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

读语义

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

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

读写语义

读写语义对应的其实就是 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 读前面不需要加内存屏障。

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

用户头像

还未添加个人签名 2021.04.20 加入

Java工具与相关资料获取等WX: pfx950924(备注来源)

评论

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