指令重排

指令重排
谈到指令重排,首先来了解一下Java内存模型(JMM)。
JMM的关键技术点都是围绕多线程的原子性、可见性、有序性来创建的。
原子性(Atomicity)
原子性是指一个操做是不可中断的,即便是在多个线程一块儿执行的时候,一个操做一旦开始,就不会被其它线程干扰。
可见性(Visibility)
可见性是指当一个线程修改了某一个共享变量的值,其余线程是否可以当即知道这个修改。
对于串行程序来讲可见性问题是不存在的,由于在任何一个操做步骤中修改了某个变量,那么后续的步骤中,读取这个变量的值,必定是修改以后的。
可是在并行程序中,若是一个线程修改了某一个全局变量,那么其余线程未必能够立刻知道这个改动。(这里涉及到编译器优化重排和硬件优化,这里不重点讲述)
有序性(Ordering)
有序性是指在单线程环境中, 程序是按序依次执行的.
而在多线程环境中, 程序的执行可能由于指令重排而出现乱序。
` class OrderExample {
int a = 0;
boolean flag = false;安全

public void writer() {
        // 如下两句执行顺序可能会在指令重排等场景下发生变化
        a = 1;
        flag = true;
    }

    public void reader() {
        if (flag) {
            int i = a + 1;
            ……
        }
    }
}`


假设线程A首先执行write()方法,接着线程B执行reader()方法,若是发生指令重排,那个线程B在执行 int i = a + 1;时不必定能看见a已经被赋值为1了。多线程

  • 指令重排
    指令重排是指在程序执行过程当中, 为了性能考虑, 编译器和CPU可能会对指令从新排序
    指令重排能够保证串行语义一致(不然咱们的应用程序根本没法正常工做),可是没有义务保证多线程间的语义也一致。
    为何须要指令重排?
    之因此这么作,彻底是由于性能考虑,首先,一条指令的执行是须要分不少步骤的。简单能够分为如下几步:
    1.取指令阶段 IF (使用PC寄存器组和存储器)
    取指令(Instruction Fetch,IF)阶段是将一条指令从主存中取到指令寄存器的过程。
    程序计数器PC中的数值,用来指示当前指令在主存中的位置。当一条指令被取出后,PC中的数值将根据指令字长度而自动递增:若为单字长指令,则(PC)+1àPC;若为双字长指令,则(PC)+2àPC,依此类推。
    //PC -> AR -> Memory
    //Memory -> IR
    2.指令译码阶段 ID (指令寄存器组)
    取出指令后,计算机当即进入指令译码(Instruction Decode,ID)阶段。
    在指令译码阶段,指令译码器按照预约的指令格式,对取回的指令进行拆分和解释,识别区分出不一样的指令类别以及各类获取操做数的方法。
    在组合逻辑控制的计算机中,指令译码器对不一样的指令操做码产生不一样的控制电位,以造成不一样的微操做序列;在微程序控制的计算机中,指令译码器用指令操做码来找到执行该指令的微程序的入口,并今后入口开始执行。
    // { 1.Ad
    //Memory -> IR -> ID -> { 2.PC变化
    // { 3.CU(Control Unit)
    3.执行指令阶段 EX (ALU算术逻辑单元)
    在取指令和指令译码阶段以后,接着进入执行指令(Execute,EX)阶段。
    此阶段的任务是完成指令所规定的各类操做,具体实现指令的功能。为此,CPU的不一样部分被链接起来,以执行所需的操做。
    例如,若是要求完成一个加法运算,算术逻辑单元ALU将被链接到一组输入和一组输出,输入端提供须要相加的数值,输出端将含有最后的运算结果。
    //Memory -> DR -> ALU
    4.访存取数阶段 MEM
    根据指令须要,有可能要访问主存,读取操做数,这样就进入了访存取数(Memory,MEM)阶段。
    此阶段的任务是:根据指令地址码,获得操做数在主存中的地址,并从主存中读取该操做数用于运算。
    //Ad -> AR -> AD -> Memory
    5.结果写回阶段 WB (寄存器组)
    做为最后一个阶段,结果写回(Writeback,WB)阶段把执行指令阶段的运行结果数据“写回”到某种存储形式:结果数据常常被写到CPU的内部寄存器中,以便被后续的指令快速地存取;在有些状况下,结果数据也可被写入相对较慢、但较廉价且容量较大的主存。许多指令还会改变程序状态字寄存器中标志位的状态,这些标志位标识着不一样的操做结果,可被用来影响程序的动做。
    //DR -> Memory
    6.循环阶段
    在指令执行完毕、结果数据写回以后,若无心外事件(如结果溢出等)发生,计算机就接着从程序计数器PC中取得下一条指令地址,开始新一轮的循环,下一个指令周期将顺序取出下一条指令。
    //重复 1~5
    //遇hlt(holt on)中止
    因为每个步骤均可能会使用不一样硬件完成,每次只执行一条指令, 依次执行效率过低(致使其余硬件中断),所以发明了流水线技术来执行指令。

    流水线技术是一种将指令分解为多步,并让不一样指令的各步操做重叠,从而实现几条指令并行处理。

指令1 IF ID EX MEN WB
指令2 IF ID EX MEN WB架构

指令的每一步都由不一样的硬件完成,假设每一步耗时1ms,执行完一条指令需耗时5ms,每条指令都按顺序执行,那两条指令则需10ms。
可是经过流水线在指令1刚执行完IF,执行IF的硬件立马就开始执行指令2的IF,这样指令2只须要等1ms,两个指令执行完只须要6ms,效率会有提高巨大!
因此经过流水线技术,可使得CPU高效执行,当流水线满载时,全部硬件都有序高效执行,可是一旦中断,全部硬件设备都会进入一个停顿期,再次满载
须要几个周期,所以性能损失会比较大,因此必须想办法尽可能不让流水线中断!
此时,指令重排的重要性就此体现出来。固然,指令重排只是减小中断的一种技术,实际上,在CPU设计中还会使用更多的软硬件技术来防止中断。app

如今来看一下代码 A=B+C 是怎么执行的
现有R1,R2,R3三个寄存器,
LW R1,B IF ID EX MEN WB(加载B到R1中)
LW R2,C IF ID EX MEN WB(加载C到R2中)
ADD R3,R2,R1 IF ID × EX MEN WB(R1,R2相加放到R3)
SW A,R3 IF ID x EX MEN WB(把R3 的值保存到变量A)
在ADD指令执行中有个x,表示中断、停顿,ADD为何要在这里停顿一下呢?由于这时C还没加载到R2中,只能等待,而这个等待使得后边的全部指令都会停顿一下。
这个停顿能够避免吗?固然是能够的,经过指令重排就能够实现,再看一下下面的例子:jvm

执行A=B+C;D=E-F;
经过将D=E-F执行的指令顺序提早,从而消除因等待加载完毕的时间。
一、LW Rb,B IF ID EX MEN WB
二、LW Rc,C IF ID EX MEN WB
三、LW Re,E IF ID EX MEN WB
四、ADD Ra,Rb,Rc IF ID EX MEN WB
五、LW Rf,F IF ID EX MEN WB
六、SW A,Ra IF ID EX MEN WB
七、SUB Rd,Re,Rf IF ID EX MEN WB
八、SW D,Rd IF ID EX MEN WB
在CPU硬件中断停顿等待的时候 能够加载别的数据,更加有效利用资源,节约时间。若是不指令重排则白白等待,效率较低。函数

编译器优化
主要指jvm层面的, 以下代码, 在jvm client模式很快就跳出了while循环, 而在server模式下运行, 永远不会中止
`/**oop

  • Created by Administrator on 2020/11/19
    */
    public class VisibilityTest extends Thread {
    private boolean stop;性能

    public void run() {
    int i = 0;
    while (!stop) {
    i++;
    }
    System.out.println("finish loop,i=" + i);
    }优化

    public void stopIt() {
    stop = true;
    }线程

    public boolean getStop() {
    return stop;
    }

    public static void main(String[] args) throws Exception {
    VisibilityTest v = new VisibilityTest();
    v.start();
    Thread.sleep(1000);
    v.stopIt();
    Thread.sleep(2000);
    System.out.println("finish main");
    System.out.println(v.getStop());
    }
    }`

    二者区别在于当jvm运行在-client模式的时候,使用的是一个代号为C1的轻量级编译器,而-server模式启动的虚拟机采用相对重量级,代号为C2的编译器. C2比C1编译器编译的相对完全,会致使程序启动慢, 但服务起来以后, 性能更高, 同时有可能带来可见性问题.

再来看两个从Java语言规范中摘取的例子, 也是涉及到编译器优化重排, 这里再也不作详细解释,可查询相关文档
例子1中有可能出现r2 = 2 而且 r1 = 1;

例子2中是r2, r5值由于都是=r1.x, 编译器会使用向前替换, 把r5指向到r2, 最终可能致使r2=r5=0, r4 = 3;

  • 禁止乱序
    CPU层面
    在Intel架构中。利用原语指令(SFENCE,LFENCE,MFENCE) 或者锁总线方式。

    sfence指令为写屏障(Store Barrier),做用是:
    保证了sfence先后Store指令的顺序,防止Store重排序
    经过刷新Store Buffer保证sfence以前的Store要指令对全局可见

lfence指令读屏障(Load Barrier),做用是:
保证了lfence先后的Load指令的顺序,防止Load重排序
刷新Load Buffer

mfence指令全屏障(Full Barrier),做用是:
保证了mfence先后的Store和Load指令的顺序,防止Store和Load重排序
保证了mfence以后的Store指令全局可见以前,mfence以前的Store指令要先全局可见

JVM层级:8个hanppens-before原则 4个内存屏障 (LL LS SL SS)
Happen-Before先行发生规则
若是光靠sychronized和volatile来保证程序执行过程当中的原子性, 有序性, 可见性, 那么代码将会变得异常繁琐.
JMM提供了8个Happen-Before规则来约束数据之间是否存在竞争, 线程环境是否安全, 具体以下:

  1. 顺序原则:一个线程内保证语义的串行性; a = 1; b = a + 1;
  2. volatile规则:volatile变量的写,先发生于读,这保证了volatile变量的可见性,
  3. 锁规则:解锁(unlock)必然发生在随后的加锁(lock)前.
  4. 传递性:A先于B,B先于C,那么A必然先于C.
  5. 线程的start()方法先于它的每个动做.
  6. 线程的全部操做先于线程的终结(Thread.join()).
  7. 线程的中断(interrupt())先于被中断线程的代码.
  8. 对象的构造函数执行结束先于finalize()方法.

4个内存屏障 (LL LS SL SS)
LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操做要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操做执行前,保证Store1的写入操做对其它处理器可见。
LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操做被刷出前,保证Load1要读取的数据被读取完毕。
StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续全部读取操做执行前,保证Store1的写入对全部处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。

as-if-serial
As-if-serial语义的意思是,全部的动做(Action)均可觉得了优化而被重排序,可是必须保证它们重排序后的结果和程序代码自己的应有结果是一致的。Java编译器、运行时和处理器都会保证单线程下的as-if-serial语义。