volatile 關鍵字
簡介
volatile 是一種同步機制,比 synchronized 或 Lock 相關類更輕量,因此使用 volatile 並不會發生上下文切換等開銷很大的行為。
如果一個變數被修飾成 volatile,那麼 JVM 就知道了這個變數可能會被並發修改。
因為其開銷小,所以對應的功能也小,volatile 不能像 synchronized 一樣提供原子保護。
實現原理
Java語言規範第3版中對volatile的定義如下:Java程式語言允許線 程訪問共享變數,為了確保共享變數能被準確和一致地更新,執行緒應 該確保通過排他鎖單獨獲得這個變數
。Java語言提供了volatile,在某些 情況下比鎖要更加方便。如果一個欄位被聲明成volatile,Java執行緒記憶體模型確保所有執行緒看到這個變數的值是一致的。
volatile 是如何保證可見性的?
首先通過工具獲取 JIT 編譯器生成的彙編指令來查看對volatile進行寫操作時,CPU會 做什麼事情。
java 程式碼:
instance = new Singleton(); // instance是volatile變數
轉變成彙編程式碼,如下:
0x01a3de1d: movb $0×0,0×1104800(%esi);0x01a3de24: lock addl $0×0,(%esp);
有volatile變數修飾的共享變數進行寫操作的時候會多出第二行匯 編程式碼,通過查IA-32架構軟體開發者手冊可知,Lock
前綴的指令在多核處理器下會引發了兩件事情 。
- 將當前處理器快取行的數據寫回到系統記憶體。
- 這個寫回記憶體的操作會使在其他CPU里快取了該記憶體地址的數 據無效。
通過之前學習的 JMM 記憶體模 可以得知,為了提高處理速度,處理器不直接和記憶體進行通訊,而是先將系 統記憶體的數據讀到內部快取(L1,L2或其他)後再進行操作,但操作完不知道何時會寫到記憶體。如果對聲明了volatile的變數進行寫操作, JVM就會向處理器發送一條 Lock 前綴的指令,將這個變數所在快取行 的數據寫回到系統記憶體。但是,就算寫回到記憶體,如果其他處理器緩 存的值還是舊的,再執行計算操作就會有問題。所以,在多處理器 下,為了保證各個處理器的快取是一致的,就會實現快取一致性協 議,每個處理器通過嗅探在匯流排上傳播的數據來檢查自己快取的值是 不是過期了,當處理器發現自己快取行對應的記憶體地址被修改,就會 將當前處理器的快取行設置成無效狀態,當處理器對這個數據進行修 改操作的時候,會重新從系統記憶體中把數據讀到處理器快取里。
volatile 的兩條實現原則
1)Lock前綴指令會引起處理器快取回寫到記憶體;
2)一個處理器的快取回寫到記憶體會導致其他處理器的快取無效 ;
主要特性
可見性
程式碼實踐
public class MyData {
// 這裡去掉 volatile 後 main 執行緒就發阻塞
volatile int num = 0;
public void addNum() {
num = 60;
}
}
class VolatileDome{
public static void main(String[] args) {
MyData myData = new MyData();
new Thread(() -> {
System.out.println(Thread.currentThread().getName());
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
myData.addNum();
System.out.println(Thread.currentThread().getName() + myData.num);
}, "AAA").start();
while (myData.num == 0) {
}
System.out.println(Thread.currentThread().getName() + "mission is over");
}
}
不保證原子性
public class MyData {
volatile int num = 0;
public void addNumPlus() {
num++;
}
}
class VolatileDome{
public static void main(String[] args) {
MyData myData = new MyData();
for (int i = 0; i < 20; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
myData.addNumPlus();
}
}, String.valueOf(i)).start();
}
while (Thread.activeCount() > 2) {
Thread.yield();
}
// 最終列印的結果很大可能不是 20W。
System.out.println(myData.num);
}
}
addNumPlus 位元組碼解讀
0 aload_0
1 dup
2 getfield #2 <com/lhn/demo1/jmm/MyData.num : I>
5 iconst_1
6 iadd
7 putfield #2 <com/lhn/demo1/jmm/MyData.num : I>
10 return
通過位元組碼可以看出 n++ 被拆成了三個指令:
- 執行 getfieid 拿到原始 n;
- 執行 iadd 進行加 1 操作;
- 執行 putfieid 把累加後的值寫回;
因為 volatile 不能保證原子性,所以在執行 num++ 的時候可能會被其它執行緒給中斷操作,導致寫入的值覆蓋掉前一個執行緒寫的值,出現丟失寫值的情況。
解決原子性問題
使用 synchronized(重量級操作,不推薦)
public synchronized void addNumPlus() {
num++;
}
使用 atomic (推薦使用)
AtomicInteger atomicInteger = new AtomicInteger();
public void addAtomic() {
atomicInteger.getAndIncrement();
}
禁止指令重排序
電腦在執行程式時,為了提高性能,編譯器和處理器常常會對指令做重排
,一般分一下三種:
A[源程式碼] –> B[編譯器優化的重排] –> c[指令並行的重排] –> d[記憶體系統的重排] –> e[最終執行的指令]
單執行緒環境
下確保程式最終執行結果和程式碼順序執行的結果一樣,處理器在進行重排序時必須要考慮指令之間的數據依賴性;
多執行緒環境
中執行緒交替執行,由於編譯器優化重排的存在,兩個執行緒中使用的變數能否保證一致性是無法確定的,結果無法預測。
實現原理
volatile 實現禁止指令重排序優化,從而避免多執行緒環境下程式出現亂序執行的現象。
這個首先要了解一個概念,就是記憶體屏障(Memory Barrier)
又稱記憶體柵欄,是一個 CPU 指令,他的作用有兩個:
- 保證特定操作的執行順序。
- 保證某些變數的記憶體可見性(利用該特性實現 volatile 的記憶體可見性)
由於編譯器和處理器都能執行指令重排序優化,如果在指令間插入一條 Memory Barrier
則會告訴編譯器和 CPU,不管什麼指令都不能和這條 Memory Barrier
指令重排序。也就是說通過插入記憶體屏障禁止在記憶體屏障前後的指令執行重新排序。
對 volatile 變數進行寫操作時,會在寫操作後加入一條 store 屏障指令,將工作記憶體中的共享變數值刷新回到主記憶體。
對 volatile 變數進行讀操作時,會在寫操作後加入一條 load 屏障指令,從主記憶體中讀取共享變數。
volatile 的應用場景
狀態變數
由於boolean
的賦值是原子性的,所以volatile
布爾變數作為多執行緒停止標誌還簡單有效的.
Copyclass Machine{
volatile boolean stopped = false;
void stop(){stopped = true;}
}
對象完整發布
這裡要提到單例對象的雙重檢查鎖,對象完整發布也依賴於happens before
原則,有興趣可以自己去查閱,這個原則是比較啰嗦,可以簡單理解為我滿足happens before
,那麼我之前的程式碼按順序執行.
Copypublic class Singleton {
//單例對象
private static Singleton instance = null;
//私有化構造器,避免外部通過構造器構造對象
private Singleton(){}
//這是靜態工廠方法,用來產生對象
public static Singleton getInstance(){
if(instance ==null){
//同步鎖防止多次new對象
synchronized (Singleton.class){
//鎖內非空判斷也是為了防止創建多個對象
if(instance == null){
instance = new Singleton();
}
}
}
return instance;
}
}
這是一個會產生bug的雙重檢查鎖程式碼,instance = new Singleton()
並不是一步完成的,他被分為這幾步:
Copy1.分配對象空間;
2.初始化對象;
3.設置instance指向剛剛分配的地址。
下面圖中,執行緒A紅色先獲得鎖,B黃色後進入.
這種情況會出現bug,但是由於volatile
滿足happens before
原則,所以會等對象實例化之後再對地址賦值,我們需要將private static Singleton instance = null;
改成private static volatile Singleton instance = null;
即可.