單例模式的7種實現總結


單例模式介紹

  單例模式(Singleton Pattern)是 Java 中最簡單、最常用的設計模式之一。單例模式提供了一種在多線程環境下保證實例唯一性的解決方案。即:對象一經初始化,後需可以直接訪問,不需要再次實例化該類的對象。屬於設計模式三大類中的創建型模式。在Java中,一般常用在工具類的實現、對象創建開銷較大的情景下。
  單例模式具有典型的三個特點:

  1. 單例類只能有一個實例。
  2. 單例類必須自己創建自己的唯一實例。(自我實例化)
  3. 單例類必須給所有其他對象提供這一實例。(提供全局訪問點)

  單例模式雖然比較簡單,但實現方式卻多種多樣,本篇列舉了7種實現方式,並從線程安全高性能懶加載三個維度對其進行評估,比較其優劣。


一、餓漢式

  餓漢模式,顧名思義,就是採用靜態初始化的方式在類被加載時就將自身實例化,所以被形象地稱之爲餓漢式單例模式。

// final不允許被繼承
public final class HungrySingleton {
	//類的成員變量(一般類都有成員變量)
	private byte[] data = new byte[1024];
	//第一步:私有化構造器,不允許外部new操作
	private HungrySingleton(){	
	}
	//第二步:在類初始化時立即實例化該對象,從而保證線程安全
	private static HungrySingleton instance = new HungrySingleton();
	//第三步:提供一個獲取全局訪問點的方法
	public static HungrySingleton getInstance() {
		return instance;
	}
	//other methods
}

  餓漢式把instance作爲"類變量"並且直接初始化,當主動使用Singleton 類時會完成instance的創建,包括其中的實例變量都會得到初始化,比如上例中的data數組將被創建並佔用1K的空間。如果instance被ClassLoader加載後很長一段時間才被使用,那就意味着instance實例所開闢的堆內存會駐留更久的時間,如果一個類的成員佔用的內存資源較多,那麼採用餓漢式就有些不妥。

總結

  • 線程安全。
  • getInstance方法的性能比較高。
  • 無法進行懶加載。

注意:上面代碼中有使用final關鍵字,強制該類不允許被繼承。若父類所有的構造器都是私有的(private修飾),那麼JVM規定該父類不允許被繼承,因爲子類的構造器都必須顯示或隱式調用父類的構造器。此時可以不使用final關鍵字聲明。

JDK餓漢式單例舉例:
在這裏插入圖片描述


二、懶漢式

  懶漢式就是在第一次使用類實例的時候再去創建,和餓漢式在類初始化時就提前創建實例不同,所以就被稱爲懶漢式單例模式。

//final不允許被繼承
public final class LazySingleton {
	//類的成員變量(一般類都有成員變量)
	private byte[] data = new byte[1024];
	// 未實例化的類變量
	private static LazySingleton instance = null;
	// 私有化構造器
	private LazySingleton() {
    }
	// 運行時加載對象
	public static LazySingleton getInstance() {
		//判斷是否已經初始化過(沒有同步機制控制,多線程不安全)
		if (instance == null) {
			instance = new LazySingleton();
		}
		return instance;
	}
	//other methods
}

總結

  • 線程不安全。instance是共享資源,當多個線程對其訪問時需要保證共享資源的同步性,因此線程不安全,無法保證單例的唯一性。(更加具體的原因不攤開說明了)
  • 性能和懶加載,就不討論了,因爲這種方法本身就不正確。

三、懶漢式+synchronized同步

  上述的懶漢式保證了實例的懶加載,但無法保證實例的唯一性,需要增加對共享資源instance的同步訪問機制,可以採用synchronized關鍵字實現。

//final不允許被繼承
public final class LazySingleton {
	//類的成員變量(一般類都有成員變量)
	private byte[] data = new byte[1024];
	// 未實例化的類變量
	private static LazySingleton instance = null;
	// 私有化構造器
	private LazySingleton() {
    }
	// 運行時加載對象(增加了synchronized,每次只能有一個線程能夠進入)
	public static synchronized LazySingleton getInstance() {
		//判斷是否已經初始化過(沒有同步機制控制,多線程不安全)
		if (instance == null) {
			instance = new LazySingleton();
		}
		return instance;
	}
	//other methods
}

總結

  • 線程安全,能夠保證實例的唯一性;
  • getInstance方法採用synchronized關鍵字所以性能較低
  • 懶加載。

四、Double-Check式(注意有坑)

  Double-Check的方式是一種更加高效的數據同步策略,只有首次初始化時才加鎖,之後多個線程獲取實例時都無需同步控制。

// final不允許被繼承
public final class LazySingleton {
	//類的成員變量(一般類都有成員變量)
	private byte[] data = new byte[1024];
	// 未實例化的類變量
	private static LazySingleton instance = null;
	// 私有化構造器
	private LazySingleton() {
	}

	public static LazySingleton getSingleton() {
		// 若instance不爲null,則不用獲取鎖,提升了效率
		if (instance == null) {
			//同步加鎖是爲了線程安全,確保只有一個線程創建實例
			synchronized (LazySingleton.class) {
				//再次判空是爲了保證單例對象的唯一性,只有沒被創建纔去創建
				if (instance == null) {
					instance = new LazySingleton();
				}
			}
		}
		return instance;
	}
	//other methods
}

  當兩個線程同時發現instance == null成立時,只有一個線程有資格進入synchronized同步代碼塊完成instance的實例化,隨後的線程進入synchronized同步代碼塊後發現instance == null不成立則無需再次實例化,以後對getSingleton方法的訪問也不需要執行synchronized同步代碼塊,大大提升了性能。滿足懶加載、線程安全、高性能這三個標準,一切看起來很完美。但這種方式在多線程環境下可能會導致空指針異常,原因如下。

首先,我們要理解new LazySingleton()做了什麼,詳細的介紹請查看《java new一個對象的過程中發生了什麼》。本篇簡單介紹new一個對象需要的4個步驟,如下:

  1. 看class對象是否加載,如果沒有就先加載class對象。(加載)
  2. 爲類的靜態變量分配內存空間併爲其初始化默認值(連接階段),爲靜態變量賦予正確的初始值(初始化階段)。
  3. 調用構造函數。(單例比較複雜時,有很多的成員變量需要初始化)
  4. 返回地址給引用。

然後,cpu爲了優化程序,可能會進行指令重排序,打亂這3,4這幾個步驟,導致實例內存還沒分配(或是隻實例化了部分成員變量),就被使用了,導致空指針。下面舉例:

線程A執行到new LazySingleton(),開始初始化實例對象,由於存在指令重排序,先執行步驟4,先把引用instance賦值了,此時還沒有執行構造函數(或執行還未完成,只實例化了部分成員變量),這時CPU時間片耗盡,切換到線程B執行,線程B調用new LazySingleton()方法,發現instance == null不成立,就直接返回引用地址了,然後線程B執行了一些操作,就可能導致線程B使用了還沒有被初始化的變量,報空指針錯誤。


五、Volatile + Double-Check式(最終版)

  Double-Check是一種巧妙的設計,但由於JVM在運行new LazySingleton()時可能對指令重排序,導致空指針異常。而volatile關鍵字可以防止重排序,因此還需要對Double-Check方式稍加修改。關於volatile關鍵字的用法,可參考另一篇博文《深入理解volatile關鍵字》

// 加volatile 修飾
private static volatile LazySingleton instance = null;

  至此,就有了一個線程安全、高性能、懶加載版本的雙重檢查加鎖式單例模式。但這種寫法對於初學者很棘手,一下子很難理解。


六、Holder式

直接上代碼,然後給出說明。

// 不允許被繼承
public final class HolderSingleton {
	// 類的成員變量
	private byte[] data = new byte[1024];
	// 私有化構造器
	private HolderSingleton() {
	}
	// 靜態內部類 Holder中持有實例instance
	private static class Holder{
		private static HolderSingleton instance = new HolderSingleton();
	}
	// 調用getSingleton,返回Holder的instance類屬性
	public static HolderSingleton getSingleton() {
		return Holder.instance;
	}
	//other methods
}

這種方式利用了類加載的特點。HolderSingleton 類中沒有持有靜態的instance實例,而是放在靜態內部類Holder中,該方式仍然需要私有化構造器。當Holder被主動引用時(懶加載)會創建HolderSingleton 的實例,JVM保證實例的唯一性,性能高。是目前廣泛採用的一種單例設計。

七、枚舉式

八、防止反射/反序列化攻擊單例類

  上面的單例實現方式中,除了枚舉類型外,其他的實現方式是可以被JAVA的反射機制攻擊的。享有特權的客戶端可以藉助AccessibleObject.setAccessible方法通過反射機制調用私有構造器,如果需要抵禦這種攻擊,可以修改構造器,讓它的被要求創建第二個實例的時候拋出一個異常。具體方式請參考 《如何防止JAVA反射對單例類的攻擊?》 《設計模式——單例模式》


參考資料

  1. 《java高併發編程詳解》 汪文君
  2. 《Effective Java 中文版 第2版》
  3. 如何防止JAVA反射對單例類的攻擊?
  4. 單例模式爲什麼要用Volatile關鍵字
  5. java new一個對象的過程中發生了什麼
  6. 設計模式——單例模式
  7. 深入理解Java枚舉類型(enum)
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章