之前我曾經寫過一篇文章《單例模式有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回復【面試題】無套路獲取。
