前言
这一系列资料基于黑马的视频: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 的值不是相同的,因为这两个数据产生了数据依赖性。
那么,什么是数据依赖呢?
2. 数据依赖性
如果两个操作访问同一个变量,且这两个操作有一个是写操作,那么这两个操作之间就存在数据依赖性。很明显,如果两个都是读操作,那么怎么排序都不会改变结果的,没有涉及到数据的变化。数据依赖分为下面三种类型的操作:
名称 | 代码示例 | 说明 |
---|---|---|
写后读 | a = 1; b = a; | 写一个变量之后,再读这个位置 |
写后写 | a = 1; a = 2; | 写一个变量之后,再写这个变量 |
读后写 | a = b; b = 1; | 读一个变量之后再写这个变量 |
上面三种情况,只要重排序其中的两个操作,程序的执行结果就会被改变。
对于数据依赖性导致的重排序问题,Java在编译的时候显然也会考虑到这一点,但是还有一些注意事项
编译器
和处理器
在做指令重排序的时候会遵循数据依赖性
,也就是说编译器和处理器不会改变存在数据依赖性的两个操作的执行顺序- 这里的数据依赖性只是在
单个处理器
中执行的指令序列
和单个线程
中执行的操作 - 对于
不同处理器
和不同线程
之间的数据依赖性不被编译器和处理器考虑
3. as-if-serial
1. 概念和理解
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.14
- B --> A --> C 执行结果:area = 3.14
总结: 可以看到执行结果其实是一样的,`as-if-serial`把单线程程序保护了起来,使得编译器、runtime 和处理器不会对没有数据依赖的指令进行重排序,也不用担心内存可见性的问题。所以有程序员可能会误以为:单线程程序是按照顺序来执行的。
4. 重排序对多线程的影响
下面分为两个例子来介绍重排序对多线程的影响
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,是最新的值。
2. 例2(诡异的结果)
其实经过上面的例1,我们大概对重排序都有一定的了解了,下面再通过一个例子来体会重排序:
<!-- 并发测试 -->
<dependency>
<groupId>org.openjdk.jcstress</groupId>
<artifactId>jcstress-core</artifactId>
<version>0.3</version>
</dependency>
@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 这三部分粘贴过去就行
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.jlhwasx</groupId>
<artifactId>testReorder</artifactId>
<version>1.0-SNAPSHOT</version>
<name>testReorder</name>
<!-- FIXME change it to the project's website -->
<url>http://www.example.com</url>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!--
jcstress version to use with this project.
-->
<jcstress.version>0.5</jcstress.version>
<!--
Java source/target to use for compilation.
-->
<javac.target>1.8</javac.target>
<!--
Name of the test Uber-JAR to generate.
-->
<uberjar.name>jcstress</uberjar.name>
</properties>
<dependencies>
<!-- https://mvnrepository.com/artifact/org.openjdk.jcstress/jcstress-core -->
<dependency>
<groupId>org.openjdk.jcstress</groupId>
<artifactId>jcstress-java-test-archetype</artifactId>
<version>0.5</version>
</dependency>
<dependency>
<groupId>org.openjdk.jcstress</groupId>
<artifactId>jcstress-samples</artifactId>
<version>0.5</version>
</dependency>
<dependency>
<groupId>org.openjdk.jcstress</groupId>
<artifactId>jcstress-core</artifactId>
<version>0.5</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.1</version>
<configuration>
<compilerVersion>${javac.target}</compilerVersion>
<source>${javac.target}</source>
<target>${javac.target}</target>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>2.2</version>
<executions>
<execution>
<id>main</id>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<finalName>${uberjar.name}</finalName>
<transformers>
<transformer
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>org.openjdk.jcstress.Main</mainClass>
</transformer>
<transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
<resource>META-INF/TestList</resource>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
- 编写测试类
@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 次,虽然在千万级别的测试面前这个概率很小,但是也会有概率出现。
5. 解决方法
其实很简单,使用 volatile 就行,这个关键字可以禁止指令重排序,关于为什么能禁止,我会单独写一篇文章具体介绍 volatile 这个关键字的一些作用,其实这里最核心的就是读写屏障的作用保证了结果输出不会被改变。
如有错误,欢迎指出!!!