指令重排

指令重排
谈到指令重排,首先来了解一下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设计中还会使用更多的软硬件技术来防止中断。

现在来看一下代码 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中,只能等待,而这个等待使得后边的所有指令都会停顿一下。
这个停顿可以避免吗?当然是可以的,通过指令重排就可以实现,再看一下下面的例子:

执行A=B+C;D=E-F;
通过将D=E-F执行的指令顺序提前,从而消除因等待加载完毕的时间。
1、LW Rb,B IF ID EX MEN WB
2、LW Rc,C IF ID EX MEN WB
3、LW Re,E IF ID EX MEN WB
4、ADD Ra,Rb,Rc IF ID EX MEN WB
5、LW Rf,F IF ID EX MEN WB
6、SW A,Ra IF ID EX MEN WB
7、SUB Rd,Re,Rf IF ID EX MEN WB
8、SW D,Rd IF ID EX MEN WB
在CPU硬件中断停顿等待的时候 可以加载别的数据,更加有效利用资源,节约时间。如果不指令重排则白白等待,效率较低。

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

  • 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语义。

posted @ 2020-11-19 21:46  白衣沽酒_绮罗生  阅读(1661)  评论(0编辑  收藏  举报