volatile關(guān)鍵字的作用外厂、原理
在只有雙重檢查鎖冕象,沒有volatile的懶加載單例模式中,由于指令重排序
的問題汁蝶,我確實不會拿到兩個不同的單例
了渐扮,但我會拿到“半個”單例
。
而發(fā)揮神奇作用的volatile掖棉,可以當之無愧的被稱為Java并發(fā)編程中“出現(xiàn)頻率最高的關(guān)鍵字”墓律,常用于保持內(nèi)存可見性和防止指令重排序。
保持內(nèi)存可見性
內(nèi)存可見性(Memory Visibility):所有線程都能看到共享內(nèi)存的最新狀態(tài)幔亥。
失效數(shù)據(jù)
以下是一個簡單的可變整數(shù)類:
public class MutableInteger {
private int value;
public int get(){
return value;
}
public void set(int value){
this.value = value;
}
}
MutableInteger
不是線程安全的耻讽,因為get
和set
方法都是在沒有同步的情況下進行的。如果線程1調(diào)用了set方法帕棉,那么正在調(diào)用的get的線程2可能會看到更新后的value值针肥,也可能看不到。
解決方法很簡單香伴,將value
聲明為volatile
變量:
private volatile int value;
神奇的volatile關(guān)鍵字
神奇的volatile關(guān)鍵字解決了神奇的失效數(shù)據(jù)問題慰枕。
Java變量的讀寫
Java通過幾種原子操作完成工作內(nèi)存
和主內(nèi)存
的交互:
- lock:作用于主內(nèi)存,把變量標識為線程獨占狀態(tài)即纲。
- unlock:作用于主內(nèi)存具帮,解除獨占狀態(tài)。
- read:作用主內(nèi)存低斋,把一個變量的值從主內(nèi)存?zhèn)鬏數(shù)骄€程的工作內(nèi)存蜂厅。
- load:作用于工作內(nèi)存,把read操作傳過來的變量值放入工作內(nèi)存的變量副本中拔稳。
- use:作用工作內(nèi)存葛峻,把工作內(nèi)存當中的一個變量值傳給執(zhí)行引擎。
- assign:作用工作內(nèi)存巴比,把一個從執(zhí)行引擎接收到的值賦值給工作內(nèi)存的變量术奖。
- store:作用于工作內(nèi)存的變量,把工作內(nèi)存的一個變量的值傳送到主內(nèi)存中轻绞。
- write:作用于主內(nèi)存的變量采记,把store操作傳來的變量的值放入主內(nèi)存的變量中。
volatile如何保持內(nèi)存可見性
volatile的特殊規(guī)則就是:
- read政勃、load唧龄、use動作必須連續(xù)出現(xiàn)。
- assign奸远、store既棺、write動作必須連續(xù)出現(xiàn)讽挟。
所以,使用volatile變量能夠保證:
- 每次
讀取前
必須先從主內(nèi)存刷新最新的值丸冕。 - 每次
寫入后
必須立即同步回主內(nèi)存當中耽梅。
也就是說,volatile關(guān)鍵字修飾的變量看到的隨時是自己的最新值胖烛。線程1中對變量v的最新修改眼姐,對線程2是可見的。
防止指令重排
在基于偏序關(guān)系
的Happens-Before內(nèi)存模型
中佩番,指令重排技術(shù)大大提高了程序執(zhí)行效率众旗,但同時也引入了一些問題。
一個指令重排的問題——被部分初始化的對象
懶加載單例模式和競態(tài)條件
一個懶加載
的單例模式
實現(xiàn)如下:
<figure class="highlight java" style="box-sizing: border-box; display: block; margin: 10px 0px; background: rgb(39, 40, 34); padding: 10px; overflow: auto; color: rgb(255, 255, 255); font-size: 0.9em; line-height: 22.4px; border-radius: 4px;">
|
class Singleton {
private static Singleton instance;
private Singleton(){}
public static Singleton getInstance() {
if ( instance == null ) { //這里存在競態(tài)條件
instance = new Singleton();
}
return instance;
}
}
競態(tài)條件
會導(dǎo)致instance
引用被多次賦值趟畏,使用戶得到兩個不同的單例贡歧。
DCL和被部分初始化的對象
為了解決這個問題,可以使用synchronized
關(guān)鍵字將getInstance
方法改為同步方法拱镐;但這樣串行化的單例是不能忍的艘款。所以我猿族前輩設(shè)計了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;
}
}
“看起來”非常完美:既減少了阻塞蜘欲,又避免了競態(tài)條件益眉。不錯,但實際上仍然存在一個問題——當instance不為null時姥份,仍可能指向一個"被部分初始化的對象"
郭脂。
問題出在這行簡單的賦值語句:
instance = new Singleton();
它并不是一個原子操作。事實上澈歉,它可以”抽象“為下面幾條JVM指令:
memory = allocate(); //1:分配對象的內(nèi)存空間
initInstance(memory); //2:初始化對象
instance = memory; //3:設(shè)置instance指向剛分配的內(nèi)存地址
上面操作2依賴于操作1展鸡,但是操作3并不依賴于操作2,所以JVM可以以“優(yōu)化”為目的對它們進行重排序
埃难,經(jīng)過重排序后如下:
memory = allocate(); //1:分配對象的內(nèi)存空間
instance = memory;//3:設(shè)置instance指向剛分配的內(nèi)存地址(此時對象還未初始化)
ctorInstance(memory); //2:初始化對象
可以看到指令重排之后莹弊,操作 3 排在了操作 2 之前,即引用instance指向內(nèi)存memory時涡尘,這段嶄新的內(nèi)存還沒有初始化——即忍弛,引用instance指向了一個”被部分初始化的對象”。此時考抄,如果另一個線程調(diào)用getInstance方法细疚,由于instance已經(jīng)指向了一塊內(nèi)存空間,從而if條件判為false川梅,方法返回instance引用疯兼,用戶得到了沒有完成初始化的“半個”單例然遏。
解決這個該問題,只需要將instance聲明為volatile變量:
private static volatile Singleton instance;
也就是說吧彪,在只有DCL沒有volatile的懶加載單例模式中啦鸣,仍然存在著并發(fā)陷阱。我確實不會拿到
兩個不同的單例
了来氧,但我會拿到“半個”單例
(未完成初始化)诫给。
然而,許多面試書籍中啦扬,涉及懶加載的單例模式最多深入到DCL中狂,卻只字不提volatile。這“看似聰明”的機制扑毡,曾經(jīng)被我廣大初入Java世界的猿胞大加吹捧——我在大四實習(xí)面試跟誰學(xué)的時候胃榕,也得意洋洋的從飽漢、餓漢講到Double Check瞄摊,現(xiàn)在看來真是傻逼勋又。對于考查并發(fā)的面試官而言,單例模式的實現(xiàn)就是一個很好的切入點换帜,看似考查設(shè)計模式楔壤,其實期望你從設(shè)計模式答到并發(fā)和內(nèi)存模型。
volatile如何防止指令重排
volatile關(guān)鍵字通過“內(nèi)存屏障”
來防止指令被重排序惯驼。
為了實現(xiàn)volatile的內(nèi)存語義蹲嚣,編譯器在生成字節(jié)碼時,會在指令序列中插入內(nèi)存屏障來禁止特定類型的處理器重排序祟牲。然而隙畜,對于編譯器來說,發(fā)現(xiàn)一個最優(yōu)布置來最小化插入屏障的總數(shù)幾乎不可能说贝,為此议惰,Java內(nèi)存模型采取保守策略。
下面是基于保守策略的JMM內(nèi)存屏障插入策略:
- 在每個volatile寫操作的前面插入一個StoreStore屏障乡恕。
- 在每個volatile寫操作的后面插入一個StoreLoad屏障言询。
- 在每個volatile讀操作的后面插入一個LoadLoad屏障。
- 在每個volatile讀操作的后面插入一個LoadStore屏障几颜。
進階
在一次回答上述問題時倍试,忘記了解釋一個很容易引起疑惑的問題:
如果存在這種重排序問題,那么synchronized代碼塊內(nèi)部不是也可能出現(xiàn)相同的問題嗎蛋哭?
即這種情況:
class Singleton {
...
if ( instance == null ) { //可能發(fā)生不期望的指令重排
synchronized (Singleton.class) {
if ( instance == null ) {
instance = new Singleton();
System.out.println(instance.toString()); //程序順序規(guī)則發(fā)揮效力的地方
}
}
}
...
}
難道調(diào)用instance.toString()
方法時县习,instance也可能未完成初始化嗎?
首先還請放寬心,synchronized代碼塊內(nèi)部雖然會重排序躁愿,但不會在代碼塊的范圍內(nèi)導(dǎo)致線程安全問題叛本。
Happens-Before內(nèi)存模型和程序順序規(guī)則
程序順序規(guī)則:如果程序中操作A在操作B之前,那么線程中操作A將在操作B之前執(zhí)行彤钟。
前面說過来候,只有在Happens-Before內(nèi)存模型中才會出現(xiàn)這樣的指令重排序問題。Happens-Before內(nèi)存模型維護了幾種Happens-Before規(guī)則逸雹,程序順序規(guī)則
最基本的規(guī)則营搅。程序順序規(guī)則的目標對象是一段程序代碼中的兩個操作A、B梆砸,其保證此處的指令重排不會破壞操作A转质、B在代碼中的先后順序,但與不同代碼甚至不同線程中的順序無關(guān)帖世。
因此休蟹,在synchronized代碼塊內(nèi)部,instance = new Singleton()
仍然會指令重排序日矫,但重排序之后的所有指令赂弓,仍然能夠保證在instance.toString()
之前執(zhí)行。進一步的哪轿,單線程中盈魁,if ( instance == null )
能保證在synchronized代碼塊之前執(zhí)行;但多線程中缔逛,線程1中的if ( instance == null )
卻與線程2中的synchronized代碼塊之間沒有偏序關(guān)系备埃,因此線程2中synchronized代碼塊內(nèi)部的指令重排對于線程1是不期望的姓惑,導(dǎo)致了此處的并發(fā)陷阱褐奴。
類似的Happens-Before規(guī)則還有
volatile變量規(guī)則
、監(jiān)視器鎖規(guī)則
等于毙。程序猿可以借助
(Piggyback)現(xiàn)有的Happens-Before規(guī)則來保持內(nèi)存可見性和防止指令重排敦冬。
注意點
上面簡單講解了volatile關(guān)鍵字的作用和原理,但對volatile的使用過程中很容易出現(xiàn)的一個問題是:
錯把volatile變量當做原子變量唯沮。
出現(xiàn)這種誤解的原因脖旱,主要是volatile關(guān)鍵字使變量的讀、寫具有了“原子性”介蛉。然而這種原子性僅限于變量(包括引用)的讀和寫萌庆,無法涵蓋變量上的任何操作,即:
- 基本類型的自增(如
count++
)等操作不是原子的币旧。 - 對象的任何非原子成員調(diào)用(包括
成員變量
和成員方法
)不是原子的践险。
如果希望上述操作也具有原子性,那么只能采取鎖、原子變量更多的措施巍虫。
總結(jié)
綜上彭则,其實volatile保持內(nèi)存可見性和防止指令重排序的原理,本質(zhì)上是同一個問題占遥,也都依靠內(nèi)存屏障得到解決俯抖。更多內(nèi)容請參見JVM相關(guān)書籍。