Java重排序

156 阅读6分钟

从具体场景学习重排序。

编译器重排序

看下下面这个代码:

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你会信吗?

image.png

由于JIT编译器可能会将指令这样重排序从而导致0,0结果出现。 image.png

编译器为什么要重排序

以下是我个人的一个看法:

java代码的执行需要经过这几个阶段:

  1. .java源代码
  2. javac编译后生成java字节码
  3. 使用即时编译器运行java字节码,会在运行时候将字节码生成机器码
  4. 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如果要执行这条执行要经过下面几个过程:

  1. 从内存中获取该指令
  2. 从内存中获取 [0xa] 地址对应的值
  3. CPU内部运算单元执行乘法运算
  4. 将运算结果写回到寄存器

上面几个步骤,第1、2步均需要从内存读取,而我们知道CPU运算速度与内存的速度差了几个数量级别。所以第1、2两步需要等待大量的时钟周期。所以就需要进行优化,对于步骤1,通车可以直接从高速缓存读取,步骤2就无能为力了。

由于从内存中读取数据需要消耗大量的时钟周期,所以出现了【乱序处理器】。其执行指令的步骤:

  1. 取指令。
  2. 将指令分派到指令队列(也称为指令缓冲区)。
  3. 指令在队列中等待,直到其输入操作数可用。该指令可以在较旧的指令之前离开队列。
  4. 该指令被发布到适当的功能单元并由该单元执行。结果排队。
  5. 只有在所有旧指令的结果都写回寄存器文件之后,才会将该结果写回寄存器文件。这称为毕业或退休阶段。

所以为了优化指令的执行速度,CPU会采用乱序执行指令。这也会导致和编译器重排序同样的效果。

内存系统重排序

内存重排序是由于CPU与内存执行速度的差异巨大,所以CPU通常会利用缓存以提高性能。

image.png

在这种模型下会存在一个现象,即缓存中的数据与主内存的数据并不是实时同步的,各CPU(或CPU核心)间缓存的数据也不是实时同步的。这导致在同一个时间点,各CPU所看到同一内存地址的数据的值可能是不一致的。从程序的视角来看,就是在同一个时间点,各个线程所看到的共享变量的值可能是不一致的。