Java內存模型之volatile(2)

前言

本小節主要介紹3個同步原語(synchronized、volatile和final)的內存語義及重排序規則在處理器中的實現;Java內存模型的設計,主要介紹Java內存模型的設計原理,及其與處理器內存模型和順序一致性內存模型的關係。

volatile的內存語義

當聲明共享變量爲volatile後,對這個變量的讀/寫將會很特別。爲了揭開volatile的神祕面 紗,下面將介紹volatile的內存語義及volatile內存語義的實現。

volatile的特性

可見性。對一個volatile變量的讀,總是能看到(任意線程)對這個volatile變量最後的寫 入。
原子性:對任意單個volatile變量的讀/寫具有原子性,但類似於volatile++這種複合操作不 具有原子性。

volatile寫-讀建立的happens-before關係

從JSR-133開始(即從JDK5開始),volatile變量的寫-讀可以實現線程之間的通信。
從內存語義的角度來說,volatile的寫-讀與鎖的釋放-獲取有相同的內存效果:volatile寫和 鎖的釋放有相同的內存語義;volatile讀與鎖的獲取有相同的內存語義。

volatile寫-讀的內存語義

volatile寫的內存語義如下。

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

下面對volatile寫和volatile讀的內存語義做個總結。

·線程A寫一個volatile變量,實質上是線程A向接下來將要讀這個volatile變量的某個線程 發出了(其對共享變量所做修改的)消息。
·線程B讀一個volatile變量,實質上是線程B接收了之前某個線程發出的(在寫這個volatile 變量之前對共享變量所做修改的)消息。
·線程A寫一個volatile變量,隨後線程B讀這個volatile變量,這個過程實質上是線程A通過 主內存向線程B發送消息。

volatile內存語義的實現

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

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

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

在實際執行時,只要不改變 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屏障外,其他的屏障都會被省略。
前面保守策略下的volatile讀和寫,在X86處理器平臺可以優化成如圖下圖所示。 前文提到過,X86處理器僅會對寫-讀操作做重排序。X86不會對讀-讀、讀-寫和寫-寫操作做重排序,因此在X86處理器中會省略掉這3種操作類型對應的內存屏障。在X86中,JMM僅需在volatile寫後面插入一個StoreLoad屏障即可正確實現volatile寫-讀的內存語義。這意味着在X86處理器中,volatile寫的開銷比volatile讀的開銷會大很多(因爲執行StoreLoad屏障開銷會比較大)。

在這裏插入圖片描述

JSR-133爲什麼要增強volatile的內存語義

在JSR-133之前的舊Java內存模型中,雖然不允許volatile變量之間重排序,但舊的Java內 存模型允許volatile變量與普通變量重排序。在舊的內存模型中,VolatileExample示例程序可能 被重排序成下列時序來執行,如圖上圖所示。上圖線程執行時序圖 在舊的內存模型中,當1和2之間沒有數據依賴關係時,1和2之間就可能被重排序(3和4類 似)。其結果就是:讀線程B執行4時,不一定能看到寫線程A在執行1時對共享變量的修改。
因此,在舊的內存模型中,volatile的寫-讀沒有鎖的釋放-獲所具有的內存語義。爲了提供 一種比鎖更輕量級的線程之間通信的機制,JSR-133專家組決定增強volatile的內存語義:嚴格 限制編譯器和處理器對volatile變量與普通變量的重排序,確保volatile的寫-讀和鎖的釋放-獲 取具有相同的內存語義。從編譯器重排序規則和處理器內存屏障插入策略來看,只要volatile 變量與普通變量之間的重排序可能會破壞volatile的內存語義,這種重排序就會被編譯器重排 序規則和處理器內存屏障插入策略禁止。 由於volatile僅僅保證對單個volatile變量的讀/寫具有原子性,而鎖的互斥執行的特性可以 確保對整個臨界區代碼的執行具有原子性。在功能上,鎖比volatile更強大;在可伸縮性和執行 性能上,volatile更有優勢。如果讀者想在程序中用volatile代替鎖,請一定謹慎,具體詳情請參 閱Brian Goetz的文章《Java理論與實踐:正確使用Volatile變量》。

先贊後看,養成習慣。歡迎收看一個行走的熊貓程序猿,下期再見

關注
文章持續更新,可以微信搜索「 熊貓程序猿a 」第一時間催更
公衆號

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