在我们面试过程中,通常避免不了会被问到什么是指令重排序?本文就这个问题进行探索。
重排序
- 前言
- 一、重排序种类
- 二、happens-before
- 三、重排序
- 1.数据依赖性
- 2. as-if-serial语义
- 3.程序顺序规则
- 4.重排序在多线程中的影响
前言
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。
一、重排序种类
在java语言中,重排序分为3种。
- 编译器优化的重排序。编译器在不改变单线程程序的语义前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现在处理器采用了指令集并行技术,来讲多条指令重叠执行。如果不存在依赖性,处理器可以改变语句对应的机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
上述1属于编译器重排序,2和3属于处理器重排序。这些重排序会导致多线程程序出现内存可见性问题。
对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序。
对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers)指令,来禁止特定类型的处理器重排序。
内存屏障类型表
屏障类型 | 说明 |
LoadLoad Barriers | 确保Load1数据的装在先于Load2及所有后续装载命令的装载 |
StoreStore Barriers | 确保Store1数据刷新到内存先于Store2及后续所有后续存储指令 |
LoadStore Barriers | 确保Load1数据装载先于Store2及所有后续存储指令刷新到内存 |
StoreLoad Barriers | 确保Store1数据对其他处理器变得可见(刷新到内存)先于Load2以及所有后续装载指令。会使该屏障之前所有的内存访问指令完成后,才执行该屏障之后的内存访问指令 |
StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush)。
二、happens-before
从JDK5开始,Java使用心得JSR-133内存模型,其中使用happen-before的概念来阐述操作之间的内存可见性。在JMM中如果一个操作的执行结果需要对另一个操作可见,那么两个操作之间必须要存在happends-before关系,这两个操作可以是在一个线程内,也可以在不同线程之间。
happens-before规则如下:
- 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
- 监视器所规则:一个线程中的每个操作,happens-before于随后这个锁的加锁。
- volitaile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
- 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
JMM属于语言级的内存模型,它确保在不同编译器和不同处理平台上,通过禁止特定类型的编译器/处理器重排序,来保证一致的内存可见性。
如下图所示,JMM基于不同的处理器编译器等做了不同的规则实现,来为程序员提供统一的happens-before规则。
三、重排序
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。
1.数据依赖性
两个操作访问同一个变量,且这两个操作其中有一个为写操作,那么这两个操作就存在数据依赖性。
写后读:写一个变量后,再读这个位置。
a=1;
b=a;
写后写:写一个变量后,再写这个变量。
a=1;
a=2;
读后写:读一个变量后,再写这个变量。
a=b;
b=1;
上面3种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。
这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
2. as-if-serial语义
as-if-serial语义的意思是:不管怎么重排序,(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。编译器和处理器不会对存在数据依赖关系的操作做重排序。
int a = 1; //A
int b = 2; //B
int c = a + b; //C
在上述三个操作中,C同时和B、A存在数据依赖关系。因此再最终执行的指令序列中,C不能被重排序到A、B的前面。但A和B之间没有数据依赖关系,编译器和处理器可以对A和B的执行顺序进行重排序。
A - B - C 。执行结果 c = 2;
B - A - C 。执行结果 c = 2;
遵守as-if-serial语义的编译器、runtime和处理器共同为编写单线程程序的程序员创建了一个幻觉
:单线程程序是按程序的顺序来执行的。
as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题
3.程序顺序规则
int a = 1; //A
int b = 2; //B
int c = a + b; //C
上述代码中,根据happens-before的传递性推导,存在了三个happens-before关系。
- A happens-before B。
- B happens-before C。
- A happens-before C。
这里虽然A happens-before B,但实际执行时B却可以排在A之前执行。这里JMM并不要求A一定要在B之前执行,因为这里操作A的结果不需要对操作B可见。并且无论A和B的执行顺序怎么变,最后的执行结果一致。JMM允许这种重排序,会认为这种重排序并不非法。
在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下,尽可能提高并行度。
编译器和处理器遵从这一目标,从happens-before的定义我们可以看出,JMM同样遵从这一目标。
4.重排序在多线程中的影响
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对共享变量a的写入呢?
答案是:不一定能看到。
由于操作1和操作2没有数据依赖关系,所以可以对这两个操作进行重排序。同样,操作3和操作4也可以进行重排序。
情况一:操作1和操作2重排序
操作1和操作2做了重排序,程序执行时,线程A先修改了变量flag,随后线程B读取到这个变量,并作运算,此时变量a还没有被线程A写入。多线程环境下语义被重排序破坏了。情况二:操作3和操作4重排序
操作3和操作4存在控制依赖关系。当代码中存在控制依赖关系时,会影响指令序列执行的并行度。编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并
行度的影响。
以处理器的猜测执行为例,执行线程B的处理器可以提前读取并计算a*a,然后把计算结果临时保存到一个名为重排序缓冲(Reorder Buffer,ROB)的硬件缓存中。当操作3的条件判断为真时,就把该计算结果写入变量i中。
由此计算结果可见,多线程环境下语义被重排序破坏了。
在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。