1. 程式人生 > >Java 併發程式設計之Volatile原理剖析及使用

Java 併發程式設計之Volatile原理剖析及使用

Java 併發程式設計之Volatile原理剖析及使用

在開始介紹Volatile之前,回顧一下在併發中極其重要的三個概念:原子性,可見行和有序性

  • 原子性: 是指一個操作不可以被中斷.比如賦值操作a=1和返回操作return a,這樣的操作在JVM中只需要一步就可以完成,因此具有原子性,而想自增操作a++這樣的操作就不具備原子性,a++在JVM中要一般經歷三個步驟:
    1. 從記憶體中取出a.
    2. 計算a+1.
    3. 將計算結果寫回記憶體中去.
  • 可見性: 確保釋放鎖之前對共享資料做出的更改對於隨後獲得該鎖的另一個執行緒是可見的.
  • 有序性: 程式執行的順序按照程式碼的先後邏輯順序執行.

只有同時保證了這三個特性才能認為操作是執行緒安全的.
在Java中,volatile是輕量級的Synchronized,在併發程式設計中保證了共享變數的可見性,與synchronized 塊相比,volatile 變數所需的編碼較少,並且執行時開銷也較少,但是它所能實現的功能也僅是 synchronized 的一部分,想在程式中用volatile代替鎖,一定要謹慎再謹慎(最好還是不要用,確實容易出錯).

volatile保證可見性的原理

在X86處理器通過工具獲取JIT編譯器生成的彙編指令來檢視對volatile

修飾變數進行寫操作時,CPU會做什麼事情.

Java程式碼如下

instance = new Singleton(); //instance是volatile變數

轉變為彙編程式碼如下.

0X01a3deld: movd $0X0,0X1104800(%esi);0x01a3de24: lock add1 $0X0,(%esp)
在對volatile修飾的共享變數進行寫操作的時候多出了0x01a3de24: lock add1 $0X0,(%esp)這行程式碼, 這裡的Lock字首的指令是實現可見性原理的關鍵.
Lock字首指令在多核處理器中會引發兩件事情:

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

所有的變數都儲存在主記憶體中,為了提高程式執行速度,執行緒擁有自己的工作記憶體,工作記憶體儲存在快取記憶體或者暫存器中,儲存了該執行緒使用的變數的主記憶體副本拷貝。但是這樣便會帶來快取一致性問題,解決了快取一致性問題,也就解決了可見性問題.

快取一致性:如果多個快取共享同一塊主記憶體區域,那麼多個快取的資料可能會不一致。

執行緒只能直接操作工作記憶體中的變數,不同執行緒之間的變數值傳遞需要通過主記憶體來完成。

volatile關鍵字如何保證可見性(解決快取一致性問題)

volatile變數時:

  • JMM會把該執行緒對應的本地記憶體中的共享變數值立即重新整理到主記憶體中.
    對應volatile的第一條實現原則—Lock字首指令會引起當前處理器快取行的資料寫回到系統記憶體

vlatile變數時:

  • JMM會把該執行緒對應的本地記憶體置為無效,然後將主記憶體最新的共享變數重新整理到本地記憶體中來.
    對應volatile的第二條實現原則—一個處理器的快取會寫到主記憶體中會導致其他處理器的快取無效(使用嗅探技術保證)

如何使用volatile關鍵字

只能在有限的一些情形下使用 volatile 變數替代鎖。要使 volatile 變數提供理想的執行緒安全,必須同時滿足下面兩個條件:

  • 對變數的寫操作不依賴於當前值。
  • 該變數沒有包含在具有其他變數的不變式中。

兩種常見錯誤

最初使用volatile關鍵字的時候,大家可能最常見的就是第一種錯誤了.

class VolatileExample{
    private volatile int value;
    public void add(){
        value++;
    }
    public int get(){
        return value;
    }
}
public static void main(String[] args) throws InterruptedException, ExecutionException {
        VolatileExample volatileExample = new VolatileExample();
        ExecutorService executorService = Executors.newCachedThreadPool();
        CountDownLatch countDownLatch = new CountDownLatch(100);
        for (int i = 0; i <100; i++) {
            executorService.execute(()->{
                volatileExample.add();
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();
        executorService.shutdown();
        System.out.println(volatileExample.get());
    }

程式碼結果輸出
98

問題分析:

vaule++這樣的操作並不是原子的,即使被volatile修飾了依舊不是原子操作.假如執行緒A從主記憶體中讀取value=10,隨後執行緒B也從主記憶體中讀取value=10,執行緒A執行value++,執行緒B執行value++,執行緒A將value=11寫入主記憶體,執行緒B也將value=11寫入主記憶體,最終主記憶體中value=11,而不是value=12.像這種初級失誤是一定要避免的.

下面演示了一個非執行緒安全的數值範圍類,違反了第二個條件。它包含了一個不變式 —— 下界總是小於或等於上界。

@NotThreadSafe 
public class NumberRange {
    private int lower, upper;
 
    public int getLower() { return lower; }
    public int getUpper() { return upper; }
 
    public void setLower(int value) { 
        if (value > upper) 
            throw new IllegalArgumentException(...);
        lower = value;
    }
 
    public void setUpper(int value) { 
        if (value < lower) 
            throw new IllegalArgumentException(...);
        upper = value;
    }
}

問題分析:

這種方式限制了範圍的狀態變數,因此將 lower 和 upper 欄位定義為 volatile 型別不能夠充分實現類的執行緒安全;從而仍然需要使用同步。否則,如果湊巧兩個執行緒在同一時間使用不一致的值執行 setLower 和 setUpper 的話,則會使範圍處於不一致的狀態。例如,如果初始狀態是 (0, 5),同一時間內,執行緒 A 呼叫 setLower(4) 並且執行緒 B 呼叫 setUpper(3),顯然這兩個操作交叉存入的值是不符合條件的,那麼兩個執行緒都會通過用於保護不變式的檢查,使得最後的範圍值是 (4, 3) —— 一個無效值。至於針對範圍的其他操作,我們需要使 setLower() 和 setUpper() 操作原子化 —— 而將欄位定義為 volatile 型別是無法實現這一目的的。

正確使用示範

講一種最常用也是最不容易出錯的使用方式—將volatile變數作為狀態標誌使用

volatile boolean shutdownRequested;
 
...
 
public void shutdown() { shutdownRequested = true; }
 
public void doWork() { 
    while (!shutdownRequested) { 
        // do stuff
    }
}

當前執行緒一直在執行doWork()方法,假如這個時候另一個執行緒呼叫shutdown()方法將shutdownRequested設定為true,當前執行緒本地記憶體的shutdownRequested拷貝副本馬上失效,需從主記憶體中重新讀取,讀取到shutdownRequestedtrue,立即停止工作.