Java 併發編程之Volatile原理剖析及使用
在開始介紹Volatile
之前,回顧一下在併發中極其重要的三個概念:原子性,可見行和有序性
- 原子性: 是指一個操作不可以被中斷.比如賦值操作
a=1
和返回操作return a
,這樣的操作在JVM中只需要一步就可以完成,因此具有原子性,而想自增操作a++
這樣的操作就不具備原子性,a++
在JVM中要一般經歷三個步驟:- 從內存中取出a.
- 計算a+1.
- 將計算結果寫回內存中去.
- 可見性: 一個線程對於共享變量的修改,能夠及時地被其他線程看到.
- 有序性: 程序執行的順序按照代碼的先後邏輯順序執行.
只有同時保證了這三個特性才能認爲操作是線程安全的.
在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
前綴指令在多核處理器中會引發兩件事情:
- 將當前處理器緩存行的數據寫回到系統內存
- 這個寫回內存的操作會使其他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
拷貝副本馬上失效,需從主內存中重新讀取,讀取到shutdownRequested
爲true
,立即停止工作.
Java 併發編程(一)Volatile原理剖析及使用
Java 併發編程(二)Synchronized原理剖析及使用
Java 併發編程(三)Synchronized底層優化(偏向鎖與輕量級鎖)
Java 併發編程(四)JVM中鎖的優化
Java 併發編程(五)原子操作類