【死磕Java併發】-----Java記憶體模型之重排序

chenssy發表於2021-12-27

在執行程式時,為了提供效能,處理器和編譯器常常會對指令進行重排序,但是不能隨意重排序,不是你想怎麼排序就怎麼排序,它需要滿足以下兩個條件:

  1. 在單執行緒環境下不能改變程式執行的結果;
  2. 存在資料依賴關係的不允許重排序

如果看過LZ上篇部落格的就會知道,其實這兩點可以歸結於一點:無法通過happens-before原則推匯出來的,JMM允許任意的排序。

as-if-serial語義

as-if-serial語義的意思是,所有的操作均可以為了優化而被重排序,但是你必須要保證重排序後執行的結果不能被改變,編譯器、runtime、處理器都必須遵守as-if-serial語義。注意as-if-serial只保證單執行緒環境,多執行緒環境下無效。

下面我們用一個簡單的示例來說明:

int a = 1 ;      //A
int b = 2 ;      //B
int c = a + b;   //C

A、B、C三個操作存在如下關係:A、B不存在資料依賴關係,A和C、B和C存在資料依賴關係,因此在進行重排序的時候,A、B可以隨意排序,但是必須位於C的前面,執行順序可以是A --> B --> C或者B --> A --> C。但是無論是何種執行順序最終的結果C總是等於3。

as-if-serail語義把單執行緒程式保護起來了,它可以保證在重排序的前提下程式的最終結果始終都是一致的。

其實對於上段程式碼,他們存在這樣的happen-before關係:

  1. A happens-before B
  2. B happens-before C
  3. A happens-before C

1、2是程式順序次序規則,3是傳遞性。但是,不是說通過重排序,B可能會排在A之前執行麼,為何還會存在存在A happens-beforeB呢?這裡再次申明A happens-before B不是A一定會在B之前執行,而是A的對B可見,但是相對於這個程式A的執行結果不需要對B可見,且他們重排序後不會影響結果,所以JMM不會認為這種重排序非法。

我們需要明白這點:在不改變程式執行結果的前提下,儘可能提高程式的執行效率。

下面我們在看一段有意思的程式碼:

public class RecordExample1 {
    public static void main(String[] args){
        int a = 1;
        int b = 2;

        try {
            a = 3;           //A
            b = 1 / 0;       //B
        } catch (Exception e) {

        } finally {
            System.out.println("a = " + a);
        }
    }
}

按照重排序的規則,操作A與操作B有可能會進行重排序,如果重排序了,B會丟擲異常( / by zero),此時A語句一定會執行不到,那麼a還會等於3麼?如果按照as-if-serial原則它就改變了程式的結果。其實JVM對異常做了一種特殊的處理,為了保證as-if-serial語義,Java異常處理機制對重排序做了一種特殊的處理:JIT在重排序時會在catch語句中插入錯誤代償程式碼(a = 3),這樣做雖然會導致cathc裡面的邏輯變得複雜,但是JIT優化原則是:儘可能地優化程式正常執行下的邏輯,哪怕以catch塊邏輯變得複雜為代價。

重排序對多執行緒的影響

在單執行緒環境下由於as-if-serial語義,重排序無法影響最終的結果,但是對於多執行緒環境呢?

如下程式碼(volatile的經典用法):

public class RecordExample2 {
    int a = 0;
    boolean flag = false;

    /**
     * A執行緒執行
     */
    public void writer(){
        a = 1;                  // 1
        flag = true;            // 2
    }

    /**
     * B執行緒執行
     */
    public void read(){
        if(flag){                  // 3
           int i = a + a;          // 4
        }
    }

}

A執行緒執行writer(),執行緒B執行read(),執行緒B在執行時能否讀到 a = 1 呢?答案是不一定(注:X86CPU不支援寫寫重排序,如果是在x86上面操作,這個一定會是a=1,LZ搞了好久都沒有測試出來,最後查資料才發現)。

由於操作1 和操作2 之間沒有資料依賴性,所以可以進行重排序處理,操作3 和操作4 之間也沒有資料依賴性,他們亦可以進行重排序,但是操作3 和操作4 之間存在控制依賴性。假如操作1 和操作2 之間重排序:

按照這種執行順序執行緒B肯定讀不到執行緒A設定的a值,在這裡多執行緒的語義就已經被重排序破壞了。

操作3 和操作4 之間也可以重排序,這裡就不闡述了。但是他們之間存在一個控制依賴的關係,因為只有操作3 成立操作4 才會執行。當程式碼中存在控制依賴性時,會影響指令序列的執行的並行度,所以編譯器和處理器會採用猜測執行來克服控制依賴對並行度的影響。假如操作3 和操作4重排序了,操作4 先執行,則先會把計算結果臨時儲存到重排序緩衝中,當操作3 為真時才會將計算結果寫入變數i中

通過上面的分析,重排序不會影響單執行緒環境的執行結果,但是會破壞多執行緒的執行語義

參考資料

  1. 周志明 :《深入理解Java虛擬機器》
  2. 方騰飛:《Java併發程式設計的藝術》

相關文章