單例模式在網上已是被寫爛的一種設計模式了,筆者也看了很多的有關單例模式的文章,可是在實際生產中使用的並非不少,若是一個知識點,你看過100遍,可是一次也沒實踐過,那麼它終究不是屬於你的。所以我藉助這篇文章來複習下設計模式中的單例模式。java
單例模式的做用在於保證整個程序在一次運行的過程當中,被單例模式聲明的類的對象要有且只有一個。針對不一樣的應用場景,單例模式的實現要求也不一樣。下文將描述幾種單例模式的實現方案,從性能和實現上將有所差別,他們在必定程度上都能保證單例的存在,可是要在生產環境的角度來看待哪種實現纔是最合適的。面試
單例模式的從實現步驟上來說,分爲三步:設計模式
經過上述三點要求咱們能夠幾乎就能夠寫出一個最最基本的單例實現方案,也就是各類資料中所描述的「餓漢式」。安全
public class BasicSingleTon { //建立惟一實例 private static final BasicSingleTon instance = new BasicSingleTon(); //第二部暴露靜態方法返回惟一實例 public static BasicSingleTon getInstance() { return instance; } //第一步構造方法私有 private BasicSingleTon() { } } 複製代碼
該方法實現簡單,也是最經常使用的一種,在不考慮線程安全的角度來講此實現也算是較爲科學的,可是存在一個很大缺點就是,在虛擬機加載改類的時候,將會在初始化階段爲類靜態變量賦值,也就是在虛擬機加載該類的時候(此時可能並無調用 getInstance 方法)就已經調用了 new BasicSingleTon();
建立了改對象的實例。可是若是追求代碼的效率那麼就須要採用下面這種方式,即延遲加載的方式。多線程
也許這裏看過看多例子的讀者可能對 Instance 變量的聲明爲 static final 有所疑問,由於有的文章裏之聲明爲 static,其實筆者認爲在此單例模式的基本應用場景下,兩者沒有很大的區別,聲明爲 final 只是爲了保證對象在方法區中的地址沒法改變。而對對象的初始化時機沒有影響。組件化
延遲加載的方式,是在咱們編碼過程當中儘量晚的實例化話對象,也就是避免在類的加載過程當中,讓虛擬機去建立這個實例對象。這種實現也就是咱們所說的「懶漢式」。他的實現也很簡單,將對象的建立操做後置到 getInstance
方法內部,最初的靜態變量賦予 null ,而 在第一次調用 getInstance
的時候建立對象。性能
public class LazyBasicSingleTon { private static LazyBasicSingleTon singleTon = null; public static LazyBasicSingleTon getInstance() { //延遲初始化 在第一次調用 getInstance 的時候建立對象 if (singleTon == null) { singleTon = new LazyBasicSingleTon(); } return singleTon; } private LazyBasicSingleTon() { } } 複製代碼
對於單線程模式上述的延遲加載已經算的上是很好的單例實踐方式了。一方面Java 是一個多線程的內存模型。而靜態變量存在於虛擬機的方法區中,該內存空間被線程共享,上述實現沒法保證對單例對象的修改保證內存的可見性,原子性。而另外一方面,newInstance 方法自己就不是一個原子類操做(分爲兩步第一步判空,第二步調用 new 來建立對象),因此結論是上述兩種實現方式不適合多線程的引用場景。gradle
那麼對於多線程環境下單例實現模式,存在的問題,咱們能夠舉個簡單的例子,假設有兩個線程都須要這個單例的對象,線程 A 率先進入語句 if (singleTon == null)
獲得的結果爲 true,此時 CPU 切換線程 B 去執行,因爲 A 線程並無進行 new LazyBasicSingleTon();
的操做,那麼 B 線程在執行語句 singleTon == null
的結果認爲 true,緊接着 B 線程建立了改類的實例對象,當 CPU 從新回到 A 線程去執行的時候,又會建立一個類的實例,這就致使了,所謂的單例並不真正的惟一,也就會產生錯誤。優化
爲了解決這個缺點,咱們能想到方法首先就是加鎖,使用 synchronized
關鍵字來保證,在執行 getInstance 的時候不會發生線程的切換。編碼
public class SyncSingleTon { private static SyncSingleTon singleTon = null; /** 使用 synchronized 保證線程在建立對象的時候讓其餘線程阻塞*/ public static synchronized SyncSingleTon getInstance() { if (singleTon == null) { singleTon = new SyncSingleTon(); } return singleTon; } private SyncSingleTon() { } } 複製代碼
其實 synchronized
關鍵字也能夠加在判空操做上,這樣本質上並無區別,只是別的資料中有這種實現方式,所以在這裏給出實現:
public static SyncSingleTon getInstance() { synchronized(SyncSingleTon.class){ if (singleTon == null) { singleTon = new SyncSingleTon(); } } return singleTon; } 複製代碼
上面的例子給出的多線程下的單例實現,也能夠保證在大多數狀況下。能夠保證單例的惟一性,可是對於效率會產生影響,由於若是咱們可預料的線程切換場景並非那麼頻繁,那麼synchronized
爲getInstance
方法加鎖,將會帶來很大效率丟失,好比單線程的模式下。
咱們繼續深刻思考一下,能夠想到,是由於在第一次獲取該實例的時候,若是恰好發生了線程的切換將會早上咱們所描述的單例不惟一的結果,在以後的調用過程當中將會不會形成這樣的結果。因此咱們能夠在 synchronized
語句以前,額外添加一次判空操做,來優化上述方案帶來的效率損失。
public class SyncSingleTon { private static SyncSingleTon singleTon = null; public static SyncSingleTon getInstance() { //此次判空是避免了,保證的多線程只有第一次調用getInstance 的時候纔會加鎖初始化 if (singleTon == null) { synchronized (SyncSingleTon.class) { if (singleTon == null) { singleTon = new SyncSingleTon(); } } } return singleTon; } private SyncSingleTon() { } } 複製代碼
上述方案很好的解決了,最開始的實如今效率上的損失,好比在多個線程場景中,即便在第一次if (singleTon == null)
判空操做中讓出 CPU 去執行,那麼在另外一個線程中也會在同步代碼中初始化改單例對象,待 CPU 切換回來的時候,也會在第二次判空的時候獲得正確結果。
當咱們都認爲這一切的看上去很完美的時候,JVM 又給我提出了個難題,那就是指令重排。
什麼是指令重排,指令重排的用大白話來簡單的描述,就是說在咱們的代碼運行時,JVM 並不必定老是按照咱們想讓它按照編碼順序去執行咱們所想象的語義,它會在 "不改變" 原有代碼語句含義的前提下進行代碼,指令的重排序。
對於指令重排Java 語言規範給出來了下面的定義:
根據《The Java Language Specification, Java SE 7 Edition》(簡稱爲java語言規範),全部線程在執行java程序時必需要遵照 intra-thread semantics(譯爲 線程內語義是一個單線程程序的基本語義)。intra-thread semantics 保證重排序不會改變單線程內的程序執行結果。換句話來講,intra-thread semantics 容許那些在單線程內,不會改變單線程程序執行結果的重排序。
那麼咱們上述雙重檢驗鎖的單例實現問題主要出在哪裏呢?問題出在 singleTon = new SyncSingleTon();
這句話在執行的過程。首先應該進行對象的建立操做大致能夠分爲三步:
(1)分配內存空間。
(2)初始化對象即執行構造方法。
(3)設置 Instance 引用指向該內存空間。
那麼若是有指令重排的前提下,這三部的執行順序將有可能發生變化:
(1)分配內存空間。
(2)設置 Instance 引用指向該內存空間。
(3)初始化對象即執行構造方法。
上面類初始化描述的步驟 2 和 3 之間雖然被重排序了, 可是這個重排序在沒有改變單線程程序的執行結果。那麼再多線程的前提下這將會形成什麼樣的後果呢?咱們假設有兩個線程同時想要初始化這個類, 這兩個線程的執行以下圖所示:
若是按照上述的語義去執行,單看線程 A 中的操做雖然指令重排了,可是返回結果並不影響。可是這樣形成的問題也顯而易見,b 線程將返回一個空的 Instance,可怕的是咱們認爲這一切是正常執行的。
爲了解決上述問題咱們能夠從兩個方面去考慮:
instance == null
對於 Volatile 關鍵字,這裏不作詳細的描述,讀者須要瞭解的是,volatile 做用有如下兩點:
能夠保證多線程條件下,內存區域的可見性,即便用 volatile 聲明的變量,將對在一個線程從內主內存(線程共享的內存區域)讀取變量,並寫入後,通知其餘線程,改變量被我改變了,別的線程在使用的時候,將會從新從主內存中去讀改變量的最新值。
能夠保證再多線程的狀況下,指令重排這個操做將會被禁止。
那麼改造完成的雙重檢鎖的單例將會是這樣的:
public class VolatileSingleTon { //使用 Volatile 保證了指令重排序在這個對象建立的時候不可用 private volatile static VolatileSingleTon singleTon = null; public static VolatileSingleTon getInstance() { if (singleTon == null) { synchronized (VolatileSingleTon.class) { if (singleTon == null) { singleTon = new VolatileSingleTon(); } } } return singleTon; } private VolatileSingleTon() {} } 複製代碼
因爲 volatile 關鍵字是在 JDK 1.5 以後被明確了有禁止指令重排的語義的,那麼有沒有可能不用 volatile 就能解決咱們上述描述的指令重排形成的問題呢,答案是確定的。
上述咱們使用 Volatile 關鍵字去解決指令重排的方法是從避免指令重排的思路出發來解決問題的。那麼對於第二種 讓 A 線程完成對象初始化後,B 再去判斷 instance == null
思路聽起來好像有必定的加鎖韻味,那麼咱們怎麼去給一個對象的初始化過程去加鎖呢,看起來好像沒思路。
這裏咱們須要補充一個知識點,是有關 JVM 在類的初始化階段期間,將會去獲取一個鎖,這個鎖的做用是能夠同步多個線程對同一個類的初始化操做。JVM 在類初始化期間會得到一個稱作初始化鎖的東西,而且每一個線程至少獲取一次鎖來確保這個類已經被初始化過了。
咱們能夠理解爲:若是一個線程在初始化一個類的時候,將會爲這個初始化過程上鎖,當此時有其餘的線程嘗試初始化這個類的時候,將會查看這個鎖的狀態,若是這個鎖沒有被釋放,那麼將會處於等待鎖釋放的狀態。這和咱們用的 synchronized
機制很類似,只是被用在類的初始化階段。
對於靜態內部類,相信讀者必定清除它不依靠外部類的存在而存在。在編譯階段將做爲獨立的一個類,生成本身的 .class 文件。而且在初始化階段也是獨立的,也就是說擁有上述所說的初始化鎖。
那麼咱們能夠有以下思路:
getInstance
中返回注意這裏的初始化是指在JVM 類加載過程當中 加載->連接(驗證,準備,解析)->初始化 中的初始化。這個初始化過程將爲類的靜態變量付具體的值。
對於一個類的初始化時機有一下幾種狀況:
1) 使用new關鍵字實例化對象的時候、讀取或設置一個類的靜態字段(被final修飾、已在編譯期把結果放入常量池的靜態字段除外)的時候,以及調用一個類的靜態方法的時候。
2)使用java.lang.reflect包的方法對類進行反射調用的時候,若是類沒有進行過初始化,則須要先觸發其初始化。
3)當初始化一個類的時候,若是發現其父類尚未進行過初始化,則須要先觸發其父類的初始化。
咱們先來看下這裏的具體實現:
public class StaticInnerSingleTon { private static class InnerStaticClass{ private static StaticInnerSingleTon singleTon = new StaticInnerSingleTon(); } public StaticInnerSingleTon getInstance(){ // //引用一個類的靜態成員,將會觸發該類的初始化 符合1)規則 return InnerStaticClass.singleTon; } private StaticInnerSingleTon() { } } 複製代碼
上述講了這麼多實現方法,也講了各個實現的缺點。直到咱們說了靜態內部類的實現單例的思路後咱們彷彿打開了新世界的大門。
爲何說枚舉實現單例的方法最簡單,這是由於 Enum 類的建立自己是就是線程安全的,這一點和靜態內部類類似,所以咱們沒必要去關心什麼 DCL 問題,而是拿拿起鍵盤直接幹:
public enum EnumSingleTon { INSTANCE } public class SingleTon { public static void main(String[] args) { EnumSingleTon instance = EnumSingleTon.INSTANCE; EnumSingleTon instance1 = EnumSingleTon.INSTANCE; System.out.println("instance1 == instance = " + (instance1 == instance));//輸出結果爲 true } } 複製代碼
枚舉的思想實際上是經過共有的靜態 final 與爲每一個枚舉常量導出實例的類,因爲沒有可訪問的構造器,因此不能調用枚舉常量的構造方法去生成對應的對象,所以在《Effective Java》 中,枚舉類型爲類型安全的枚舉模式,枚舉也被稱爲單例的泛型化。
一篇行文下來,對於單例模式的理解變的更加深入了,尤爲是 DSL(double checked locking)) 的問題的解決思路上,更是涉及到,指令重排和類的加載機制的方面的知識。面試的時候,面試官也常常由此引出更深的只是,好比JVM 類加載的相關知識點,volatile 關鍵字的做用,以及多線程方面的知識點。其實對於面試者來講這也許是個好事,畢竟有跡可循了。
筆者最近加班加傻了,文章都半個月沒跟新了。可是年初定下的目標沒有忘卻。我的這種層層深刻的瞭解比業務代碼更能帶來快感。可是這都是一些拾人牙慧的東西了,看到別的大佬都在研究 gradle 和插件化組件化,筆者也是眼紅... 精力就那麼多,這可如何是好呀。