Java併發:volatile記憶體可見性和指令重排
1. 正確認識 volatile
volatile變數具有synchronized的可見性特性,但是不具備原子特性。volatile變數可用於提供執行緒安全,但是隻能應用於非常有限的一組用例:多個變數之間或者某個變數的當前值與修改後值之間沒有約束。因此,單獨使用volatile還不足以實現計數器、互斥鎖或任何具有與多個變數相關的不變式(Invariants)的類(例如 “start <=end”)。
出於簡易性或可伸縮性的考慮,我們更傾向於使用volatile變數而不是鎖。此外,volatile變數不會像鎖那樣造成執行緒阻塞。在某些情況下,如果讀操作遠遠大於寫操作,volatile變數還可以提供優於鎖的效能優勢。
2. 何時使用 volatile
我們只能在某些特定情形下使用volatile變數替代鎖,要使volatile變數提供理想的執行緒安全,必須同時滿足下面兩個條件:
- 對變數的寫操作不依賴於當前值。
- 該變數沒有包含在具有其他變數的不變式中。
實際上,這些條件表明,可以被寫入volatile變數的這些有效值獨立於任何程式的狀態,包括變數的當前狀態。
第一個條件的限制使 volatile 變數不能用作執行緒安全計數器。雖然增量操作(x++)看上去類似一個單獨操作,實際上它是一個由讀取-修改-寫入操作序列組成的組合操作,必須以原子方式執行,而volatile不能提供必須的原子特性。實現正確的操作需要使 x 的值在操作期間保持不變,而 volatile 變數無法實現這點。(然而,如果將值調整為只從單個執行緒寫入,那麼可以忽略第一個條件。)
大多數程式設計情形都會與這兩個條件的其中之一衝突,使得volatile變數不能像synchronized那樣普遍適用於實現執行緒安全。
3. 記憶體可見性
Java記憶體模型(關於記憶體模型可參照 https://www.cnblogs.com/dolphin0520/p/3920373.html ,Java記憶體模型定義了8種原子操作)規定,對於多個執行緒共享的變數,儲存在主記憶體當中,每個執行緒都有自己獨立的工作記憶體(比如CPU的暫存器),執行緒只能訪問自己的工作記憶體,不可以訪問其它執行緒的工作記憶體。
先看一段程式碼,假如執行緒1先執行,執行緒2後執行:
// 執行緒1
boolean stop = false ;
while(!stop){
doSomething();
}
// 執行緒2
stop = true;
很多人在中斷執行緒時可能都會採用這種標記辦法。但是事實上並不一定能夠中斷執行緒,為什麼呢?在前面已經解釋過,每個執行緒在執行過程中都有自己的工作記憶體,那麼執行緒1在執行的時候,會將stop變數的值拷貝一份放在自己的工作記憶體當中。那麼當執行緒2更改了stop變數的值之後,但是還沒來得及寫入主存當中,執行緒2轉去做其他事情,那麼執行緒1由於不知道執行緒2對stop變數的更改,因此還會一直迴圈下去。
所以也許在大多數時候,這個程式碼能夠把執行緒中斷,但是一旦出現上面的情況那麼將不僅僅是無法中斷執行緒,還可能發生死迴圈。
被 volatile 修飾的變數則不同:
- 使用volatile關鍵字會強制將修改的值立即寫入主存。
- 使用volatile關鍵字的話,當執行緒2進行修改時,會導致執行緒1的工作記憶體中快取變數stop的快取行無效(反映到硬體層的話,就是CPU的L1或者L2快取中對應的快取行無效)。
- 由於執行緒1的工作記憶體中快取變數stop的快取行無效,所以執行緒1再次讀取變數stop的值時會去主存讀取。
那麼線上程2修改stop值時(當然這裡包括2個操作,修改執行緒2工作記憶體中的值,然後將修改後的值寫入記憶體),會使得執行緒1的工作記憶體中快取變數stop的快取行無效,然後執行緒1讀取時,發現自己的快取行無效,它會等待快取對應的主存地址被更新之後,然後去對應的主存讀取最新的值。
4. 指令重排序
什麼是指令重排序? 指令重排序是JVM為優化指令、提高程式執行效率,在不影響單執行緒程式執行結果的前提下,儘可能地提高並行度。指令重排序包括編譯器重排序和執行時重排序。
看以下語句:
1 double r = 2.1;
2 double pi = 3.14;
3 double area = pi*r*r;
計算順序1->2->3與2->1->3對結果並無影響,所以編譯時和執行時可以根據需要對1、2語句進行重排序。
語句重排會出現什麼問題呢?先看下面的程式碼:
// 執行緒A中
{
context = loadContext();
inited = true;
}
// 執行緒B中
{
if (inited)
fun(context);
}
如果執行緒A中的指令發生重排序,那麼B中很可能就會拿到一個尚未初始化或尚未初始化完成的context,從而引發程式錯誤。
再看另一個例子:指令重排導致單例模式失效,我們都知道一個經典的懶載入方式的雙重判斷單例模式:
public class Singleton {
private static Singleton instance = null;
private Singleton() { }
public static Singleton getInstance() {
if(instance == null) {
synchronzied(Singleton.class) {
if(instance == null) {
<strong>instance = new Singleton(); //非原子操作
}
}
}
return instance;
}
}
看似簡單的一段賦值語句:instance= new Singleton(),但是很不幸它並不是一個原子操作,其實際上可以抽象為下面幾條JVM指令:
memory =allocate(); //1:分配物件的記憶體空間
ctorInstance(memory); //2:初始化物件
instance =memory; //3:設定instance指向剛分配的記憶體地址
上面操作2依賴於操作1,但是操作3並不依賴於操作2,所以JVM是可以針對它們進行指令的優化重排序的,經過重排序後如下:
memory =allocate(); //1:分配物件的記憶體空間
instance =memory; //3:instance指向剛分配的記憶體地址,此時物件還未初始化
ctorInstance(memory); //2:初始化物件
可以看到指令重排之後,instance指向分配好的記憶體放在了前面,而這段記憶體的初始化被排在了後面。
線上程A執行這段賦值語句,在初始化分配物件之前就已經將其賦值給instance引用,恰好另一個執行緒進入方法判斷instance引用不為null,然後就將其返回使用,導致出錯。
volatile如何禁止指令重排序? volatile關鍵字通過提供“記憶體屏障”的方式來防止指令被重排序,為了實現volatile的記憶體語義,編譯器在生成位元組碼時,會在指令序列中插入記憶體屏障來禁止特定型別的處理器重排序。對於編譯器來說,發現一個最優佈置來最小化插入屏障的總數幾乎不可能,為此,Java記憶體模型採取保守策略。下面是基於保守策略的JMM記憶體屏障插入策略:
- 在每個volatile寫操作的前面插入一個StoreStore屏障。
- 在每個volatile寫操作的後面插入一個StoreLoad屏障。
- 在每個volatile讀操作的後面插入一個LoadLoad屏障。
- 在每個volatile讀操作的後面插入一個LoadStore屏障。
下面這段話摘自《深入理解Java虛擬機器》: 觀察加入volatile關鍵字和沒有加入volatile關鍵字時所生成的彙編程式碼發現,加入volatile關鍵字時,會多出一個lock字首指令,lock字首指令實際上相當於一個記憶體屏障(也成記憶體柵欄),記憶體屏障會提供3個功能:
- 確保指令重排序時不會把其後面的指令排到記憶體屏障之前的位置,也不會把前面的指令排到記憶體屏障的後面;即在執行到記憶體屏障這句指令時,在它前面的操作已經全部完成。
- 它會強制將對快取的修改操作立即寫入主存。
- 如果是寫操作,它會導致其他CPU中對應的快取行無效。
總結:在需要同步的時候,第一選擇應該是synchronized關鍵字,這是最安全的方式,嘗試其他任何方式都是有風險的。尤其在JDK 1.5之後,對synchronized同步機制做了很多優化,如:自適應的自旋鎖、鎖粗化、鎖消除、輕量級鎖等,使得它的效能明顯有了很大的提升。