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前綴指令會引起當前處理器緩存行的數據寫回到系統內存

volatile變量時:

  • JMM會把其他線程中該volatile變量對應的本地內存置爲無效,然後將主內存最新的共享變量刷新到本地內存中來.
    對應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 volatile 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,立即停止工作.

Java 併發編程(一)Volatile原理剖析及使用
Java 併發編程(二)Synchronized原理剖析及使用
Java 併發編程(三)Synchronized底層優化(偏向鎖與輕量級鎖)
Java 併發編程(四)JVM中鎖的優化
Java 併發編程(五)原子操作類

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