一、基本概念
对于一个线程的执行代码而言,我们总是习惯性认为代码的执行总是从上到下,有序执行。
但为了提升性能,编译器和处理器通常会对指令序列进行重新排序。
Java规范规定JVM线程内部维持顺序化语义,即只要程序的最终结果与它顺序化执行的结果一致,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。
指令重排序类型
(1)编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
(2)指令级并行的重排序:现代处理器采用了指令级并行技术(ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
(3)内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
上述的1属于编译器重排序,2和3属于处理器重排序。这些重排序可能会导致多线程程序出现内存可见性问题。
指令重排序优缺点
- 优点:JVM能根据处理器特性(CPU多级缓存系统、多核处理等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度发挥机器性能。
- 缺点:指令重排序可以保证串行语义一致,但没有义务保证多线程之间的语义一致(即可能产生“脏读”)。
二、代码解读指令重排
JVM
会在不影响正确性的前提下,可以调整语句的执行顺序,思考下面一段代码
static int i; static int j; // 在某个线程内执行如下赋值操作 i = ...; j = ...;
可以看到,至于是先执行 i 还是 先执行 j ,对最终的结果不会产生影响。所以,上面代码真正执行时,既可以是
i = ...; j = ...;
也可以是
j = ...; i = ...;
这种特性称之为『指令重排』,多线程下『指令重排』会影响正确性。为什么要有重排指令这项优化呢?从 CPU 执行指令的原理来理解一下吧
三、指令级并行原理
指令重排序优化
事实上,现代处理器会设计为一个时钟周期完成一条执行时间最长的 CPU 指令。为什么这么做呢?可以想到指令还可以再划分成一个个更小的阶段,例如,每条指令都可以分为: 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回
这 5 个阶段。
术语参考:
instruction fetch (IF)
instruction decode (ID)
execute (EX)
memory access (MEM)
register write back (WB)
在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序和组合来实现指令级并行,分阶段、分工正是提升效率的关键!
支持流水线的处理器
现代 CPU 支持多级指令流水线,例如支持同时执行取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回
的处理器,就可以称之为五级指令流水线。这时 CPU 可以在一个时钟周期内,同时运行五条指令的不同阶段(相当于一 条执行时间最长的复杂指令),IPC = 1,本质上,流水线技术并不能缩短单条指令的执行时间,但它变相地提高了 指令地吞吐率。
大多数处理器包含多个执行单元,并不是所有计算功能都集中在一起,可以再细分为整数运算单元、浮点数运算单元等,这样可以把多条指令也可以做到并行获取、译码等,CPU 可以在一个时钟周期内,执行多于一条指令,IPC>1。
四、指令重排序带来的问题
4.1 诡异的结果
int num = 0; boolean ready = false; // 线程1 执行此方法 public void actor1(I_Result r) { if(ready) { r.r1 = num + num; } else { r.r1 = 1; } } // 线程2 执行此方法 public void actor2(I_Result r) { num = 2; ready = true; }
I_Result 是一个对象,有一个属性 r1 用来保存结果,问,可能的结果有几种?
- 情况1:线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1
- 情况2:线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结果为1
- 情况3:线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过了)
- 情况4:线程2 执行 ready = true,切换到线程1,进入 if 分支,相加结果为 0,再切回线程2 执行 num = 2(情况4由于指令重排序出现里问题)
这种现象叫做指令重排,是 JIT 编译器在运行时的一些优化
- 这个现象需要通过大量测试才能复现:借助 java 并发压测工具jcstress(Java Concurrency Stress)
- jmeter侧重对于接口整体的响应速度等进行测试,而JCStress框架能对某块逻辑代码进行高并发测试,更加侧重JVM,类库等领域的研究
4.2 指令重排的说明
- 指令重排序操作不会对存在数据依赖关系的操作进行重排序。比如:a=1;b=a; 这个指令序列,由于第二个操作依赖于第一个操作,所以在编译时和处理器运行时这两个操作不会被重排序。
- 重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变。 比如:a=1;b=2;c=a+b这三个操作,第一步(a=1)和第二步(b=2)由于不存在数据依赖关系,所以可能会发生重排序,但是c=a+b这个操作是不会被重排序的,因为需要保证最终的结果一定是c=a+b=3。
指令重排序在单线程模式下是一定会保证最终结果的正确性, 但是在多线程环境下,问题就出来了。
五、解决方法
volatile 修饰的变量,可以禁用指令重排
注:对于volatile禁止指令重排的原理见文章:volatile原理
import org.openjdk.jcstress.annotations.*; import org.openjdk.jcstress.infra.results.I_Result; @JCStressTest // 标记此类为一个并发测试类 @Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok") @Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!") @State // 标记此类是有状态的 public class ConcurrencyTest { int num = 0; volatile boolean ready = false;//加上 volatile 防止 修改ready操作 之前的写指令重排 @Actor 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; } }
运行结果
*** INTERESTING tests Some interesting behaviors observed. This is for the plain curiosity. 0 matching test results.
思考:是否可以通过synchronized来解决该问题?
使用synchronized并不能解决有序性
问题,但是如果是该变量
整个都在synchronized代码块的保护范围内,那么变量就不会被多个线程同时操作,也不用考虑有序性问题!在这种情况下相当于解决了重排序问题!