快速理解 volatile 關鍵字

水目沾發表於2019-03-30

前言

  看了很多 Java 併發程式設計書籍的目錄,volatile 在 JMM 中總是單獨拎出來作為一個章節來講,主要是因為它的特殊規則。要徹底弄懂 volatile 不太容易,但是如果從它如何解決併發程式設計中的可見性、原子性和有序性問題來學習,就能很快掌握 volatile 的作用。學習 volatile 關鍵字很有必要,Java 併發工具中的很多類都是基於 volatile 的。

volatile 特性

  在 JMM 中 volatile 的三大特性如下:

  1. 保證可見性:當寫一個 volatile 變數時,JMM會把該執行緒對應的本地記憶體中的共享變數值重新整理到主記憶體,使其他執行緒立即可見。
  2. 保證有序性:當變數被修飾為 volatile 時,JMM 會禁止讀寫該變數前後語句的大部分重排序優化,以保證變數賦值操作的順序與程式中的執行順序一致。
  3. 部分原子性:對任意單個 volatile 變數的讀/寫具有原子性,但類似於 volatile++ 這種複合操作不具有原子性。

如何保證可見性

  volatile 變數可見性很多書上都喜歡放到 happens-before 原則中來講:

    對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀。
複製程式碼

  其實我覺得這句話初看並不能很好的理解 volatile 的可見性,而且還會引入新的概念 happens-before 規則。換一種表述方式會容易理解的多,其在 JMM 中的寫和讀語義如下:

  1. 當寫一個volatile變數時,JMM會把該執行緒對應的本地記憶體中的共享變數值重新整理到主記憶體。
  2. 當讀一個volatile變數時,JMM會把該執行緒對應的本地記憶體置為無效。執行緒接下來將從主記憶體中讀取共享變數。

  這就保證了 volatile 變數的可見性,也解釋了 happens-before 中的 volatile 規則,而且需要注意的是:在寫和讀時操作的是整個工作記憶體中的共享變數,所以在讀 volatile 變數時工作記憶體中的其他共享變數也是最新的。

如何保證有序性

  volatile 的有序性可能比較晦澀,但是看完 JMM 針對編譯器制定的 volatile 重排序規則表後就會很容易理解:

快速理解 volatile 關鍵字
  由上圖 1 可知,JMM 限制了大部分情況下 volatile 變數讀寫語句前後語句的重排序,結合圖片來看看下個這個例子:

class OrderingExample {
    int x = 0;
    volatile boolean flag = false;
    public void writer() {
        x = 42; //宇宙的終極答案
        flag = true;
    }
    public void reader() {
        if (flag == true) {
            //x = ?
        }
    }
}
複製程式碼

  以上程式碼在並發程式設計前傳 中講有序性的時候也貼過,這裡將 flag 定義成 volatile。如果執行緒 A 先執行完 writer(),執行緒 B 後執行到 reader() 中的 x= 的時候,x 一定等於 42(JDK 1.5 以後),原因如下:

  參考圖 1,可以看出普通變數的寫不能重排到 volatile 變數的寫後面,所以便不存在有序性問題。 其他禁止重排序規則參考圖 1 進行類推,整個規則讓 JMM 在多執行緒環境下保證了 volatile 變數的有序性。在本規則中有以下兩點需要注意:

  1. 只要 volatile 變數與普通變數之間的重排序可能會破壞 volatile 的記憶體語義,這種重排序就會被編譯器重排序規則和處理器記憶體屏障插入策略禁止。換句話說,如果沒有破壞 volatile 的記憶體語義則可以重排序,參考圖 1 空白格子對應的規則。

  2. 為了實現volatile的記憶體語義,編譯器在生成位元組碼時,會在指令序列中插入記憶體屏障來禁止特定型別的處理器重排序,細則如下:

     在每個volatile寫操作的前面插入一StoreStore屏障。 
     在每個volatile寫操作的後面插入一個StoreLoad屏障。
     在每個volatile讀操作的後面插入一個LoadLoad屏障。
     在每個volatile讀操作的後面插入一個LoadStore屏障。
    複製程式碼

如何保證部分原子性

  同樣拿併發程式設計前傳中 dobule 和 long 的例子,double 和 long 變數的單個讀/寫在絕大部分商業虛擬機器上都是原子的,但在在極端情況下並不具有原子性,而加了 volatile 後就一定能保證單個讀/寫原子性。這由 JMM 保證,其中底層原理有待深究,但底層應該是通過 cpu 指令來實現的。

  之所以說只能保證部分原子性,是因為 volatile 並不能保證 volatile 變數參與的複合語句的原子性,比如 i++; i+=1; 等這種看上去是單讀和寫,實質需要先讀後寫的語句。

與 synchronized 的區別

  由於 volatile 僅僅保證對單個 volatile 變數的讀/寫具有原子性,而鎖的互斥執行的特性可以確保對整個臨界區程式碼的執行具有原子性。在功能上,鎖比 volatile 更強大;在可伸縮性和執行效能上,volatile 更有優勢。如果讀者想在程式中用volatile代替鎖,請一定謹慎。即使是單個變數的語句,也只有以下三種情況下可以使用 volatile 代替鎖:

  1. 對變數的寫入操作不依賴變數的當前值,或者你能確保只有單個執行緒更新變數的值。
  2. 該變數不會與其他狀態變數一起納入不變性條件中。
  3. 在訪問變數時不需要加鎖。

  對於 1 的前半句是指對變數的寫之前不能還要去讀它,比如類似 i++、i = i + 1 等語句。至於 1 的後半句類似於我們常見的一寫多讀模型,不存在多執行緒問題。

  對於 2 是指該變數不能與其他變數一起控制某個操作,比如 if( i < j ){},其中 i 和 j 都是共享變數,i 是 volatile 修飾的。又比如 while( i - j > 2){} 等。i 與其他共享變數 j 一起參與了不變的條件控制,故存在問題。

  在《Java 併發程式設計實戰》中列出了第 3 點,而《深入理解 Java 虛擬機器》中直接刪去了。可見對於 3 是不言而喻的。

總結

  瞭解 volatile 的三大特性以後,回看阿里資料庫大牛何登成關於 volatile 的文章《C/C++ volatile關鍵詞深度剖析》理解起來不要太簡單。理解 volatile 簡單,如果想靈活應用 volatile 可以看看 Java 併發工具包中的一些原始碼實現,看看大牛如何把 volatile 運用的恰到好處的。

快速理解 volatile 關鍵字

相關文章