在只有雙重檢查鎖,沒有volatile的懶加載單例模式中,因爲指令重排序
的問題,我確實不會拿到兩個不一樣的單例
了,但我會拿到「半個」單例
。html
而發揮神奇做用的volatile,能夠當之無愧的被稱爲Java併發編程中「出現頻率最高的關鍵字」,經常使用於保持內存可見性和防止指令重排序。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關鍵字解決了神奇的失效數據問題。多線程
Java經過幾種原子操做完成工做內存
和主內存
的交互:併發
volatile的特殊規則就是:app
因此,使用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
引用被屢次賦值,使用戶獲得兩個不一樣的單例。
爲了解決這個問題,可使用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的內存語義,編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序。然而,對於編譯器來講,發現一個最優佈置來最小化插入屏障的總數幾乎不可能,爲此,Java內存模型採起保守策略。
下面是基於保守策略的JMM內存屏障插入策略:
在一次回答上述問題時,忘記了解釋一個很容易引發疑惑的問題:
若是存在這種重排序問題,那麼synchronized代碼塊內部不是也可能出現相同的問題嗎?
即這種狀況:
class Singleton { ... if ( instance == null ) { //可能發生不指望的指令重排 synchronized (Singleton.class) { if ( instance == null ) { instance = new Singleton(); System.out.println(instance.toString()); //程序順序規則發揮效力的地方 } } } ... }
難道調用instance.toString()
方法時,instance也可能未完成初始化嗎?
首先還請放寬心,synchronized代碼塊內部雖然會重排序,但不會在代碼塊的範圍內致使線程安全問題。
程序順序規則:若是程序中操做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相關書籍。
參考:https://www.cnblogs.com/monkeysayhi/p/7654460.html