指令重排

指令重排
談到指令重排,首先來了解一下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語義。

相關文章
相關標籤/搜索