設計模式-單例模式
單例模式在網上已經是被寫爛的一種設計模式了,筆者也看了不少的有關單例模式的文章,但是在實際生產中使用的并不是很多,如果一個知識點,你看過100遍,但是一次也沒實踐過,那么它終究不是屬于你的。因此我借助這篇文章來復習下設計模式中的單例模式。
單例模式的作用在于保證整個程序在一次運行的過程中,被單例模式聲明的類的對象要有且只有一個。針對不同的應用場景,單例模式的實現要求也不同。下文將描述幾種單例模式的實現方案,從性能和實現上將有所差異,他們在一定程度上都能保證單例的存在,但是要在生產環境的角度來看待哪一種實現才是最合適的。
最基本的實現方案
單例模式的從實現步驟上來講,分為三步:
- 構造方法私有,保證無法從外部通過 new 的方式創建對象。
- 對外提供獲取該類實例的靜態方法
- 類的內部創建該類的對象,通過第 2 步的靜態方法返回
通過上述三點要求我們可以幾乎就可以寫出一個最最基本的單例實現方案,也就是各種資料中所描述的「餓漢式」。
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 來創建對象),所以結論是上述兩種實現方式不適合多線程的引用場景。
那么對于多線程環境下單例實現模式,存在的問題,我們可以舉個簡單的例子,假設有兩個線程都需要這個單例的對象,線程 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;
}
雙重判空操作的多線程單例實現
上面的例子給出的多線程下的單例實現,也可以保證在大多數情況下??梢员WC單例的唯一性,但是對于效率會產生影響,因為如果我們可預料的線程切換場景并不是那么頻繁,那么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 引用指向該內存空間。
那么如果有指令重排的前提下,這三部的執行順序將有可能發生變化:
?。?)分配內存空間。
(2)設置 Instance 引用指向該內存空間。
?。?)初始化對象即執行構造方法。
上面類初始化描述的步驟 2 和 3 之間雖然被重排序了, 但是這個重排序在沒有改變單線程程序的執行結果。那么再多線程的前提下這將會造成什么樣的后果呢?我們假設有兩個線程同時想要初始化這個類, 這兩個線程的執行如下圖所示:
如果按照上述的語義去執行,單看線程 A 中的操作雖然指令重排了,但是返回結果并不影響。但是這樣造成的問題也顯而易見,b 線程將返回一個空的 Instance,可怕的是我們認為這一切是正常執行的。
為了解決上述問題我們可以從兩個方面去考慮:
- 避免指令重排
- 讓 A 線程完成對象初始化后,B 再去判斷
instance == null
通過 Volatile 避免指令重排序
對于 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
上述講了這么多實現方法,也講了各個實現的缺點。直到我們說了靜態內部類的實現單例的思路后我們仿佛打開了新世界的大門。
為什么說枚舉實現單例的方法最簡單,這是因為 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 和插件化組件化,筆者也是眼紅... 精力就那么多,這可如何是好呀。