內存屏障與volatile內存語義的實現

內存屏障

爲了保證內存可見性,java 編譯器在生成指令序列的適當位置會插入內存屏障指令來禁止特定類型的處理器重排序。JMM 把內存屏障指令分爲下列四類:

屏障類型

指令示例

說明

LoadLoad Barriers

Load1;

LoadLoad;

Load2

確保 Load1 數據的加載,在Load2 及所有後續裝載指令的裝載之前。

StoreStore Barriers

Store1;

StoreStore;

Store2

確保 Store1 數據對其他處理器可見(刷新到內存),在 Store2 及所有後續存儲指令的存儲之前。

LoadStore Barriers

Load1;

LoadStore;

Store2

確保 Load1 數據加載,在 Store2 及所有後續的存儲指令刷新到內存之前。

StoreLoad Barriers

Store1;

StoreLoad;

Load2

確保 Store1 數據對其他處理器變得可見(指刷新到內存),在 Load2 及所有後續裝載指令的加載之前。StoreLoad Barriers 會使該屏障之前的所有內存訪問指令(存儲和加載指令)完成之後,才執行該屏障之後的內存訪問指令。

StoreLoad Barriers 是一個“全能型”的屏障,它同時具有其他三個屏障的效果。

volatile內存語義

  • 當寫一個 volatile 變量時,JMM 會把該線程對應的本地內存中的共享變量刷新到主內存。
  • 當讀一個 volatile 變量時,JMM 會把該線程對應的本地內存置爲無效。線程接下來將從主內存中讀取共享變量。

內存語義的實現

爲了實現 volatile 內存語義,JMM 會分別限制這兩種類型的重排序類型。下面是 JMM 針對編譯器制定的 volatile 重排序規則表:

舉例來說,第三行最後一個單元格的意思是:在程序順序中,當第一個操作爲普通變量的讀或寫時,如果第二個操作爲 volatile 寫,則編譯器不能重排序這兩個操作。

 從上表我們可以看出:

  • 當第二個操作是 volatile 寫時,不管第一個操作是什麼,都不能重排序。這個規則確保 volatile 寫之前的操作不會被編譯器重排序到 volatile 寫之後。
  • 當第一個操作是 volatile 讀時,不管第二個操作是什麼,都不能重排序。這個規則確保 volatile 讀之後的操作不會被編譯器重排序到 volatile 讀之前。
  • 當第一個操作是 volatile 寫,第二個操作是 volatile 讀時,不能重排序。

爲了實現 volatile 的內存語義,編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序。對於編譯器來說,發現一個最優佈置來最小化插入屏障的總數幾乎不可能,爲此,JMM 採取保守策略。下面是基於保守策略的 JMM 內存屏障插入策略:

  • 在每個 volatile 寫操作的前面插入一個 StoreStore 屏障。
  • 在每個 volatile 寫操作的後面插入一個 StoreLoad 屏障。
  • 在每個 volatile 讀操作的後面插入一個 LoadLoad 屏障。
  • 在每個 volatile 讀操作的後面插入一個 LoadStore 屏障。

下面是volatile 寫插入內存屏障後生成的指令序列示意圖

上圖中的 StoreStore 屏障可以保證在 volatile 寫之前,其前面的所有普通寫操作已經對任意處理器可見了。這是因爲 StoreStore 屏障將保障上面所有的普通寫在 volatile 寫之前刷新到主內存。

這裏比較有意思的是 volatile 寫後面的 StoreLoad 屏障。這個屏障的作用是避免 volatile 寫與後面可能有的 volatile 讀 / 寫操作重排序。因爲編譯器常常無法準確判斷在一個 volatile 寫的後面,是否需要插入一個 StoreLoad 屏障(比如,一個 volatile 寫之後方法立即 return)。
爲了保證能正確實現 volatile 的內存語義,JMM 在這裏採取了保守策略:在每個 volatile 寫的後面或在每個 volatile 讀的前面插入一個 StoreLoad 屏障。
從整體執行效率的角度考慮,JMM 選擇了在每個 volatile 寫的後面插入一個 StoreLoad 屏障。因爲 volatile 寫 - 讀內存語義的常見使用模式是:一個寫線程寫 volatile 變量,多個讀線程讀同一個 volatile 變量。當讀線程的數量大大超過寫線程時,選擇在 volatile 寫之後插入 StoreLoad 屏障將帶來可觀的執行效率的提升。從這裏我們可以看到 JMM 在實現上的一個特點:首先確保正確性,然後再去追求執行效率。

下面是volatile 讀插入內存屏障後生成的指令序列示意圖

上圖中的 LoadLoad 屏障用來禁止處理器把上面的 volatile 讀與下面的普通讀重排序。LoadStore 屏障用來禁止處理器把上面的 volatile 讀與下面的普通寫重排序。

在實際執行時,只要不改變 volatile 寫 - 讀的內存語義,編譯器可以根據具體情況省略不必要的屏障。下面我們通過具體的示例代碼來說明:

class VolatileBarrierExample {
    int a;
    volatile int v1 = 1;
    volatile int v2 = 2;

    void readAndWrite() {
        int i = v1;           // 第一個 volatile 讀 
        int j = v2;           // 第二個 volatile 讀 
        a = i + j;            // 普通寫 
        v1 = i + 1;          // 第一個 volatile 寫 
        v2 = j * 2;          // 第二個 volatile 寫 
    }
    …                    // 其他方法 
}

針對 readAndWrite() 方法,編譯器在生成字節碼時可以做如下的優化:

注意,最後的 StoreLoad 屏障不能省略。因爲第二個 volatile 寫之後,方法立即 return。此時編譯器可能無法準確斷定後面是否會有 volatile 讀或寫,爲了安全起見,編譯器常常會在這裏插入一個 StoreLoad 屏障。

由於不同的處理器有不同“鬆緊度”的處理器內存模型,內存屏障的插入還可以根據具體的處理器內存模型繼續優化。以 x86 處理器爲例,上圖中除最後的 StoreLoad 屏障外,其它的屏障都會被省略。

x86 處理器僅會對寫 - 讀操作做重排序。X86 不會對讀 - 讀,讀 - 寫和寫 - 寫操作做重排序,因此在 x86 處理器中會省略掉這三種操作類型對應的內存屏障。在 x86 中,JMM 僅需在 volatile 寫後面插入一個 StoreLoad 屏障即可正確實現 volatile 寫 - 讀的內存語義。這意味着在 x86 處理器中,volatile 寫的開銷比 volatile 讀的開銷會大很多(因爲執行 StoreLoad 屏障開銷會比較大)。

參考鏈接:http://ifeve.com/java-memory-model-0/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章