1. 程式人生 > >併發中的volatile的語義

併發中的volatile的語義

目錄

概述

宣告共享變數為volatile後, 對這個變數的讀/寫將會很特別. 為了揭開volatile的神祕面紗, 下面將介紹volatile的記憶體語義及volatile記憶體語義的實現.

volatile的特性

理解volatile特性的一個好方法就是把對volatile變數的單個讀/寫, 看成是使用同一個鎖對這些單個讀/寫操作做了同步.

意思就是

public class Demo {

    volatile long val = 10L;

    public void setVal(long val) {
        this.val = val;
    }

    public long get() {
        return val;
    }

}

public class Demo {

    volatile long val = 10L;

    public synchronized void setVal(long val) {
        this.val = val;
    }

    public synchronized long get() {
        return val;
    }

}

是一樣的效果.

如上所示, 一個volatile變數的單個讀/寫操作, 與一個使用同一個鎖來同步的普通變數的的讀/寫, 它們的執行效果是相同的.

鎖的happens-before規則保證釋放鎖和獲取鎖的兩個執行緒之間的記憶體可見性, 這意味著對一個volatile變數的讀, 總是能看到(任意執行緒)對這個volatile變數最後的寫入.

鎖的記憶體語義決定了臨界區程式碼的執行具有原子性. 這就意味著, 即使是64位的long型別和double型別變數, 只要它是volatile變數, 對該變數的讀/寫就具有原子性. 如果多個volatile操作或類似於volatile++這種複合操作, 這些操作整體上不具有原子性.

簡言之, volatile變數自身具有以下特性.

  • 可見性: 對一個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記憶體語義的實現

為了實現volatile的記憶體語義, JMM會限制編譯器和處理器的重排序. 下面是限制的重排序規則.

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

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

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

上述記憶體屏障插入策略非常保守, 但它可以保證在任意處理器平臺, 任意的程式中都能得到正確的volatile記憶體語義. 這也是為Java提供的跨平臺打下的基礎. 當然編譯器可以根據具體的情況省略不必要的屏障.

處理器的重排序規則

處理器\規則 LoadLoad LoadStore StoreStore StoreLoad 資料依賴
SPARC-TSO N N N Y N
X86(X64 & AMD64) N N N Y N
IA64 Y Y Y Y N
PowerPC Y Y Y Y N

單元格中的"N"表示處理器不允許兩個操作重排序, "Y"表示允許重排序.

可以發現常見的處理器都允許StoreLoad重排序; 常見的處理器都不允許對存在資料依賴的操作做重排序. SPARC-TSO和X86擁有相對較強的處理器記憶體模型, 它們僅允許對寫-讀操作做重排序(因為它們都使用了寫緩衝區).

記憶體屏障型別表

屏障型別 指令示例 說明
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是一個"全能型"的屏障, 它同時具有其他3個屏障的效果. 現代的多處理器大多支援該屏障(其他型別的屏障不一定被所有處理器支援). 執行該屏障開銷會很昂貴, 因為當前處理器通常要把寫緩衝區中的資料全部重新整理到記憶體中(Buffer Fully Flush).

JSR-133為什麼要增強volatile的記憶體語義

在JSR-133之前的舊Java記憶體模型中, 雖然不允許volatile變數之間重排序, 但舊的Java記憶體模型允許volatile變數與普通變數重排序.

在舊的記憶體模型中, volatile的寫-讀沒有鎖的釋放-獲所具有的記憶體語義. 為了提供一種比鎖更輕量級的執行緒之間通訊的機制, JSR-133專家組決定增強volatile的記憶體語義: 嚴格限制編譯器和處理器對volatile變數與普通變數的重排序, 確保volatile的寫-讀和鎖的釋放-獲取具有相同的記憶體語義. 從編譯器重排序規則和處理器記憶體屏障插入策略來看, 只要volatile變數與普通變數之間的重排序可能會破壞volatile的記憶體語義, 這種重排序就會被編譯器重排序規則和處理器記憶體屏障插入策略禁止.

由於volatile僅僅保證對單個volatile變數的讀/寫具有原子性, 而鎖的互斥執行的特性可以確保對整個臨界區程式碼的執行具有原子性. 在功能上, 鎖比volatile更強大; 在可伸縮性和執行效能上, volatile更有優勢.

參考<