(2.1.27.5)Java併發程式設計:Volatile
Java語言提供了一種稍弱的同步機制,即volatile變數,用來確保將變數的更新操作通知到其他執行緒。
- 當把變數宣告為volatile型別後,編譯器與執行時都會注意到這個變數是共享的,因此不會將該變數上的操作與其他記憶體操作一起重排序。
- volatile變數不會被快取在暫存器或者對其他處理器不可見的地方,因此在讀取volatile型別的變數時總會返回最新寫入的值。
當一個變數定義為 volatile 之後,將具備兩種特性:
-
保證此變數對所有的執行緒的可見性
- 當一個執行緒修改了這個變數的值,volatile 保證了新值能立即同步到主記憶體,以及每次使用前立即從主記憶體重新整理。但普通變數做不到這點,普通變數的值線上程間傳遞均需要通過主記憶體(詳見:Java記憶體模型)來完成。
- volatile之所以具有可見性,是因為底層中的Lock指令,該指令會將當前處理器快取行的資料直接寫會到系統記憶體中,且這個寫回記憶體的操作會使在其他CPU裡快取了該地址的資料無效。
-
禁止指令重排序優化。
- 有volatile修飾的變數,賦值後多執行了一個“load addl $0x0, (%esp)”操作,這個操作相當於一個記憶體屏障(指令重排序時不能把後面的指令重排序到記憶體屏障之前的位置),只有一個CPU訪問記憶體時,並不需要記憶體屏障;
- 指令重排序:是指CPU採用了允許將多條指令不按程式規定的順序分開發送給各相應電路單元處理
- volatile之所以能防止指令重排序,是因為Java編譯器對於volatile修飾的變數,會插入記憶體屏障。記憶體屏障會防止CPU處理指令的時候重排序的問題
一、volatile可見性
1.1 執行緒的可見性
當一個變數定義為volatile後,那麼該變數對所有執行緒都是“可見的”,其中“可見的”是指當一條執行緒修改了這個變數的值,那麼新值對於其他執行緒來說是可以立即知道的。
【volatile可見性】
- 在上圖中,執行緒A與執行緒B分別從主記憶體中獲取變數a(用volatile修飾)到自己的工作記憶體中,也就是現線上程A與執行緒B中工作記憶體中的a現在的變數為12
- 當執行緒A修改a的值為8時,會將修改後的值(a=8)同步到主記憶體中
- 同時,那麼會導致執行緒B中的快取a變數的值(a=12)無效,會讓執行緒B重新從主記憶體中獲取新的值(a=8)
1.2 volatile可見性的原理
物理計算機為了處理快取不一致的問題。提出了快取一致性的協議,其中快取一致性的核心思想是:
- 當CPU寫資料時,如果發現操作的變數是共享變數,即在其他CPU中也存在該變數的副本,會發出訊號通知其他CPU將該變數的快取行置為無效狀態
- 因此當其他CPU需要讀取這個變數時,發現自己快取中快取該變數的快取行是無效的,那麼它就會從記憶體重新讀取。
既然volatile修飾的變數能具有“可見性”,那麼volatile內部肯定是走的底層,同時也肯定滿足快取一致性原則。
因為涉及到底層彙編,這裡我們不要去了解組合語言,我們只要知道當用volatile修飾變數時,生成的彙編指令會比普通的變數宣告會多一個Lock指令。那麼Lock指令會在多核處理器下會做兩件事情:
- 將當前處理器快取的資料直接寫會到系統記憶體中
- 從Java記憶體模型來理解,就是將執行緒中的工作記憶體的資料直接寫入到主記憶體中
- 這個寫回記憶體的操作會使在其他CPU裡快取了該地址的資料無效
- 從Java記憶體模型理解,當執行緒A將工作記憶體的資料修改後(新值),同步到主記憶體中,那麼執行緒B從主記憶體中初始的值(舊值)就無效了
從某種意義上,它就相當於:宣告變數是 volatile 的,JVM 保證了每次讀變數都從主記憶體中讀,跳過 CPU cache 這一步。
二、volatile防止重排序
在《(2.1.27.2)Java併發程式設計:JAVA的記憶體模型》我們已經提及“CPU(處理器)會對沒有資料依賴性的指令進行重排序”在多執行緒環境中引發的問題,Java記憶體模型規定了使用volatile來修飾相應變數時,可以防止CPU(處理器)在處理指令的時候禁止重排序。具體如下圖所示
public class Demo {
private int a = 0;
private volatile boolean isInit = false;
private Config config;
public void init() {
config = readConfig();//1
isInit = true;//2
}
public void doSomething() {
if (isInit) {//3
doSomethingWithconfig();//4
}
}
}
2.1 volatile防止重排序規則
那麼為了處理CPU重排序的問題。Java定義了以下規則防止CPU的重排序。
【volatile防止重排序規則】
從上表我們可以看出
- 當第二個操作是volatile寫時,不管第一個操作是什麼,都不能重排序,這個規則確保voatile寫之前的操作不會被編譯器排序到volatile之後。
- 當第一個操作是volatile讀時,不管第二個操作是什麼,都不能重排序。這個規則確保volatile讀之後的操作不會被編譯器重排序到volatile讀之前。
- 當第一個操作是volatile寫,第二個操作如果是volatile讀時,不能進行重排序。
2.2 volatile防止重排序原理
為了具體實現上訴我們提到的重排序規則,在Java中對於volatile修飾的變數,編譯器在生成位元組碼時,會在指令序列中插入記憶體屏障來禁止特定型別的處理器重排序問題。
在瞭解記憶體屏障之前,我們先複習之前的主記憶體與工作記憶體互動的8種原子操作,因為記憶體屏障主要是對Java記憶體模型的幾種原子操作進行限制的。
【volatile防止重排序原理】
這裡對記憶體屏障所涉及到的兩種操作進行解釋:
- load:作用於工作記憶體的變數,它把read操作從主記憶體中得到的變數值放入到工作記憶體變數副本中。
- store:作用於工作記憶體的變數,它把工作記憶體中一個變數值傳送到主記憶體中。以便隨後的write操作。
下面是基於volatile修飾的變數,編譯器在指令序列插入的記憶體屏障保守插入策略如下:
- 在每個volatile寫操作的前面插入一個storestore屏障。
- 在每個volatile寫操作的後面插入一個storeload屏障。
- 在每個volatile讀操作的後面插入一個loadload屏障。
- 在每個volatile讀操作的後面插入一個loadstore屏障。
2.2.1 volatile寫記憶體屏障
【volatile寫記憶體屏障】
- storestore屏障:
- 對於這樣的語句store1; storestore; store2,在store2及後續寫入操作執行前,保證store1的寫入操作對其它處理器可見。
- (也就是說如果出現storestore屏障,那麼store1指令一定會在store2之前執行,CPU不會store1與store2進行重排序)
- storeload屏障:
- 對於這樣的語句store1; storeload; load2,在load2及後續所有讀取操作執行前,保證store1的寫入對所有處理器可見。
- (也就是說如果出現storeload屏障,那麼store1指令一定會在load2之前執行,CPU不會對store1與load2進行重排序)
2.3 volatile讀記憶體屏障
【volatile讀記憶體屏障】
- loadload屏障:
- 對於這樣的語句load1; loadload; load2,在load2及後續讀取操作要讀取的資料被訪問前,保證load1要讀取的資料被讀取完畢。
- (也就是說,如果出現loadload屏障,那麼load1指令一定會在load2之前執行,CPU不會對load1與load2進行重排序)
- loadstore屏障:
- 對於這樣的語句load1; loadstore; store2,在store2及後續寫入操作被刷出前,保證load1要讀取的資料被讀取完畢。
- (也就是說,如果出現loadstore屏障,那麼load1指令一定會在load2之前執行,CPU不會對load1與store2進行重排序)
2.4 編譯器記憶體屏障的優化
上面我們講到了在插入記憶體屏障時,編譯器如果採用保守策略的情況下,分別會在volatile寫與volatile讀插入不同的記憶體屏障,那現在我們來看一下,在實際開發中,編譯器在使用記憶體屏障時的優化。
public class VolatileBarrierDemo {
int a;
volatile int v1 = 1;
volatile int v2 = 2;
public void readAndWrite() {
int i = v1;//第一個volatile讀
int j = v2;//第二個volatile讀
a = i + j;//普通寫
v1 = i + 1;//第一個volatile寫
v2 = j * 2;//第二個volatile寫
}
}
【編譯器記憶體屏障的優化】
觀察上圖,我們發現,在編譯器生成屏障時,省略了第一個volatile讀下的loadstore屏障,省略了第二個volatile讀下的loadload屏障,省略了第一個volatile寫下的storeload屏障。結合上訴我們所講的loadstore屏障、loadload屏障、storeload屏障下的語義,我們能得到省略以下屏障的原因。
- 省略第一個volatile讀下的loadstore屏障:因為第一個volatile讀下的下一個操作是第二個volatile的讀,並不涉及到寫的操作(也就是store)。所以可以省略。
- 省略第二個volatile讀下的loadload屏障:因為第二個volatile讀的下一個操作是普通寫,並不涉及到讀的操作(也就是load)。所以可以省略
- 省略第一個volatile寫下的storeload屏障:因為第一個volatile寫的下一個操作是第二個volatile的寫,並不涉及到讀的操作(也就是load)。所以可以省略。
其中大家要注意的是,優化結束後的storeload屏障時不能省略的,因為在第二個volatile寫之後,方法理解return,此時編譯器可能無法確定後面是否會有讀寫操作,為了安全起見,編譯器通常會在這裡加入一個storeload屏障。
2.5 處理器記憶體屏障的優化
上面我們講了編譯器在生成屏障的時候,會根據程式的邏輯操作省略不必要的記憶體屏障。
但是由於不同的處理器有不同的“鬆耦度”的記憶體模型,記憶體屏障的優化根據不同的處理器有著不同的優化方式。
以x86處理器為例。針對我們上面所描述的編譯器記憶體屏障優化圖。在x86處理器中,除最後的storeload屏障外,其他的屏障都會省略。
【處理器記憶體屏障的優化】
三、volatile的使用條件
現在我們已經瞭解了volatile的相關特性,那麼就來說說,volatile的具體使用場景,因為volatie變數只能保證可見性,並不能保證原子性,這就是說執行緒能夠自動發現 volatile 變數的最新值。所以在輕量級執行緒同步中我們可以使用volatile關鍵字。
但是有兩個前提條件:
- 第一個條件:運算結果並不依賴變數的當前值,或者能夠確保只有單一的執行緒修改變數的值。
- 第二個條件:變數不需要與其他的狀態變數共同參與不變約束。
3.1 針對第一個條件
volatile int a = 0;
//在多執行緒情況下錯誤,在單執行緒情況下正確的方式
public void doSomeThingA() {
//在單執行緒情況下,不會出現執行緒安全的問題,正確
//在多執行緒情況下,a最終的值依賴於當前a的值,錯誤
a++;
}
//正確的使用方式
public void doSomeThingB() {
//不管是在單執行緒還是多執行緒的情況下,都不會出現執行緒安全的問題
if(a==0){
a = 1;
}
}
上述虛擬碼中,我們能明確的看出:
- 只要volatile修飾的變數不涉及與運算結果的依賴,那麼不管是在多執行緒,還是單執行緒的情況下,都是正確的。
- 否則,只在單執行緒中有效
3.2 針對第二個條件
其實理解第二個條件,大家可以反過來理解,即使用volatile的變數不能包含在其他變數的不變式中,下面虛擬碼將會通過反例說明:
private volatile int lower;
private volatile int 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)。 那麼在多執行緒的情況下,兩個執行緒在同一時間使用不一致的值執行 setLower 和 setUpper 的話,則會使範圍處於不一致的狀態。
- 如果初始狀態是(0, 5),同一時間內,執行緒 A 呼叫setLower(4) 並且執行緒 B 呼叫setUpper(3),顯然這兩個操作交叉存入的值是不符合條件的
- 然而實際上,兩個執行緒都會通過用於保護不變式的檢查,使得最後的範圍值是(4, 3)。很顯然這個結果是錯誤的。
四、volatile的適用場景
Volatile 變數可用於提供執行緒安全,但是隻能應用於非常有限的一組用例:多個變數之間或者某個變數的當前值與修改後值之間沒有約束。因此,單獨使用 volatile 還不足以實現計數器、互斥鎖或任何具有與多個變數相關的不變式(Invariants)的類(例如 “start <=end”)。
模式1:狀態標誌
也許實現 volatile 變數的規範使用僅僅是使用一個布林狀態標誌,用於指示發生了一個重要的一次性事件,例如完成初始化或請求停機。
volatile boolean shutdownRequested;
...
public void shutdown() {
shutdownRequested = true;
}
public void doWork() {
while (!shutdownRequested) {
// do stuff
}
}
執行緒1執行doWork()的過程中,可能有另外的執行緒2呼叫了shutdown,所以boolean變數必須是volatile。 而如果使用 synchronized 塊編寫迴圈要比使用 volatile 狀態標誌編寫麻煩很多。由於 volatile 簡化了編碼,並且狀態標誌並不依賴於程式內任何其他狀態,因此此處非常適合使用 volatile。
這種型別的狀態標記的一個公共特性是:通常只有一種狀態轉換;shutdownRequested 標誌從false 轉換為true,然後程式停止。 這種模式可以擴充套件到來回轉換的狀態標誌,但是隻有在轉換週期不被察覺的情況下才能擴充套件(從false 到true,再轉換到false)。此外,還需要某些原子狀態轉換機制,例如原子變數。
模式2:一次性安全釋出(one-time safe publication)
在缺乏同步的情況下,可能會遇到某個物件引用的更新值(由另一個執行緒寫入)和該物件狀態的舊值同時存在。這就是造成著名的雙重檢查鎖定(double-checked-locking)問題的根源,其中物件引用在沒有同步的情況下進行讀操作,產生的問題是您可能會看到一個更新的引用,但是仍然會通過該引用看到不完全構造的物件。
//注意volatile!!!!!!!!!!!!!!!!!
private volatile static Singleton instace;
public static Singleton getInstance(){
//第一次null檢查
if(instance == null){
synchronized(Singleton.class) { //1
//第二次null檢查
if(instance == null){ //2
instance = new Singleton();//3
}
}
}
return instance;
}
如果不用volatile,則因為記憶體模型允許所謂的“無序寫入”,可能導致失敗。——某個執行緒可能會獲得一個未完全初始化的例項。
考察上述程式碼中的 //3 行。此行程式碼建立了一個 Singleton 物件並初始化變數 instance 來引用此物件。這行程式碼的問題是:在Singleton 建構函式體執行之前,變數instance 可能成為非 null 的!
在解釋這個現象如何發生前,請先暫時接受這一事實,我們先來考察一下雙重檢查鎖定是如何被破壞的。假設上述程式碼執行以下事件序列:
- 執行緒 1 進入 getInstance() 方法。
- 由於 instance 為 null,執行緒 1 在 //1 處進入synchronized 塊。
- 執行緒 1 前進到 //3 處,但在建構函式執行之前,使例項成為非null。
- 執行緒 1 被執行緒 2 預佔。
- 執行緒 2 檢查例項是否為 null。因為例項不為 null,執行緒 2 將instance 引用返回,返回一個構造完整但部分初始化了的Singleton 物件。
- 執行緒 2 被執行緒 1 預佔。
- 執行緒 1 通過執行 Singleton 物件的建構函式並將引用返回給它,來完成對該物件的初始化。
模式3:獨立觀察(independent observation)
-
安全使用 volatile 的另一種簡單模式是:定期 “釋出” 觀察結果供程式內部使用。
- 【例如】假設有一種環境感測器能夠感覺環境溫度。一個後臺執行緒可能會每隔幾秒讀取一次該感測器,並更新包含當前文件的 volatile 變數。然後,其他執行緒可以讀取這個變數,從而隨時能夠看到最新的溫度值。
-
使用該模式的另一種應用程式就是收集程式的統計資訊。
- 【例】如下程式碼展示了身份驗證機制如何記憶最近一次登入的使用者的名字。將反覆使用lastUser 引用來發布值,以供程式的其他部分使用。
public class UserManager {
public volatile String lastUser; //釋出的資訊
public boolean authenticate(String user, String password) {
boolean valid = passwordIsValid(user, password);
if (valid) {
User u = new User();
activeUsers.add(u);
lastUser = user;
}
return valid;
}
}
模式4:“volatile bean” 模式
volatile bean 模式的基本原理是:很多框架為易變資料的持有者(例如 HttpSession)提供了容器,但是放入這些容器中的物件必須是執行緒安全的。
在 volatile bean 模式中,JavaBean 的所有資料成員都是 volatile 型別的,並且 getter 和 setter 方法必須非常普通——即不包含約束!
@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;
}
}
模式5:開銷較低的“讀-寫鎖”策略
如果讀操作遠遠超過寫操作,您可以結合使用內部鎖和 volatile 變數來減少公共程式碼路徑的開銷。
如下顯示的執行緒安全的計數器,使用 synchronized 確保增量操作是原子的,並使用 volatile 保證當前結果的可見性。如果更新不頻繁的話,該方法可實現更好的效能,因為讀路徑的開銷僅僅涉及 volatile 讀操作,這通常要優於一個無競爭的鎖獲取的開銷。
@ThreadSafe
public class CheesyCounter {
// Employs the cheap read-write lock trick
// All mutative operations MUST be done with the 'this' lock held
@GuardedBy("this") private volatile int value;
//讀操作,沒有synchronized,提高效能
public int getValue() {
return value;
}
//寫操作,必須synchronized。因為x++不是原子操作
public synchronized int increment() {
return value++;
}
}
使用鎖進行所有變化的操作,使用 volatile 進行只讀操作。 其中,鎖一次只允許一個執行緒訪問值,volatile 允許多個執行緒執行讀操作