單例陷阱——雙重檢查鎖中的指令重排問題
之前我曾經寫過一篇文章《單例模式有8種寫法,你知道麼?》,其中提到了一種實現單例的方法-雙重檢查鎖,最近在讀併發方面的書籍,發現雙重檢查鎖使用不當也並非絕對安全,在這裡分享一下。
單例回顧
首先我們回顧一下最簡單的單例模式是怎樣的?
/** *單例模式一:懶漢式(執行緒安全) */ public class Singleton1 { private static Singleton1 singleton1; private Singleton1() { } public static Singleton1 getInstance() { if (singleton1 == null) { singleton1 = new Singleton1(); } return singleton1; } }
這是一個懶漢式的單例實現,眾所周知,因為沒有相應的鎖機制,這個程式是執行緒不安全的,實現安全的最快捷的方式是新增 synchronized
/** * 單例模式二:懶漢式(執行緒安全) */ public class Singleton2 { private static Singleton2 singleton2; private Singleton2() { } public static synchronized Singleton2 getInstance() { if (singleton2 == null) { singleton2 = new Singleton2(); } return singleton2; } }
使用synchronized之後,可以保證執行緒安全,但是synchronized將全部程式碼塊鎖住,這樣會導致較大的效能開銷,因此,人們想出了一個“聰明”的技巧:雙重檢查鎖DCL(double checked locking)的機制實現單例。
雙重檢查鎖
一個雙重檢查鎖實現的單例如下所示:
/** * 單例模式三:DCL(double checked locking)雙重校驗鎖 */ public class Singleton3 { private static Singleton3 singleton3; private Singleton3() { } public static Singleton3 getInstance() { if (singleton3 == null) { synchronized (Singleton3.class) { if (singleton3 == null) { singleton3 = new Singleton3(); } } } return singleton3; } }
如上面程式碼所示,如果第一次檢查instance不為null,那麼就不需要執行下面的加鎖和初始化操作。因此可以大幅降低synchronized帶來的效能開銷。上面程式碼表面上看起來,似乎兩全其美:
在多個執行緒試圖在同一時間建立物件時,會通過加鎖來保證只有一個執行緒能建立物件。 在物件建立好之後,執行getInstance()將不需要獲取鎖,直接返回已建立好的物件。
程式看起來很完美,但是這是一個不完備的優化,線上程執行到第9行程式碼讀取到instance不為null時(第一個if),instance引用的物件有可能還沒有完成初始化。
問題的根源
問題出現在建立物件的語句singleton3 = new Singleton3();
上,在java中建立一個物件並非是一個原子操作,可以被分解成三行虛擬碼:
//1:分配物件的記憶體空間
memory = allocate();
//2:初始化物件
ctorInstance(memory);
//3:設定instance指向剛分配的記憶體地址
instance = memory;
上面三行虛擬碼中的2和3之間,可能會被重排序(在一些JIT編譯器中),即編譯器或處理器為提高效能改變程式碼執行順序,這一部分的內容稍後會詳細解釋,重排序之後的虛擬碼是這樣的:
//1:分配物件的記憶體空間
memory = allocate();
//3:設定instance指向剛分配的記憶體地址
instance = memory;
//2:初始化物件
ctorInstance(memory);
在單執行緒程式下,重排序不會對最終結果產生影響,但是併發的情況下,可能會導致某些執行緒訪問到未初始化的變數。
模擬一個2個執行緒建立單例的場景,如下表:
時間 | 執行緒A | 執行緒B |
---|---|---|
t1 | A1:分配物件記憶體空間 | |
t2 | A3:設定instance指向記憶體空間 | |
t3 | B1:判斷instance是否為空 | |
t4 | B2:由於instance不為null,執行緒B將訪問instance引用的物件 | |
t5 | A2:初始化物件 | |
t6 | A4:訪問instance引用的物件 |
按照這樣的順序執行,執行緒B將會獲得一個未初始化的物件,並且自始至終,執行緒B無需獲取鎖!
指令重排序
前面我們已經分析到,導致問題的原因在於“指令重排序”,那麼什麼是“指令重排序”,它為什麼在併發時會影響到程式處理結果? 首先我們看一下“順序一致性記憶體模型”概念。
順序一致性理論記憶體模型
順序一致性記憶體模型是一個被電腦科學家理想化了的理論參考模型,它為程式設計師提供了極強的記憶體可見性保證。順序一致性記憶體模型有兩大特性:
一個執行緒中的所有操作必須按照程式的順序來執行。 (不管程式是否同步)所有執行緒都只能看到一個單一的操作執行順序。在順序一致性記憶體模型中,每個操作都必須原子執行且立刻對所有執行緒可見。
實際JMM模型
但是,順序一致性模型只是一個理想化了的模型,在實際的JMM實現中,為了儘量提高程式執行效率,和理想的順序一致性記憶體模型有以下差異:
在順序一致性模型中,所有操作完全按程式的順序序列執行。在JMM中不保證單執行緒操作會按程式順序執行(即指令重排序
)。
順序一致性模型保證所有執行緒只能看到一致的操作執行順序,而JMM不保證所有執行緒能看到一致的操作執行順序。
順序一致性模型保證對所有的記憶體寫操作都具有原子性,而JMM不保證對64位的long型和double型變數的讀/寫操作具有原子性(分為2個32位寫操作進行,本文無關不細闡述)
指令重排序
指令重排序是指編譯器或處理器為了優化效能而採取的一種手段,在不存在資料依賴性情況下(如寫後讀,讀後寫,寫後寫),調整程式碼執行順序。 舉個例子:
//A
double pi = 3.14;
//B
double r = 1.0;
//C
double area = pi * r * r;
這段程式碼C依賴於A,B,但A,B沒有依賴關係,所以程式碼可能有2種執行順序:
A->B->C B->A->C 但無論哪種最終結果都一致,這種滿足單執行緒內無論如何重排序不改變最終結果的語義,被稱作 as-if-serial語義
,遵守as-if-serial語義的編譯器,runtime 和處理器共同為編寫單執行緒程式的程式設計師建立了一個幻覺: 單執行緒程式是按程式的順序來執行的。
雙重檢查鎖問題解決方案
回來看下我們出問題的雙重檢查鎖程式,它是滿足as-if-serial語義
的嗎?是的,單執行緒下它沒有任何問題,但是在多執行緒下,會因為重排序出現問題。
解決方案就是大名鼎鼎的volatile關鍵字,對於volatile我們最深的印象是它保證了”可見性“,它的”可見性“是通過它的記憶體語義實現的:
寫volatile修飾的變數時,JMM會把本地記憶體中值重新整理到主記憶體 讀volatile修飾的變數時,JMM會設定本地記憶體無效
重點:為了實現可見性記憶體語義,編譯器在生成位元組碼時,會在指令序列中插入記憶體屏障來防止重排序!
對之前程式碼加入volatile關鍵字,即可實現執行緒安全的單例模式。
/**
* 單例模式三:DCL(double checked locking)雙重校驗鎖
*/
public class Singleton3 {
private static volatile Singleton3 singleton3;
private Singleton3() {
}
public static Singleton3 getInstance() {
if (singleton3 == null) {
synchronized (Singleton3.class) {
if (singleton3 == null) {
singleton3 = new Singleton3();
}
}
}
return singleton3;
}
}
感謝閱讀,如有收穫,求
點贊
、求關注
讓更多人看到這篇文章,本文首發於不止於技術的技術公眾號Nauyus
,歡迎識別下方二維碼獲取更多內容,主要分享JAVA,微服務,程式語言,架構設計,思維認知類等原創技術乾貨,2019年12月起開啟周更模式,歡迎關注,與Nauyus一起學習。
福利一:後端開發視訊教程
這些年整理的幾十套JAVA後端開發視訊教程,包含微服務,分散式,Spring Boot,Spring Cloud,設計模式,快取,JVM調優,MYSQL,大型分散式電商專案實戰等多種內容,關注Nauyus立即回覆【視訊教程】無套路獲取。
福利二:面試題打包下載
這些年整理的面試題資源彙總,包含求職指南,面試技巧,微軟,華為,阿里,百度等多家企業面試題彙總。 本部分還在持續整理中,可以持續關注。立即關注Nauyus回覆【面試題】無套路獲取。