从具体场景学习重排序。
编译器重排序
看下下面这个代码:
public class Test {
private static int x = 0, y = 0;
private static int a = 0, b =0;
public static void main(String[] args) throws InterruptedException {
int i = 0;
for(;;) {
i++;
x = 0; y = 0;
a = 0; b = 0;
Thread one = new Thread(new Runnable() {
public void run() {
//由于线程one先启动,下面这句话让它等一等线程two. 读着可根据自己电脑的实际性能适当调整等待时间.
shortWait(100000);
a = 1;
x = b;
}
});
Thread other = new Thread(new Runnable() {
public void run() {
b = 1;
y = a;
}
});
one.start();other.start();
one.join();other.join();
String result = "第" + i + "次 (" + x + "," + y + ")";
if(x == 0 && y == 0) {
System.err.println(result);
break;
} else {
System.out.println(result);
}
}
}
public static void shortWait(long interval){
long start = System.nanoTime();
long end;
do{
end = System.nanoTime();
}while(start + interval >= end);
}
}
看代码很知道由于CPU分时运行,所以结果可能是
- 1,0
- 0,1
- 1,1
如果我说结果也可能是0,0你会信吗?
由于JIT编译器可能会将指令这样重排序从而导致0,0结果出现。
编译器为什么要重排序
以下是我个人的一个看法:
java代码的执行需要经过这几个阶段:
- .java源代码
- javac编译后生成java字节码
- 使用即时编译器运行java字节码,会在运行时候将字节码生成机器码
- CPU执行机器码
上面图中标记的顺序只是比喻个大概,实际重排序的代码是生成的机器码。了解这个之后你是否还记得在汇编中,如果需要进行乘法运算需要怎么做吗?
比如:java代码的一个乘法运算,
int a = 1;
int b = 2;
int c = a * b
对应到汇编中,伪代码大概是这样:
;假设三个变量的内存地址用0xa,0xb,0xc表示
mov [0xa],1
mov [0xb],2
mov eax,[0xa] ;将变量a的值移动到寄存器eax
mul [0xb]; 将寄存器eax值 与 变量b 相乘,结果存放在eax中
mov [0xc],eax ; 将结果赋值给变量c
通过汇编代码你会发现,如果要进行数学运算必须要用到CPU提供的MUL等运算指令,而这些指令也只能使用到寄存器上(CPU定义的,不要纠结这个问题)。那这和我们的JIT编译器重排序有什么关系呢?我们将java代码稍稍改动,下面就会揭晓答案:
int a = 1;
int b = 2;
int d = 0;// 增加变量d
int c = a * b
d = b * b;// 增加 一个加法运算
c = c * a;// 增加一个加法运算
赋值语句我就略过,下面是汇编代码:
mov eax,[0xa] ;将变量a的值移动到寄存器eax
mul [0xb]; 将寄存器eax值 与 变量b 相乘,结果存放在eax中
mov [0xc],eax ; 将结果赋值给变量c
mov eax,[0xb]; 将变量b值移动到寄存器eax
mul [0xb]; 将寄存器eax值 与 变量 b 相乘,结果会存放在eax
mov [0xd],eax ; 将结果赋值给变量d
mov eax,[0xc]; 将变量c值移动到寄存器eax
mul [0xa]; 将寄存器eax值 与 变量 a 相乘,结果会存放在eax
mov [0xc],eax ; 将结果赋值给变量c
如果用汇编指令的数量来评估汇编代码好坏,你能否优化上面的汇编代码?
很明显,由于插入的d = b * b运算,使用变量c的运算不得不重新在赋值到寄存器eax中,如果三个运算的顺序修改为:
int c = a * b
c = c * a;// 增加一个加法运算
d = b * b;// 增加 一个加法运算
那么就可以用更少的汇编指令进行实现。很明显,打乱的顺序相比原来对结果没有任何影响。所以对于JIT编译器来说,调整顺序是一个很棒的优化手段。
CPU指令重排序
在了解CPU指令重排序之前,需要先知道下面两个概念
时钟周期
有没有思考过CPU什么时候执行上面的汇编代码?我想到过下面几个时机:
- 鼠标双击打开QQ,CPU开始执行QQ程序的汇编代码指令,直到关机
- 开机按钮按下,CPU开始执行,直到关机
很不幸,上面两个例子都不是。当电源按下,CPU开始启动,但此时并没有执行代码指令。CPU内部有一个类似石英振荡器的东西,会每间隔一定时间发送一个脉冲,每当CPU收到脉冲信号时,才会去执行CPU指令,执行的时间即为触发脉冲的间隔时间,也叫做一个时钟周期。而一行汇编代码,可能需要经过多个时钟周期才能执行完成。
CPU执行一条指令的过程
以这个汇编代码为例:
mul [0xa]; 将eax寄存器的值 与 内存地址为0xa的值相乘
CPU如果要执行这条执行要经过下面几个过程:
- 从内存中获取该指令
- 从内存中获取 [0xa] 地址对应的值
- CPU内部运算单元执行乘法运算
- 将运算结果写回到寄存器
上面几个步骤,第1、2步均需要从内存读取,而我们知道CPU运算速度与内存的速度差了几个数量级别。所以第1、2两步需要等待大量的时钟周期。所以就需要进行优化,对于步骤1,通车可以直接从高速缓存读取,步骤2就无能为力了。
由于从内存中读取数据需要消耗大量的时钟周期,所以出现了【乱序处理器】。其执行指令的步骤:
- 取指令。
- 将指令分派到指令队列(也称为指令缓冲区)。
- 指令在队列中等待,直到其输入操作数可用。该指令可以在较旧的指令之前离开队列。
- 该指令被发布到适当的功能单元并由该单元执行。结果排队。
- 只有在所有旧指令的结果都写回寄存器文件之后,才会将该结果写回寄存器文件。这称为毕业或退休阶段。
所以为了优化指令的执行速度,CPU会采用乱序执行指令。这也会导致和编译器重排序同样的效果。
内存系统重排序
内存重排序是由于CPU与内存执行速度的差异巨大,所以CPU通常会利用缓存以提高性能。
在这种模型下会存在一个现象,即缓存中的数据与主内存的数据并不是实时同步的,各CPU(或CPU核心)间缓存的数据也不是实时同步的。这导致在同一个时间点,各CPU所看到同一内存地址的数据的值可能是不一致的。从程序的视角来看,就是在同一个时间点,各个线程所看到的共享变量的值可能是不一致的。