徹底理解volatile

你聽___發表於2018-05-02

徹底理解volatile

1. volatile簡介

在上一篇文章中我們深入理解了java關鍵字synchronized,我們知道在java中還有一大神器就是關鍵volatile,可以說是和synchronized各領風騷,其中奧妙,我們來共同探討下。

通過上一篇的文章我們瞭解到synchronized是阻塞式同步,線上程競爭激烈的情況下會升級為重量級鎖。而volatile就可以說是java虛擬機器提供的最輕量級的同步機制。但它同時不容易被正確理解,也至於在併發程式設計中很多程式設計師遇到執行緒安全的問題就會使用synchronized。Java記憶體模型告訴我們,各個執行緒會將共享變數從主記憶體中拷貝到工作記憶體,然後執行引擎會基於工作記憶體中的資料進行操作處理。執行緒在工作記憶體進行操作後何時會寫到主記憶體中?這個時機對普通變數是沒有規定的,而針對volatile修飾的變數給java虛擬機器特殊的約定,執行緒對volatile變數的修改會立刻被其他執行緒所感知,即不會出現資料髒讀的現象,從而保證資料的“可見性”。

現在我們有了一個大概的印象就是:被volatile修飾的變數能夠保證每個執行緒能夠獲取該變數的最新值,從而避免出現資料髒讀的現象。

2. volatile實現原理

volatile是怎樣實現了?比如一個很簡單的Java程式碼:

instance = new Instancce() //instance是volatile變數

在生成彙編程式碼時會在volatile修飾的共享變數進行寫操作的時候會多出Lock字首的指令(具體的大家可以使用一些工具去看一下,這裡我就只把結果說出來)。我們想這個Lock指令肯定有神奇的地方,那麼Lock字首的指令在多核處理器下會發現什麼事情了?主要有這兩個方面的影響:

  1. 將當前處理器快取行的資料寫回系統記憶體;
  2. 這個寫回記憶體的操作會使得其他CPU裡快取了該記憶體地址的資料無效

為了提高處理速度,處理器不直接和記憶體進行通訊,而是先將系統記憶體的資料讀到內部快取(L1,L2或其他)後再進行操作,但操作完不知道何時會寫到記憶體。如果對宣告瞭volatile的變數進行寫操作,JVM就會向處理器傳送一條Lock字首的指令,將這個變數所在快取行的資料寫回到系統記憶體。但是,就算寫回到記憶體,如果其他處理器快取的值還是舊的,再執行計算操作就會有問題。所以,在多處理器下,為了保證各個處理器的快取是一致的,就會實現快取一致性協議,每個處理器通過嗅探在匯流排上傳播的資料來檢查自己快取的值是不是過期了,當處理器發現自己快取行對應的記憶體地址被修改,就會將當前處理器的快取行設定成無效狀態,當處理器對這個資料進行修改操作的時候,會重新從系統記憶體中把資料讀到處理器快取裡。因此,經過分析我們可以得出如下結論:

  1. Lock字首的指令會引起處理器快取寫回記憶體;
  2. 一個處理器的快取回寫到記憶體會導致其他處理器的快取失效;
  3. 當處理器發現本地快取失效後,就會從記憶體中重讀該變數資料,即可以獲取當前最新值。

這樣針對volatile變數通過這樣的機制就使得每個執行緒都能獲得該變數的最新值。

3. volatile的happens-before關係

經過上面的分析,我們已經知道了volatile變數可以通過快取一致性協議保證每個執行緒都能獲得最新值,即滿足資料的“可見性”。我們繼續延續上一篇分析問題的方式(我一直認為思考問題的方式是屬於自己,也才是最重要的,也在不斷培養這方面的能力),我一直將併發分析的切入點分為兩個核心,三大性質。兩大核心:JMM記憶體模型(主記憶體和工作記憶體)以及happens-before;三條性質:原子性,可見性,有序性(關於三大性質的總結在以後得文章會和大家共同探討)。廢話不多說,先來看兩個核心之一:volatile的happens-before關係。

在六條happens-before規則中有一條是:**volatile變數規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀。**下面我們結合具體的程式碼,我們利用這條規則推導下:

public class VolatileExample {
    private int a = 0;
    private volatile boolean flag = false;
    public void writer(){
        a = 1;          //1
        flag = true;   //2
    }
    public void reader(){
        if(flag){      //3
            int i = a; //4
        }
    }
}
複製程式碼

上面的例項程式碼對應的happens-before關係如下圖所示:

VolatileExample的happens-before關係推導

加鎖執行緒A先執行writer方法,然後執行緒B執行reader方法圖中每一個箭頭兩個節點就程式碼一個happens-before關係,黑色的代表根據程式順序規則推匯出來,紅色的是根據volatile變數的寫happens-before 於任意後續對volatile變數的讀,而藍色的就是根據傳遞性規則推匯出來的。這裡的2 happen-before 3,同樣根據happens-before規則定義:如果A happens-before B,則A的執行結果對B可見,並且A的執行順序先於B的執行順序,我們可以知道操作2執行結果對操作3來說是可見的,也就是說當執行緒A將volatile變數 flag更改為true後執行緒B就能夠迅速感知。

4. volatile的記憶體語義

還是按照兩個核心的分析方式,分析完happens-before關係後我們現在就來進一步分析volatile的記憶體語義(按照這種方式去學習,會不會讓大家對知識能夠把握的更深,而不至於不知所措,如果大家認同我的這種方式,不妨給個贊,小弟在此謝過,對我是個鼓勵)。還是以上面的程式碼為例,假設執行緒A先執行writer方法,執行緒B隨後執行reader方法,初始時執行緒的本地記憶體中flag和a都是初始狀態,下圖是執行緒A執行volatile寫後的狀態圖。

執行緒A執行volatile寫後的記憶體狀態圖

當volatile變數寫後,執行緒中本地記憶體中共享變數就會置為失效的狀態,因此執行緒B再需要讀取從主記憶體中去讀取該變數的最新值。下圖就展示了執行緒B讀取同一個volatile變數的記憶體變化示意圖。

執行緒B讀volatile後的記憶體狀態圖

從橫向來看,執行緒A和執行緒B之間進行了一次通訊,執行緒A在寫volatile變數時,實際上就像是給B傳送了一個訊息告訴執行緒B你現在的值都是舊的了,然後執行緒B讀這個volatile變數時就像是接收了執行緒A剛剛傳送的訊息。既然是舊的了,那執行緒B該怎麼辦了?自然而然就只能去主記憶體去取啦。

好的,我們現在兩個核心:happens-before以及記憶體語義現在已經都瞭解清楚了。是不是還不過癮,突然發現原來自己會這麼愛學習(微笑臉),那我們下面就再來一點乾貨----volatile記憶體語義的實現。

4.1 volatile的記憶體語義實現

我們都知道,為了效能優化,JMM在不改變正確語義的前提下,會允許編譯器和處理器對指令序列進行重排序,那如果想阻止重排序要怎麼辦了?答案是可以新增記憶體屏障。

記憶體屏障

JMM記憶體屏障分為四類見下圖,

記憶體屏障分類表

java編譯器會在生成指令系列時在適當的位置會插入記憶體屏障指令來禁止特定型別的處理器重排序。為了實現volatile的記憶體語義,JMM會限制特定型別的編譯器和處理器重排序,JMM會針對編譯器制定volatile重排序規則表:

volatile重排序規則表

"NO"表示禁止重排序。為了實現volatile記憶體語義時,編譯器在生成位元組碼時,會在指令序列中插入記憶體屏障來禁止特定型別的處理器重排序。對於編譯器來說,發現一個最優佈置來最小化插入屏障的總數幾乎是不可能的,為此,JMM採取了保守策略:

  1. 在每個volatile寫操作的前面插入一個StoreStore屏障;
  2. 在每個volatile寫操作的後面插入一個StoreLoad屏障;
  3. 在每個volatile讀操作的後面插入一個LoadLoad屏障;
  4. 在每個volatile讀操作的後面插入一個LoadStore屏障。

需要注意的是:volatile寫是在前面和後面分別插入記憶體屏障,而volatile讀操作是在後面插入兩個記憶體屏障

StoreStore屏障:禁止上面的普通寫和下面的volatile寫重排序;

StoreLoad屏障:防止上面的volatile寫與下面可能有的volatile讀/寫重排序

LoadLoad屏障:禁止下面所有的普通讀操作和上面的volatile讀重排序

LoadStore屏障:禁止下面所有的普通寫操作和上面的volatile讀重排序

下面以兩個示意圖進行理解,圖片摘自相當好的一本書《java併發程式設計的藝術》。

volatile寫插入記憶體屏障示意圖

volatile讀插入記憶體屏障示意圖

5. 一個示例

我們現在已經理解volatile的精華了,文章開頭的那個問題我想現在我們都能給出答案了。更正後的程式碼為:

public class VolatileDemo {
    private static volatile boolean isOver = false;

    public static void main(String[] args) {
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                while (!isOver) ;
            }
        });
        thread.start();
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        isOver = true;
    }
}
複製程式碼

注意不同點,現在已經將isOver設定成了volatile變數,這樣在main執行緒中將isOver改為了true後,thread的工作記憶體該變數值就會失效,從而需要再次從主記憶體中讀取該值,現在能夠讀出isOver最新值為true從而能夠結束在thread裡的死迴圈,從而能夠順利停止掉thread執行緒。現在問題也解決了,知識也學到了:)。(如果覺得還不錯,請點贊,是對我的一個鼓勵。)

參考文獻

《java併發程式設計的藝術》

相關文章