理解 Java 中的 volatile
標題 neta 自《計算機網路自頂向下》
思維導圖
volatile 在 Java 中被稱為輕量級 synchronized
。很多併發專家引導使用者遠離 volatile 變數,因為使用它們要比使用鎖更加容易出錯。但是如果理解了 volatile 能幫助你寫出更好的程式。
- 當讀比寫更多時會獲得比鎖好相當多的效能
- 比鎖更好的伸縮性
- 比鎖使用方便,只需要宣告變數即可,程式碼量小
記憶體語義
volatile 的講解
為了方便理解 volatile,用程式碼來表示一下加了 volatile 的效果。
給變數加上 volatile 相當於在 get 和 set 方法中加了鎖。
public synchronized int getX() { return x; } public synchronized void setX(int x) { this.x = x; }
注意這裡只保證了get 和 set 的原子性,當有其他操作的時候就不是原子性的了。
下面的操作不是原子性的,當個 5 個執行緒同時執行這個方法 100 次後出現的結果很可能小於 500。
volatile int x; public void inc() { x++; }
原因是這個程式相當於
int x; public synchronized int getX() { return x; } public synchronized void setX(int x) { this.x = x; } public void inc() { int temp = getX(); // 1 temp += 1; // 2 setX(temp); // 3 }
可以看出即使 get 和 set 操作是原子性的,整個操作也不是原子性的。
當兩個執行緒 A , B 同時執行 inc
時,可能會出現
A-1 得到 x = 1
B-1 得到 x = 1
A-2 temp 為 2
B-2 temp 為 2
A-3 x 被設為 2
B-3 x 被設為 2
在執行完畢後 x 的值只增加了 1。
原子性和可見性
我們在 JMM 中講解 volatile 的記憶體語義。可以參照著這篇看。 ofollow,noindex">JVM記憶體模型、指令重排、記憶體屏障概念解析
volatile 保證了新的值能立刻同步到主記憶體中,以及每次使用前都到主記憶體重新整理。
volatile 通過在寫入變數的時候,JVM 會向 CPU 傳送一個 lock 字首指令將變數同步入主記憶體
而當出現了這個命令以後,所有其他執行緒上的快取就會被強制設定成無效,當下次要用到這個變數的時候需要去主記憶體中取。
通過 Lock 指令
每次使用變數之前都必須先從主記憶體重新整理最新的值。
每次修改變數後都必須立即同步回主記憶體中,保證其他執行緒可以看到最新的值。
一個比較有用的抽象:把加了 volatile 的變數當作是沒有中間的快取,所有的資料操作都是在主記憶體上的。
###禁止指令重排
CPU 和編譯器為了執行效率,會將指令重排序。如果不知道的可以參照上面那一片博文來對照著讀。
volatile 修飾變數不會被指令重排優化,保證程式碼執行順序和程式順序相同。
在幾個地方會插入 StoreStore 和 StoreLoad 阻止重排序。
- 在 volatile 寫前插入 StoreStore 屏障,寫後插入 StoreLoad 屏障
- 在 volatile 讀前插入 LoadLoad 屏障,讀後插入 LoadStore 屏障
如果不知道這兩個指令可以看一下上面的部落格。 // TODO 馬上寫完 (咕咕咕
正確的使用 volatile
正確使用 volatile 依賴於
- 對變數的寫操作不依賴於當前值
- 該變數沒有包含在具有其他變數的不變式中
模式 #1:狀態標誌
也許實現 volatile 變數的規範使用僅僅是使用一個布林狀態標誌,用於指示發生了一個重要的一次性事件,比如遊戲結束,將遊戲正在進行的 flag 設定為 false,通知繪圖執行緒停止。
volatile boolean flag = false; private void waiting() { while(!flag) { // do something } }
模式 #2:一次性安全釋出
一次性安全分佈用於雙重檢查實現單例模式。
private volatile SingleTest instance; SingleTest getInstance() { if (instance == null) { synchronized (SingleTest.class) { if (instance == null) { instance = new SingleTest(); } } } return instance; }
為什麼要用到 volatile 呢?因為新建類分為三步
- 分配記憶體空間
- 初始化物件
- 設定記憶體地址,初始化引用
在這裡第二步可能重排序,這時候可能會將沒有初始化成功就把物件釋出出去了,所以需要 volatile 來阻止指令重排。
模式 #3:獨立觀察
安全使用 volatile 的另一種簡單模式是:定期 “釋出” 觀察結果供程式內部使用。例如,假設有一種環境感測器能夠感覺環境溫度。一個後臺執行緒可能會每隔幾秒讀取一次該感測器,並更新包含當前文件的 volatile 變數。然後,其他執行緒可以讀取這個變數,從而隨時能夠看到最新的溫度值。
使用該模式的另一種應用程式就是收集程式的統計資訊。清單 4 展示了身份驗證機制如何記憶最近一次登入的使用者的名字。將反覆使用 lastUser
引用來發布值,以供程式的其他部分使用。
該模式是前面模式的擴充套件;將某個值釋出以在程式內的其他地方使用,但是與一次性事件的釋出不同,這是一系列獨立事件。這個模式要求被髮布的值是有效不可變的 —— 即值的狀態在釋出後不會更改。使用該值的程式碼需要清楚該值可能隨時發生變化。
模式 #4:volatile bean 模式
volatile bean 是執行緒安全的。在 volatile bean 模式中,JavaBean 被用作一組具有 getter 和/或 setter 方法 的獨立屬性的容器。
在 volatile bean 模式中,
volatile
@ThreadSafe public class Person { private volatile String firstName; private volatile String lastName; private volatile int age; public String getFirstName() { return firstName; } public String getLastName() { return lastName; } public int getAge() { return age; } public void setFirstName(String firstName) { this.firstName = firstName; } public void setLastName(String lastName) { this.lastName = lastName; } public void setAge(int age) { this.age = age; } }
對於 volatile 的優化
在 JDK 7 併發包裡新增了一個佇列集合 LinkedTransferQueue,它在使用 volatile 變數時,用一種追加位元組的方式來優化出隊和入隊的效能。
它將變數追加到了 64 位元組來提高效能。
64 位 CPU 在佇列中的元素不足 64 個位元組時會將多個元素讀入一個快取行中,在多執行緒當讀取一個元素的時候會鎖住這個快取行,進而導致這個元素附近的元素都不能被讀取。
如果一個變數為 64 位元組,那麼每個元素都被讀入不同的快取中,相鄰佇列元素就能被不同執行緒同時訪問了。
參考文獻
- 周志明. 深入理解 Java 虛擬機器 [M]. 機械工業出版社, 2011.
- 方騰飛.Java 併發程式設計的藝術 [M]. 機械工業出版社, 2015.
- 正確使用 Volatile 變數
- JVM記憶體模型、指令重排、記憶體屏障概念解析