週末了,臨近五一勞動節,女朋友還沒有想好要去哪裡玩,還在看著各種攻略。我則在旁邊一邊看書默默的心疼著我的錢包。突然女朋友開始發問:
單例模式,也叫單子模式,是一種常用的軟體設計模式。在應用這個模式時,單例物件的類必須保證只有一個例項存在。
許多時候整個系統只需要擁有一個的全域性物件,這樣有利於我們協調系統整體的行為。比如在某個伺服器程式中,該伺服器的配置資訊存放在一個檔案中,這些配置資料由一個單例物件統一讀取,然後服務程式中的其他物件再通過這個單例物件獲取這些配置資訊。這種方式簡化了在複雜環境下的配置管理。
舉個簡單的例子,就像中國的一夫一妻制度,夫妻之間只能是一對一的,也就是說,一個男子同時只能有一個老婆。這種情況就叫做單例。在中國,是通過《婚姻法》來限制一夫一妻制的。
男女雙方來到民政局登記
if 男方目前已經有老婆{
提醒二位無法結婚。並告知其當前老婆是誰。
}else{
檢查女方婚姻狀況,其他基本資訊核實。
同意雙方結為夫妻。
}
複製程式碼
對於程式碼開發中,一個類同時只有一個例項物件的情況就叫做單例。那麼,如何保證一個類只能有一個物件呢?
我們知道,在物件導向的思想中,通過類的建構函式可以建立物件,只要記憶體足夠,可以建立任意個物件。
所以,要想限制某一個類只有一個單例物件,就需要在他的建構函式上下功夫。
實現物件單例模式的思路是:
1、一個類能返回物件一個引用(永遠是同一個)和一個獲得該例項的方法(必須是靜態方法,通常使用getInstance這個名稱);
2、當我們呼叫這個方法時,如果類持有的引用不為空就返回這個引用,如果類保持的引用為空就建立該類的例項並將例項的引用賦予該類保持的引用;
3、同時我們還將該類的建構函式定義為私有方法,這樣其他處的程式碼就無法通過呼叫該類的建構函式來例項化該類的物件,只有通過該類提供的靜態方法來得到該類的唯一例項。
public class Singleton {
private static Singleton instance;
private Singleton (){}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
複製程式碼
以上Java程式碼,就實現了一個簡單的單例模式。我們通過將構造方法定義為私有,然後提供一個getInstance方法,該方法中來判斷是否已經存在該類的例項,如果存在直接返回。如果不存在則建立一個再返回。
關於併發,可以參考《如何給女朋友解釋什麼是並行和併發》。
在中國,想要擁有一個妻子,需要男女雙方帶著各自的戶口本一起去民政局領證。民政局的工作人員會先在系統中查詢雙方的婚姻狀況,然後再辦理登記手續。之所以可以保證一夫一妻登記成功的前提是不會發生併發問題。
假設某男子可以做到在同一時間分別和兩個不同的女子來登記,就有一種概率是當工作人員查詢的時候他並沒有結婚,然後就可能給他登記兩次結婚。當然,這種情況在現實生活中是根本不可能發生的。
但是,在程式中,一旦有多執行緒場景,這種情況就很常見。就像上面的程式碼。
上面這種單例的實現方式我們通常稱之為懶漢模式,所謂懶漢,指的是隻有在需要物件的時候才會生成(getInstance方法被呼叫的時候才會生成)。這有點像現實生活中有一種"生米煮成熟飯"的情況,到了一定要結婚的時候才開始去領證。
上面的這種懶漢模式並不是執行緒安全的,所以並不建議在日常開發中使用。基於這種模式,我們可以實現一個執行緒安全的單例的,如下:
public class Singleton {
private static Singleton instance;
private Singleton (){}
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
複製程式碼
通過在getInstance方法上增加synchronized,通過鎖來解決併發問題。這種實現方式就不會發生有多個物件被建立的問題了。
上面這種執行緒安全的懶漢寫法能夠在多執行緒中很好的工作,但是,遺憾的是,這種做法效率很低,因為只有第一次初始化的時候才需要進行併發控制,大多數情況下是不需要同步的。
我們其實可以把上述程式碼做一些優化的,因為懶漢模式中使用synchronized定義一個同步方法,我們知道,synchronized還可以用來定義同步程式碼塊,而同步程式碼塊的粒度要比同步方法小一些,從而效率就會高一些。如以下程式碼:
public class Singleton {
private volatile static Singleton singleton;
private Singleton (){}
public static Singleton getSingleton() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
複製程式碼
上面這種形式,只有在singleton == null的情況下再進行加鎖建立物件,如果singleton!=null的話,就直接返回就行了,並沒有進行併發控制。大大的提升了效率。
從上面的程式碼中可以看到,其實整個過程中進行了兩次singleton == null的判斷,所以這種方法被稱之為"雙重校驗鎖"。
還有值得注意的是,雙重校驗鎖的實現方式中,靜態成員變數singleton必須通過volatile來修飾,保證其初始化的原子性,否則可能被引用到一個未初始化完成的物件。
為什麼雙重校驗鎖需要使用volatile來修飾靜態成員變數singleton?為什麼執行緒安全的懶漢就不需要呢?關於這個問題,後續文章深入講解。
前面提到的懶漢模式,其實是一種lazy-loading思想的實踐,這種實現有一個比較大的好處,就是隻有真正用到的時候才建立,如果沒被使用到,就一直不會被建立,這就避免了不必要的開銷。
但是這種做法,其實也有一個小缺點,就是第一次使用的時候,需要進行初始化操作,可能會有比較高的耗時。如果是已知某一個物件一定會使用到的話,其實可以採用一種餓漢的實現方式。
所謂餓漢,就是事先準備好,需要的時候直接給你就行了。這就是日常中比較常見的"先買票後上車",走正常的手續。
如以下程式碼,餓漢模式:
public class Singleton {
private static Singleton instance = new Singleton();
private Singleton (){}
public static Singleton getInstance() {
return instance;
}
}
複製程式碼
或者以下程式碼,餓漢變種:
public class Singleton {
private Singleton instance = null;
static {
instance = new Singleton();
}
private Singleton (){}
public static Singleton getInstance() {
return this.instance;
}
}
複製程式碼
以上兩段程式碼其實沒有本質的區別,都是通過static來例項化類物件。餓漢模式中的靜態變數是隨著類載入時被完成初始化的。餓漢變種中的靜態程式碼塊也會隨著類的載入一塊執行。
以上兩個餓漢方法,其實都是通過定義靜態的成員變數,以保證instance可以在類初始化的時候被例項化。
因為類的初始化是由ClassLoader完成的,這其實是利用了ClassLoader的執行緒安全機制。ClassLoader的loadClass方法在載入類的時候使用了synchronized關鍵字。也正是因為這樣, 除非被重寫,這個方法預設在整個裝載過程中都是同步的(執行緒安全的)
除了以上兩種餓漢方式,還有一種實現方式也是藉助了calss的初始化來實現的,那就是通過靜態內部類來實現的單例:
public class Singleton {
private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}
private Singleton (){}
public static final Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
}
複製程式碼
前面提到的餓漢模式,只要Singleton類被裝載了,那麼instance就會被例項化。
而這種方式是Singleton類被裝載了,instance不一定被初始化。因為SingletonHolder類沒有被主動使用,只有顯示通過呼叫getInstance方法時,才會顯示裝載SingletonHolder類,從而例項化instance。
使用靜態內部類,藉助了classloader來實現了執行緒安全,這與餓漢模式有著異曲同工之妙,但是他有兼顧了懶漢模式的lazy-loading功能,相比較之下,有很大優勢。
前文介紹過,我們實現的單例,把構造方法設定為私有方法來避免外部呼叫是很重要的一個前提。但是,私有的構造方法外部真的就完全不能呼叫了麼?
其實不是的,我們是可以通過反射來呼叫類中的私有方法的,構造方法也不例外,所以,我們可以通過反射來破壞單例。
除了這種情況,還有一種比較容易被忽視的情況,那就是其實物件的序列化和反序列化也會破壞單例。
如使用ObjectInputStream進行反序列化時,在ObjectInputStream的readObject生成物件的過程中,其實會通過反射的方式呼叫無參構造方法新建一個物件。
可以通過在Singleton類中定義readResolve的方式,解決該問題:
/**
* 使用雙重校驗鎖方式實現單例
*/
public class Singleton implements Serializable{
private volatile static Singleton singleton;
private Singleton (){}
public static Singleton getSingleton() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
private Object readResolve() {
return singleton;
}
}
複製程式碼
在StakcOverflow中,有一個關於What is an efficient way to implement a singleton pattern in Java?的討論:
回答者引用了Joshua Bloch大神在《Effective Java》中明確表達過的觀點:
使用列舉實現單例的方法雖然還沒有廣泛採用,但是單元素的列舉型別已經成為實現Singleton的最佳方法。
如果你真的深入理解了單例的用法以及一些可能存在的坑的話,那麼你也許也能得到相同的結論,那就是:使用列舉實現單例是一種很好的方法。
列舉實現單例:
public enum Singleton {
INSTANCE;
public void whateverMethod() {
}
}
複製程式碼
以上,就實現了一個非常簡單的單例,從程式碼行數上看,他比之前介紹過的任何一種都要精簡,並且,他還是執行緒安全的。
這些,其實還不足以說服我們這種方式最優。但是還有個至關重要的原因,那就是:列舉可解決反序列化會破壞單例的問題
關於這個知識點,大家可以參考《為什麼我牆裂建議大家使用列舉來實現單例》這篇文章,裡面詳細的闡述了關於列舉與單例的所有知識點。
前面講過的所有方式,只要是執行緒安全的,其實都直接或者間接用到了synchronized,那麼,如果不能使用synchronized的話,怎麼實現單例呢?
使用Lock?這當然可以了,但是其實根本還是加鎖,有沒有不用鎖的方式呢?
答案是有的,那就是CAS。CAS是一項樂觀鎖技術,當多個執行緒嘗試使用CAS同時更新同一個變數時,只有其中一個執行緒能更新變數的值,而其它執行緒都失敗,失敗的執行緒並不會被掛起,而是被告知這次競爭中失敗,並可以再次嘗試。
在JDK1.5 中新增java.util.concurrent(J.U.C)就是建立在CAS之上的。相對於對於synchronized這種阻塞演算法,CAS是非阻塞演算法的一種常見實現。所以J.U.C在效能上有了很大的提升。
藉助CAS(AtomicReference)實現單例模式:
public class Singleton {
private static final AtomicReference<Singleton> INSTANCE = new AtomicReference<Singleton>();
private Singleton() {}
public static Singleton getInstance() {
for (;;) {
Singleton singleton = INSTANCE.get();
if (null != singleton) {
return singleton;
}
singleton = new Singleton();
if (INSTANCE.compareAndSet(null, singleton)) {
return singleton;
}
}
}
}
複製程式碼
用CAS的好處在於不需要使用傳統的鎖機制來保證執行緒安全,CAS是一種基於忙等待的演算法,依賴底層硬體的實現,相對於鎖它沒有執行緒切換和阻塞的額外消耗,可以支援較大的並行度。
使用CAS實現單例只是個思路而已,只是擴充一下幫助讀者熟練掌握CAS以及單例等知識、千萬不要在程式碼中使用!!!這個程式碼其實有很大的優化空間。聰明的你,知道以上程式碼存在哪些隱患嗎?