前言1. 概念2. 数据依赖性3. as-if-serial
1. 概念和理解2. 例子 4. 重排序对多线程的影响
1. 例1(基本的重排序)2. 例2(诡异的结果) 5. 解决方法
前言
这一系列资料基于黑马的视频:java并发编程,这篇文章中介绍指令重排序以及如何解决这个问题。同时参考了《Java并发编程这本书》以及在里面加入一些自己的理解,volatile的文章也发布了,有兴趣可以看看:volatile细说
1. 概念
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。比如下面的一段代码:
//重排序前 int i = 0; int j = 1; //重排序后 int j = 1; int i = 0;
这段赋值的代码重排序之后可能顺序是先对 j 赋值,再对 i 赋值,虽然顺序改变了,但是结果并没有什么影响,因为 i 和 j 的数据并没有依赖性,所以其实先对哪个赋值其实并没有什么影响。但是如果是有数据依赖性的呢?我们再看这一段代码:
//忽略前面定义变量的过程 //重排序前 a = b; b = 1; //重排序后 b = 1; a = b;
这段代码如果 3 和 2 进行了重排序,那么结果就有问题了,很明显这时候重排序前后 a 的值不是相同的,因为这两个数据产生了数据依赖性。
那么,什么是数据依赖呢?
如果两个 *** 作访问同一个变量,且这两个 *** 作有一个是写 *** 作,那么这两个 *** 作之间就存在数据依赖性。很明显,如果两个都是读 *** 作,那么怎么排序都不会改变结果的,没有涉及到数据的变化。数据依赖分为下面三种类型的 *** 作:
上面三种情况,只要重排序其中的两个 *** 作,程序的执行结果就会被改变。
对于数据依赖性导致的重排序问题,Java在编译的时候显然也会考虑到这一点,但是还有一些注意事项
编译器和处理器在做指令重排序的时候会遵循数据依赖性,也就是说编译器和处理器不会改变存在数据依赖性的两个 *** 作的执行顺序这里的数据依赖性只是在单个处理器中执行的指令序列和单个线程中执行的 *** 作对于不同处理器和不同线程之间的数据依赖性不被编译器和处理器考虑
as-if-serial 语句的意思是:对于单线程而言,不管编译器和处理器为了提高并行度做了怎么样的重排序 *** 作,但是执行的结果是不能被改变的。编译器、runtime和处理器都必须遵循 as-if-serial 语义。
上面我们也谈到了,单线程情况下才可以遵循这种准则,而为了实现这种效果,编译器和处理器就不会对上面谈到的具有数据依赖关系的两个 *** 作做重排序。
实际上,如果你了解 CPU 的微指令优化,就可以了解到,对于没有依赖的两个指令,可以放在同一个时钟周期内执行,提高指令执行效率。举一个流水线的例子,比如一条浮点数加法流水线(a + b + c + d + e + f + g + h),为了提高效率,我们可以
- 先进行(a + b),(c + d),(e + f),(g + h)再执行(a + b + c + d)+ (e + f + g + h)最后进行(a + b + c + d+ e + f + g + h)
那么此时不难发现步骤 2 依赖 步骤 1 的结果,步骤 3 依赖 步骤 2 的结果,所以我们设计流水线的时候就得考虑到依赖的关系,比如第二部的几个指令阶段必须等到第一步的结果出来才可以开始执行,所以我们必须合理安排流水线的阶段。
其实上面这个例子也是为了说明数据的依赖关系,在微指令中,存在数据依赖关系的指令执行必须有一个先后顺序,这和上面的重排序思想也是一样的。具有数据依赖性的代码不会被重排序才可以保证结果是一致的。
那么,下面就让我们看看没有数据依赖性的指令的执行顺序究竟有没有变化
2. 例子
我们就直接用书上的⚪的面积作为例子就可以了,比如有一段代码:
double pi = 3.14; //A double r = 1.0; //B double area = pi * r * r; //C
上面 3 个 *** 作的数据依赖性类似下图:A, B 都和 C 有依赖关系
上图中可以看到 A, B 都和 C 有依赖关系,但是 A 和 B 是没有依赖关系的,所以在最终的执行的指令序列中,C不可能被重排序到 A 和 B 面前,如果排到 A 和 B 的面前结果就会发生改变。但是编译器和处理器可以对 A 和 B 进行指令重排序,重排序后的执行流程有 2 种:
A --> B --> C 执行结果:area = 3.14B --> A --> C 执行结果:area = 3.14
总结: 可以看到执行结果其实是一样的,`as-if-serial`把单线程程序保护了起来,使得编译器、runtime 和处理器不会对没有数据依赖的指令进行重排序,也不用担心内存可见性的问题。所以有程序员可能会误以为:单线程程序是按照顺序来执行的。
下面分为两个例子来介绍重排序对多线程的影响
1. 例1(基本的重排序)class ReorderExample { 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 .... } } }
假设有两个线程 A 和 B,A 首先执行 writer 方法,然后 B 再执行 reader 方法,那么线程 B 执行到 *** 作 4 的时候可以看到线程 A 在 *** 作 1 对共享变量的写入呢?
答案是不一定能看到,因为 *** 作 1 和 *** 作 2 是没有依赖性的,同时 *** 作 3 和 *** 作 4 也是没有数据依赖性的,所以编译器和处理器可以对这两个 *** 作进行指令重排序。
对 *** 作 1 和 *** 作 2 进行指令重排序:
这时候我们发现,对 *** 作 1 和 *** 作 2 做了指令重排序之后,线程 A 首先写入 true,然后线程 B 判断 if 为真,最后对 i 进行赋值,但是此时 线程 A 还没有对 a进行写入,所以结果时 i = 0,这里多线程的语义被重排序破坏了。
对 *** 作 3 和 *** 作 4 进行指令重排序(顺便说说控制依赖):
有可能有人会有疑惑,为什么会多出一个temp,下面进行说明:
在程序中, *** 作 3 和 *** 作 4 存在控制依赖关系,所谓控制依赖,其实看名字也知道就是 *** 作 4 由 *** 作 3 来决定是否运行。当代码中存在控制依赖的时候,会影响指令序列执行的并发度,因为要考虑到控制依赖的关系,不能让有这种关系的指令并行执行。
为此,编译器和处理器会采取猜测(Speculation)执行来进行克服控制对并行度的影响。以上面的代码为例,线程 B 可以提前读取具有依赖性的代码,在上面的代码中就是 a * a,先使用一个 temp 进行接收,然后把这个计算的结果临时保存到一个名为重排序缓冲的硬件缓存中,当 *** 作 3 的判断为 true 的时候,就把结果 temp 写入变量 i 中。这也是重排序的一种。
其实这样一来,我们就可以发现了,只要涉及到多线程的重排序,不做防护的情况下指令执行结果被破坏的情况还是有的。在单线程中,对存在控制依赖的 *** 作进行重排序时不会改变执行结果的,这也是为什么 as-if-serial语义允许对存在控制依赖的 *** 作做重排序的原因,但是多线程情况下是有问题的。我的猜测就是单线程在执行 writer 方法的时候把 a 赋值为了 1,而在执行到 reader 方法的时候就算进行了重排序,缓存中村的也是 1 * 1,是最新的值。
其实经过上面的例1,我们大概对重排序都有一定的了解了,下面再通过一个例子来体会重排序:
org.openjdk.jcstress jcstress-core0.3
@JCStressTest @Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok") @Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!") @State public class ConcurrencyTest { //num = 0 int num = 0; //初始值算是false boolean ready = false; @Actor //I_Result 是一个对象,有一个属性 r1 用来保存结果,问,可能的结果有几种? //线程 1 执行 actor1 public void actor1(I_Result r) { if(ready) { r.r1 = num + num; } else { r.r1 = 1; } } //线程 2 执行 actor2 @Actor public void actor2(I_Result r) { num = 2; ready = true; } }
再执行之前,我们先来自己分析一下会有什么结果:
情况1:线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1情况2:线程2 先执行 num = 2,但没来得及执行 ready = true,发生上下文切换,线程1 执行,还是进入 else 分支,结果为1情况3:线程2 先执行 num = 2,然后接着执行 ready = true,线程1 进入 ready,此时结果为 4情况4:指令重排序的影响,经过指令重排序之后线程 2 先执行 ready = true,然后发送上下文切换,此时线程 1 执行进入 if 语句,由于此时 num 没有赋值,那么结果就是 0+0 = 0
下面就通过测试结果验证我们的猜想是不是对的:
- 创建一个 maven 工程修改 pom.xml 文件,下面这个是在网上找了一个能用的,把 properties 、maven、build 这三部分粘贴过去就行
4.0.0 com.jlhwasx testReorder1.0-SNAPSHOT testReorder http://www.example.com UTF-8 1.8 1.8 UTF-8 0.5 1.8 jcstress org.openjdk.jcstress jcstress-java-test-archetype0.5 org.openjdk.jcstress jcstress-samples0.5 org.openjdk.jcstress jcstress-core0.5 org.apache.maven.plugins maven-compiler-plugin3.1 ${javac.target} ${javac.target} org.apache.maven.plugins maven-shade-plugin2.2 main package shade ${uberjar.name} org.openjdk.jcstress.Main meta-INF/TestList
- 编写测试类
@JCStressTest @Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok") @Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!") @State public class ConcurrencyTest { //num = 0 int num = 0; //初始值算是false boolean ready = false; @Actor //I_Result 是一个对象,有一个属性 r1 用来保存结果,问,可能的结果有几种? public void actor1(I_Result r) { if(ready) { r.r1 = num + num; } else { r.r1 = 1; } } @Actor public void actor2(I_Result r) { num = 2; ready = true; } }
- 使用maven进行打包,先 clean 再 package 打包
打开控制台
进入 target 目录(cd target)运行 java -jar jcstress.jar,执行 jar 包
- 我测试的时候是一直运行的,所以手动使用了 ctrl + c 停止运行,然后找到其中两次测试的结果:
- 分析上面的结果,我们可以看到到 出现 4 和 1 和 2 的次数是最多的,达到了 千万级别,但是注意其中的 0,出现了 1147 次,虽然在千万级别的测试面前这个概率很小,但是也会有概率出现。
其实很简单,使用 volatile 就行,这个关键字可以禁止指令重排序,关于为什么能禁止,我会单独写一篇文章具体介绍 volatile 这个关键字的一些作用,其实这里最核心的就是读写屏障的作用保证了结果输出不会被改变。
如有错误,欢迎指出!!!
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)