多线程知识梳理(1):当我们谈到指令乱序的时候,在谈什么?
结论
我喜欢先说结论。
程序里面的每行代码的执行顺序,有可能被编译器和CPU根据某种策略给打乱掉。目的是为了性能的提升,让指令的执行能尽可能的并行起来。
可能发生乱序的地方
在Java代码运行过程中,有三处地方会发生指令乱序。
-
代码编译过程中,无论是javac将.java文件编译为.class文件的过程中,还是JIT动态编译的过程中,代码的执行顺序都有可能和你当时写的顺序不一样。
-
CPU执行过程中,CPU在执行指令的时候,并不一定会按照收到的指令顺序去执行,CPU为了让指令尽可能的并行执行,会打乱执行顺序。
-
内存乱序,也就是说,CPU的多核之间的指令顺序也不一致。比如CPU0执行的L0(假设这是一个读取操作,执行序号是0),再执行W1(假设这是一个写操作,执行序号是1),但是从另一个CPU1看起来,他可能先看到的是W1,再看到L0.
乱序执行 vs 顺序提交
就像前面收的CPU为了使指令尽可能的并行起来,发明了流水线技术。但如果前后两个指令存在依赖关系,那么后一条语句就要等前一条完成后才能开始。
这里就要说到一个重要的原则:happens before原则
happens before原则
-
如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
-
两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法。
下面是happens-before原则规则:
- 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
- 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作;
- volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
- 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
- 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
- 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
- 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;
CPU的乱序
CPU为了提高流水线的运行效率,就会做出比如:
- 对无依赖的前后指令做出适当的乱序和调度
- 对控制以来的指令做分支预测
- 对读取内存等耗时操作,做提前预读等
重排序处理器
但是,这里有一个地方非常重要,CPU虽然会按照自己的流水线去乱序执行给定的指令,但是从CPU对外表现来看却不是这样的。这得益于“重排序处理器”。
重排序处理器,会把各个指令的结果按照CPU接收到的指令顺序写入到store buffer、高速缓存或者内存区中
内存乱序
写缓冲区乱序
写缓冲器位于cpu核和高速缓存之间,对X86的架构来说,写缓冲器是FIFO(先进先出)的,因此并不会出现乱序的情况,但是对ARMPower架构来说,写缓冲区并不能保证FIFO,因此可能会乱序。
高速缓存和写缓冲器的指令重排
cpu会将数据写入写缓冲器的过程是store,从高速缓存或者内存中读取数据是load。
写缓冲器和高速缓存执行store和load的过程都是按照处理器指示的顺序来的,处理的重拍处理器就是按程序的顺序来load和store的,但是其它的处理器看到的可能出现load和store是重排序的,也就是内存重排序。
内存重排的四种可能性:
- load load:cpu0先执行l0再执行l1,cpu1看到的是先l1再l0
- store store : cpu0限制性w0再执行w1,cpu1看到的是先w1再w0
- load store:cpu0先执行l0再执行w1,cpu1看到的是先w1再l0
- store load:cpu0限制性w0再执行l1,cpu1看到的是先l1再w0
这样的指令重排可能出现什么样的问题呢?我们简单举个例子:
共享变量
Resource resource = null;
Boolean flag = false;
CPU0
cpu0执行了
resource = loadResource();
flag = true;
CPU1
cpu1执行的代码块是
while(!flag){
// 一大堆业务逻辑
// 等待信息等等
}
resource.excute();
但是由于内存重排导致,cpu1先看到了cpu0的写操作,也就是flag=true,这个时候代码块就跳出的while循环,开始执行resource.excute()方法。但是CPU1这个时候可能还没有看到cpu0的读取操作也就是resource=loadResource()方法,这个时候cpu1中的resource还是null,那再执行resource.execute()就会出现NPE。