以前我曾經寫過一篇文章《單例模式有8種寫法,你知道麼?》,其中提到了一種實現單例的方法-雙重檢查鎖,最近在讀併發方面的書籍,發現雙重檢查鎖使用不當也並不是絕對安全,在這裏分享一下。java
首先咱們回顧一下最簡單的單例模式是怎樣的?程序員
/** *單例模式一:懶漢式(線程安全) */
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帶來的性能開銷。上面代碼表面上看起來,彷佛一箭雙鵰:設計模式
程序看起來很完美,可是這是一個不完備的優化,在線程執行到第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不保證對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種執行順序:
as-if-serial語義
,遵照as-if-serial語義的編譯器,runtime 和處理器共同爲編寫單線程程序的程序員建立了一個幻覺: 單線程程序是按程序的順序來執行的。回來看下咱們出問題的雙重檢查鎖程序,它是知足as-if-serial語義
的嗎?是的,單線程下它沒有任何問題,可是在多線程下,會由於重排序出現問題。
解決方案就是大名鼎鼎的volatile關鍵字,對於volatile咱們最深的印象是它保證了」可見性「,它的」可見性「是經過它的內存語義實現的:
重點:爲了實現可見性內存語義,編譯器在生成字節碼時,會在指令序列中插入內存屏障來防止重排序!
對以前代碼加入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回覆【面試題】無套路獲取。