什麼是指令重排?

ML李嘉圖發表於2021-11-29

案例

public class MemoryReorderingExample {
    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;
        while (true) {
            i++;
            x = 0;
            y = 0;
            a = 0;
            b = 0;
            Thread t1 = new Thread(() -> {
                a = 1;
                x = b;
            });
            Thread t2 = new Thread(() -> {
                b = 1;
                y = a;
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();
            String result = "第" + i + "次(" + x + "," + y + ")";
            if (x == 0 && y == 0) {
                System.out.println(result);
                break;
            }
        }
    }
}

結果

第多少多少次(0,0)
  • 定義四個int型別的變數,初始化都為0。
  • 定義兩個執行緒t1、t2
    • t1執行緒修改a和x的值
    • t2執行緒修改b和y的值
    • 分別啟動兩個執行緒。
  • 正常情況下,x和y的值,會根據t1和t2執行緒的執行情況來決定。
    • 如果t1執行緒優先執行,那麼得到的結果是x=0、y=1。
    • 如果t2執行緒優先執行,那麼得到的結果是x=1、y=0。
    • 如果t1和t2執行緒同時執行,那麼得到的結果是x=1、y=1。

為什麼?結果為什麼是 0 和 0。

其實這就是所謂的指令重排序問題,假設上面的程式碼通過指令重排序之後,變成下面這種結構:

Thread t1=new Thread(()->{
    x=b; //指令重排序
    a=1;
});
Thread t2=new Thread(()->{
    y=a; //指令重排序
    b=1;
});

經過重排序之後,如果t1和t2執行緒同時執行,就會得到x=0、y=0的結果,這個結果從人的視角來看,就有點類似於t1執行緒中a=1的修改結果對t2執行緒不可見,同樣t2執行緒中b=1的執行結果對t1執行緒不可見。

什麼是指令重排?

指令重排序是指編譯器或CPU為了優化程式的執行效能而對指令進行重新排序的一種手段,重排序會帶來可見性問題,所以在多執行緒開發中必須要關注並規避重排序。

從原始碼到最終執行的指令,會經過如下兩個階段的重排序。

第一階段,編譯器重排序,就是在編譯過程中,編譯器根據上下文分析對指令進行重排序,目的是減少CPU和記憶體的互動,重排序之後儘可能保證CPU從暫存器或快取行中讀取資料。

在前面分析JIT優化中提到的迴圈表示式外提(Loop Expression Hoisting)就是編譯器層面的重排序,從CPU層面來說,避免了處理器每次都去記憶體中載入stop,減少了處理器和記憶體的互動開銷。

第二階段,處理器重排序,處理器重排序分為兩個部分。

  • 並行指令集重排序,這是處理器優化的一種,處理器可以改變指令的執行順序。
  • 記憶體系統重排序,這是處理器引入Store Buffer緩衝區延時寫入產生的指令執行順序不一致的問題。

擴充套件

什麼是JIT?

1、動態編譯(dynamic compilation)指的是“在執行時進行編譯”;與之相對的是事前編譯(ahead-of-time compilation,簡稱AOT),也叫靜態編譯(static compilation)。

2、JIT 編譯(just-in-time compilation)狹義來說是當某段程式碼即將第一次被執行時進行編譯,因而叫“即時編譯”。JIT編譯是動態編譯的一種特例。JIT編譯一詞後來被泛化,時常與動態編譯等價;但要注意廣義與狹義的JIT編譯所指的區別。
3、自適應動態編譯(adaptive dynamic compilation)也是一種動態編譯,但它通常執行的時機比JIT編譯遲,先讓程式“以某種式”先執行起來,收集一些資訊之後再做動態編譯。這樣的編譯可以更加優化。

什麼是指令重排?

在部分商用虛擬機器中(如HotSpot),Java程式最初是通過直譯器(Interpreter)進行解釋執行的,當虛擬機器發現某個方法或程式碼塊的執行特別頻繁時,就會把這些程式碼認定為“熱點程式碼”。為了提高熱點程式碼的執行效率,在執行時,虛擬機器將會把這些程式碼編譯成與本地平臺相關的機器碼,並進行各種層次的優化,完成這個任務的編譯器稱為即時編譯器(Just In Time Compiler,下文統稱JIT編譯器)。

即時編譯器並不是虛擬機器必須的部分,Java虛擬機器規範並沒有規定Java虛擬機器內必須要有即時編譯器存在,更沒有限定或指導即時編譯器應該如何去實現。但是,即時編譯器編譯效能的好壞、程式碼優化程度的高低卻是衡量一款商用虛擬機器優秀與否的最關鍵的指標之一,它也是虛擬機器中最核心且最能體現虛擬機器技術水平的部分。

由於Java虛擬機器規範並沒有具體的約束規則去限制即使編譯器應該如何實現,所以這部分功能完全是與虛擬機器具體實現相關的內容,如無特殊說明,我們提到的編譯器、即時編譯器都是指Hotspot虛擬機器內的即時編譯器,虛擬機器也是特指HotSpot虛擬機器。

為什麼HotSpot虛擬機器要使用直譯器與編譯器並存的架構?

儘管並不是所有的Java虛擬機器都採用直譯器與編譯器並存的架構,但許多主流的商用虛擬機器(如HotSpot),都同時包含直譯器和編譯器。

直譯器與編譯器兩者各有優勢:當程式需要 迅速啟動和執行 的時候,直譯器可以首先發揮作用,省去編譯的時間,立即執行。在程式執行後,隨著時間的推移,編譯器逐漸發揮作用,把越來越多的程式碼編譯成原生程式碼之後,可以獲取 更高的執行效率 。當程式執行環境中 記憶體資源限制較大 (如部分嵌入式系統中),可以使用 直譯器執行節約記憶體 ,反之可以使用 編譯執行來提升效率 。此外,如果編譯後出現“罕見陷阱”,可以通過逆優化退回到解釋執行。

HotSpot虛擬機器中內建了兩個即時編譯器:Client Complier和Server Complier,簡稱為C1、C2編譯器,分別用在客戶端和服務端。目前主流的HotSpot虛擬機器中預設是採用直譯器與其中一個編譯器直接配合的方式工作。程式使用哪個編譯器,取決於虛擬機器執行的模式。HotSpot虛擬機器會根據自身版本與宿主機器的硬體效能自動選擇執行模式,使用者也可以使用“-client”或“-server”引數去強制指定虛擬機器執行在Client模式或Server模式。

用Client Complier獲取更高的編譯速度,用Server Complier 來獲取更好的編譯質量。為什麼提供多個即時編譯器與為什麼提供多個垃圾收集器類似,都是為了適應不同的應用場景。

編譯的時間開銷

直譯器的執行,抽象的看是這樣的:

*輸入的程式碼 -> [ 直譯器 解釋執行 ] -> 執行結果

而要JIT編譯然後再執行的話,抽象的看則是:

*輸入的程式碼 -> [ 編譯器 編譯 ] -> 編譯後的程式碼 -> [ 執行 ] -> 執行結果
*說JIT比解釋快,其實說的是“執行編譯後的程式碼”比“直譯器解釋執行”要快,並不是說“編譯”這個動作比“解釋”這個動作快。

JIT編譯再怎麼快,至少也比解釋執行一次略慢一些,而要得到最後的執行結果還得再經過一個“執行編譯後的程式碼”的過程。所以,對“只執行一次”的程式碼而言,解釋執行其實總是比JIT編譯執行要快。

怎麼算是“只執行一次的程式碼”呢?粗略說,下面兩個條件同時滿足時就是嚴格的“只執行一次”
1、只被呼叫一次,例如類的構造器(class initializer,())
2、沒有迴圈

對只執行一次的程式碼做JIT編譯再執行,可以說是得不償失。
對只執行少量次數的程式碼,JIT編譯帶來的執行速度的提升也未必能抵消掉最初編譯帶來的開銷。

只有對頻繁執行的程式碼,JIT編譯才能保證有正面的收益。

什麼是並行指令集?

在處理器核心中一般會有多個執行單元,比如算術邏輯單元、位移單元等。

  • 在引入並行指令集之前,CPU在每個時鐘週期內只能執行單條指令,也就是說只有一個執行單元在工作,其他執行單元處於空閒狀態;
  • 在引入並行指令集之後,CPU在一個時鐘週期內可以同時分配多條指令在不同的執行單元中執行。

那麼什麼是並行指令集的重排序呢?

如下圖所示,假設某一段程式有多條指令,不同指令的執行實現也不同。

什麼是指令重排?

對於一條從記憶體中讀取資料的指令,CPU的某個執行單元在執行這條指令並等到返回結果之前,按照CPU的執行速度來說它足夠處理幾百條其他指令,而CPU為了提高執行效率,會根據單元電路的空閒狀態和指令能否提前執行的情況進行分析,把那些指令地址順序靠後的指令提前到讀取記憶體指令之前完成。

實際上,這種優化的本質是通過提前執行其他可執行指令來填補CPU的時間空隙,然後在結束時重新排序運算結果,從而實現指令順序執行的執行結果。

as-if-serial語義

as-if-serial表示所有的程式指令都可以因為優化而被重排序,但是在優化的過程中必須要保證是在單執行緒環境下,重排序之後的執行結果和程式程式碼本身預期的執行結果一致,Java編譯器、CPU指令重排序都需要保證在單執行緒環境下的as-if-serial語義是正確的。

可能有些讀者會有疑惑,既然能夠保證在單執行緒環境下的順序性,那為什麼還會存在指令重排序呢?在JSR-133規範中,原文是這麼說的。


The compiler, runtime, and hardware are supposed to conspire to create the illusion of as-if-serial semantics, which means that in a single-threaded program, the program should not be able to observe the effects of reorderings.However, reorderings can come into play in incorrectly synchronized multithreaded programs, where one thread is able to observe the effects of other threads, and may be able to detect that variable accesses become visible to other threads in a different order than executed or specified in the program.

編譯器、執行時和硬體應該合力創造as-if-serial語義的錯覺,這意味著在單執行緒程式中,程式不應該能夠觀察到重新排序的效果。然而,重新排序可以 在不正確同步的多執行緒程式中發揮作用,其中一個執行緒能夠觀察其他執行緒的影響,並且可能能夠檢測到變數訪問對其他執行緒以與程式中執行或指定的順序不同的順序變得可見。


as-if-serial語義允許重排序,CPU層面的指令優化依然存在。在單執行緒中,這些優化並不會影響整體的執行結果,在多執行緒中,重排序會帶來可見性問題。

另外,為了保證as-if-serial語義是正確的,編譯器和處理器不會對存在依賴關係的操作進行指令重排序,因為這樣會影響程式的執行結果。我們來看下面這段程式碼:

public void execute(){
    int x=10;  		  //1
    int y=5;   		  //2
    int c=x+y; 		//3
}

上述程式碼按照正常的執行順序應該是1、2、3,在多執行緒環境下,可能會出現2、1、3這樣的執行順序,但是一定不會出現3、2、1這樣的順序,因為3與1和2存在資料依賴關係,一旦重排序,就無法保證as-if-serial語義是正確的。

相關文章