在只有雙重檢查鎖,沒有volatile的懶載入單例模式中,由於指令重排序
的問題,我確實不會拿到兩個不同的單例
了,但我會拿到“半個”單例
。
而發揮神奇作用的volatile,可以當之無愧的被稱為Java併發程式設計中“出現頻率最高的關鍵字”,常用於保持記憶體可見性和防止指令重排序。
保持記憶體可見性
記憶體可見性(Memory Visibility):所有執行緒都能看到共享記憶體的最新狀態。
失效資料
以下是一個簡單的可變整數類:
public class MutableInteger {
private int value;
public int get(){
return value;
}
public void set(int value){
this.value = value;
}
}複製程式碼
MutableInteger
不是執行緒安全的,因為get
和set
方法都是在沒有同步的情況下進行的。如果執行緒1呼叫了set方法,那麼正在呼叫的get的執行緒2可能會看到更新後的value值,也可能看不到。
解決方法很簡單,將value
宣告為volatile
變數:
private volatile int value;複製程式碼
神奇的volatile關鍵字
神奇的volatile關鍵字解決了神奇的失效資料問題。
Java變數的讀寫
Java通過幾種原子操作完成工作記憶體
和主記憶體
的互動:
- lock:作用於主記憶體,把變數標識為執行緒獨佔狀態。
- unlock:作用於主記憶體,解除獨佔狀態。
- read:作用主記憶體,把一個變數的值從主記憶體傳輸到執行緒的工作記憶體。
- load:作用於工作記憶體,把read操作傳過來的變數值放入工作記憶體的變數副本中。
- use:作用工作記憶體,把工作記憶體當中的一個變數值傳給執行引擎。
- assign:作用工作記憶體,把一個從執行引擎接收到的值賦值給工作記憶體的變數。
- store:作用於工作記憶體的變數,把工作記憶體的一個變數的值傳送到主記憶體中。
- write:作用於主記憶體的變數,把store操作傳來的變數的值放入主記憶體的變數中。
volatile如何保持記憶體可見性
volatile的特殊規則就是:
- read、load、use動作必須連續出現。
- assign、store、write動作必須連續出現。
所以,使用volatile變數能夠保證:
- 每次
讀取前
必須先從主記憶體重新整理最新的值。 - 每次
寫入後
必須立即同步回主記憶體當中。
也就是說,volatile關鍵字修飾的變數看到的隨時是自己的最新值。執行緒1中對變數v的最新修改,對執行緒2是可見的。
防止指令重排
在基於偏序關係
的Happens-Before記憶體模型
中,指令重排技術大大提高了程式執行效率,但同時也引入了一些問題。
一個指令重排的問題——被部分初始化的物件
懶載入單例模式和競態條件
一個懶載入
的單例模式
實現如下:
class Singleton {
private static Singleton instance;
private Singleton(){}
public static Singleton getInstance() {
if ( instance == null ) { //這裡存在競態條件
instance = new Singleton();
}
return instance;
}
}複製程式碼
競態條件
會導致instance
引用被多次賦值,使使用者得到兩個不同的單例。
DCL和被部分初始化的物件
為了解決這個問題,可以使用synchronized
關鍵字將getInstance
方法改為同步方法;但這樣序列化的單例是不能忍的。所以我猿族前輩設計了DCL
(Double Check Lock,雙重檢查鎖)機制,使得大部分請求都不會進入阻塞程式碼塊:
class Singleton {
private static Singleton instance;
private Singleton(){}
public static Singleton getInstance() {
if ( instance == null ) { //當instance不為null時,仍可能指向一個“被部分初始化的物件”
synchronized (Singleton.class) {
if ( instance == null ) {
instance = new Singleton();
}
}
}
return instance;
}
}複製程式碼
“看起來”非常完美:既減少了阻塞,又避免了競態條件。不錯,但實際上仍然存在一個問題——當instance不為null時,仍可能指向一個"被部分初始化的物件"
。
問題出在這行簡單的賦值語句:
instance = new Singleton();複製程式碼
它並不是一個原子操作。事實上,它可以”抽象“為下面幾條JVM指令:
memory = allocate(); //1:分配物件的記憶體空間
initInstance(memory); //2:初始化物件
instance = memory; //3:設定instance指向剛分配的記憶體地址複製程式碼
上面操作2依賴於操作1,但是操作3並不依賴於操作2,所以JVM可以以“優化”為目的對它們進行重排序
,經過重排序後如下:
memory = allocate(); //1:分配物件的記憶體空間
instance = memory; //3:設定instance指向剛分配的記憶體地址(此時物件還未初始化)
ctorInstance(memory); //2:初始化物件複製程式碼
可以看到指令重排之後,操作 3 排在了操作 2 之前,即引用instance指向記憶體memory時,這段嶄新的記憶體還沒有初始化——即,引用instance指向了一個"被部分初始化的物件"。此時,如果另一個執行緒呼叫getInstance方法,由於instance已經指向了一塊記憶體空間,從而if條件判為false,方法返回instance引用,使用者得到了沒有完成初始化的“半個”單例。
解決這個該問題,只需要將instance宣告為volatile變數:
private static volatile Singleton instance;複製程式碼
也就是說,在只有DCL沒有volatile的懶載入單例模式中,仍然存在著併發陷阱。我確實不會拿到
兩個不同的單例
了,但我會拿到“半個”單例
(未完成初始化)。
然而,許多面試書籍中,涉及懶載入的單例模式最多深入到DCL,卻隻字不提volatile。這“看似聰明”的機制,曾經被我廣大初入Java世界的猿胞大加吹捧——我在大四實習面試跟誰學的時候,也得意洋洋的從飽漢、餓漢講到Double Check,現在看來真是傻逼。對於考查併發的面試官而言,單例模式的實現就是一個很好的切入點,看似考查設計模式,其實期望你從設計模式答到併發和記憶體模型。
volatile如何防止指令重排
volatile關鍵字通過“記憶體屏障”
來防止指令被重排序。
為了實現volatile的記憶體語義,編譯器在生成位元組碼時,會在指令序列中插入記憶體屏障來禁止特定型別的處理器重排序。然而,對於編譯器來說,發現一個最優佈置來最小化插入屏障的總數幾乎不可能,為此,Java記憶體模型採取保守策略。
下面是基於保守策略的JMM記憶體屏障插入策略:
- 在每個volatile寫操作的前面插入一個StoreStore屏障。
- 在每個volatile寫操作的後面插入一個StoreLoad屏障。
- 在每個volatile讀操作的後面插入一個LoadLoad屏障。
- 在每個volatile讀操作的後面插入一個LoadStore屏障。
進階
在一次回答上述問題時,忘記了解釋一個很容易引起疑惑的問題:
如果存在這種重排序問題,那麼synchronized程式碼塊內部不是也可能出現相同的問題嗎?
即這種情況:
class Singleton {
...
if ( instance == null ) { //可能發生不期望的指令重排
synchronized (Singleton.class) {
if ( instance == null ) {
instance = new Singleton();
System.out.println(instance.toString()); //程式順序規則發揮效力的地方
}
}
}
...
}複製程式碼
難道呼叫instance.toString()
方法時,instance也可能未完成初始化嗎?
首先還請放寬心,synchronized程式碼塊內部雖然會重排序,但不會在程式碼塊的範圍內導致執行緒安全問題。
Happens-Before記憶體模型和程式順序規則
程式順序規則:如果程式中操作A在操作B之前,那麼執行緒中操作A將在操作B之前執行。
前面說過,只有在Happens-Before記憶體模型中才會出現這樣的指令重排序問題。Happens-Before記憶體模型維護了幾種Happens-Before規則,程式順序規則
最基本的規則。程式順序規則的目標物件是一段程式程式碼中的兩個操作A、B,其保證此處的指令重排不會破壞操作A、B在程式碼中的先後順序,但與不同程式碼甚至不同執行緒中的順序無關。
因此,在synchronized程式碼塊內部,instance = new Singleton()
仍然會指令重排序,但重排序之後的所有指令,仍然能夠保證在instance.toString()
之前執行。進一步的,單執行緒中,if ( instance == null )
能保證在synchronized程式碼塊之前執行;但多執行緒中,執行緒1中的if ( instance == null )
卻與執行緒2中的synchronized程式碼塊之間沒有偏序關係,因此執行緒2中synchronized程式碼塊內部的指令重排對於執行緒1是不期望的,導致了此處的併發陷阱。
類似的Happens-Before規則還有
volatile變數規則
、監視器鎖規則
等。程式猿可以藉助
(Piggyback)現有的Happens-Before規則來保持記憶體可見性和防止指令重排。
注意點
上面簡單講解了volatile關鍵字的作用和原理,但對volatile的使用過程中很容易出現的一個問題是:
錯把volatile變數當做原子變數。
出現這種誤解的原因,主要是volatile關鍵字使變數的讀、寫具有了“原子性”。然而這種原子性僅限於變數(包括引用)的讀和寫,無法涵蓋變數上的任何操作,即:
- 基本型別的自增(如
count++
)等操作不是原子的。 - 物件的任何非原子成員呼叫(包括
成員變數
和成員方法
)不是原子的。
如果希望上述操作也具有原子性,那麼只能採取鎖、原子變數更多的措施。
總結
綜上,其實volatile保持記憶體可見性和防止指令重排序的原理,本質上是同一個問題,也都依靠記憶體屏障得到解決。更多內容請參見JVM相關書籍。
參考:
本文連結:volatile關鍵字的作用、原理
作者:猴子007
出處:monkeysayhi.github.io
本文基於 知識共享署名-相同方式共享 4.0 國際許可協議釋出,歡迎轉載,演繹或用於商業目的,但是必須保留本文的署名及連結。