如何建立完美的單例模式?

秉心發表於2017-10-12

原文作者: Ankit Sinhal

原文地址:How to make the perfect Singleton?

譯者: sunluyao

如何建立完美的單例模式?

設計模式在軟體開發者中十分受歡迎。設計模式是對於常見軟體問題的良好解決方案。單例模式是 Java 中建立型設計模式的一種。

單例模式的目的是什麼?

單例類的目的是控制物件建立,約束物件的數量有且只有一個。單例模式只允許有一個入口來建立類例項。

因為只有一個單例類例項,任何單例類的例項都將只會產生一個類,就像靜態域一樣。當你需要控制資源的時候,例如在資料庫連線或者使用 sockets ,單例模式是非常有用的。

這看起來是一個很簡單的設計模式,但是當我們真正去實現的時候,會帶來許多的實現問題。單例模式的實現在開發者當中總是存在一定爭議。現在,我們將會討論一下如何建立一個單例類以完成下列目的:

限制類的例項並且保證在 JVM 中只存在一個類例項。

讓我們在 Java 中建立單例類並在不同的情況下進行測試。

建立單例類

為了實現單例類,最簡單方法是把構造器變為 private。有兩種初始化方法。

餓漢式

餓漢式初始化,單例類的例項在類載入時被建立,這是建立單例類最簡單的方法。

通過將構造器宣告為 private ,不允許其他類來建立單例類例項。取而代之的是,建立一個靜態方法(通常命名為 getInstance)來提供建立類例項的唯一入口。

public class SingletonClass {

	private static volatile SingletonClass sSoleInstance = new SingletonClass();

	//private constructor.
	private SingletonClass(){}

	public static SingletonClass getInstance() {
    	return sSoleInstance;
	}
}
複製程式碼

這種方法有一個缺陷,即使在程式沒有使用到它的時候,例項已經被建立了。當你建立資料庫連線或者 socket 時,這可能成為一個相當大的問題,會導致記憶體洩漏問題。解決方法是當需要的時候再建立例項,我們稱之為懶漢式初始化。

懶漢式

與餓漢式相反,你在 getInstance() 方法中初始化類例項。方法中將會判斷類例項是否已經建立,如果已經存在,將返回舊的例項,反之在 JVM 中建立新的例項並返回。

public class SingletonClass {

	private static SingletonClass sSoleInstance;

	private SingletonClass(){}  //private constructor.

	public static SingletonClass getInstance(){
    	if (sSoleInstance == null){ //if there is no instance available... create new one
        sSoleInstance = new SingletonClass();
    	}

    return sSoleInstance;
	}
}
複製程式碼

我們都知道在 Java 中,如果兩個物件是相同的,那麼他們的 hashCode 也是相同的。讓我們測試一下,如果上面的單例類都正確實現,那麼將會返回同樣的雜湊。

public class SingletonTester {
	public static void main(String[] args) {
    	//Instance 1
    	SingletonClass instance1 = SingletonClass.getInstance();

    	//Instance 2
    	SingletonClass instance2 = SingletonClass.getInstance();

    	//now lets check the hash key.
    	System.out.println("Instance 1 hash:" + instance1.hashCode());
    	System.out.println("Instance 2 hash:" + instance2.hashCode());  
	}
}
複製程式碼

下面是輸出日誌:

15:04:341 I/System.out: Instance 1 hash:247127865
15:04:342 I/System.out: Instance 2 hash:247127865
複製程式碼

可以看到兩個例項擁有同樣的 hashCode。所以,這就意味著上面的程式碼建立了完美的單例類,是嗎?不。

讓單例類反射安全

在上面的單例類中,通過反射可以建立不止一個例項。 Java Reflection 是一個在執行時檢測或者修改類的執行時行為的過程。通過在執行時修改構造器的可見性並通過構造器建立例項可以產生新的單例類例項。執行下面的程式碼,單例類還存在嗎?

public class SingletonTester {
	public static void main(String[] args) {
    	//Create the 1st instance
    	SingletonClass instance1 = SingletonClass.getInstance();
    
    	//Create 2nd instance using Java Reflection API.
    	SingletonClass instance2 = null;
    	try {
        	Class<SingletonClass> clazz = SingletonClass.class;
        	Constructor<SingletonClass> cons = clazz.getDeclaredConstructor();
        	cons.setAccessible(true);
        	instance2 = cons.newInstance();
    	} catch (NoSuchMethodException | 	InvocationTargetException | 	IllegalAccessException | 	InstantiationException e) {
        	e.printStackTrace();
    	}

    	//now lets check the hash key.
    	System.out.println("Instance 1 hash:" + instance1.hashCode());
    	System.out.println("Instance 2 hash:" + instance2.hashCode());
	}
}
複製程式碼

下面是輸出日誌:

15:21:48.216 I/System.out: Instance 1 hash:51110277
15:21:48.216 I/System.out: Instance 2 hash:212057050
複製程式碼

每一個例項都有不同的 hashCode。顯然這個單例類無法通過測試。

解決方案:

為了預防反射導致的單例失敗,當構造器已經初始化並且其他類再次初始化時,丟擲一個執行時異常。讓我們更新 SingletonClass.java

public class SingletonClass {

	private static SingletonClass sSoleInstance;

	//private constructor.
	private SingletonClass(){
   
    	//Prevent form the reflection api.
    	if (sSoleInstance != null){
        	throw new RuntimeException("Use getInstance() method to get the single instance of this class.");
    	}
	} 

	public static SingletonClass getInstance(){
    	if (sSoleInstance == null){ //if there is no instance available... create new one
        	sSoleInstance = new SingletonClass();
    	}

    	return sSoleInstance;
	}
}
複製程式碼

讓單例類執行緒安全

如果兩個執行緒幾乎同時嘗試初始化單例類,將會發生什麼?讓我們測試下面的程式碼,兩個執行緒幾乎同時被建立並且呼叫 getInstance()

public class SingletonTester {
	public static void main(String[] args) {
    	//Thread 1
    	Thread t1 = new Thread(new Runnable() {
        	@Override
        	public void run() {
            	SingletonClass instance1 = SingletonClass.getInstance();
            	System.out.println("Instance 1 hash:" + instance1.hashCode());
        	}
    	});

    	//Thread 2
    	Thread t2 = new Thread(new Runnable() {
        	@Override
        	public void run() {
            	SingletonClass instance2 = SingletonClass.getInstance();
            	System.out.println("Instance 2 hash:" + instance2.hashCode());
        	}
    	});

    	//start both the threads
    	t1.start();
    	t2.start();
	}
}	
複製程式碼

如果你多次執行這些程式碼,有時你會發現不同的執行緒建立了不同的例項。

16:16:24.148 I/System.out: Instance 1 hash:247127865
16:16:24.148 I/System.out: Instance 2 hash:267260104
複製程式碼

這說明了你的單例類不是執行緒安全的。所有的執行緒同時呼叫 getInstance()方法,sSoleInstance == null 條件對所有執行緒返回值,所以兩個不同的例項被建立。這打破了單例原則。

解決方案

同步 getInstance() 方法

public class SingletonClass {

	private static SingletonClass sSoleInstance;

	//private constructor.
	private SingletonClass(){
   
    	//Prevent form the reflection api.
    	if (sSoleInstance != null){
        	throw new RuntimeException("Use getInstance() method to get the single instance of this class.");
    	}
	} 

	public synchronized static SingletonClass getInstance(){
    	if (sSoleInstance == null){ //if there is no instance available... create new one
        	sSoleInstance = new SingletonClass();
    	}

    	return sSoleInstance;
	}
}
複製程式碼

在我們同步 getInstance() 方法之後,第二個執行緒必須等到第一個執行緒執行完 getInstance() 方法之後才能執行,這就保證了執行緒安全。

但是,這個方法同樣有一些缺點:

  • 鎖的開銷導致執行變慢
  • 例項變數初始化之後的同步操作時不必要的

雙檢查鎖

使用 雙檢查鎖 方法建立例項可以克服上面的問題。

這這種方法中,當例項為空時,在同步程式碼塊中建立單例類,這樣只有當 sSoleInstance 為空時,同步程式碼塊才會執行,避免了不必要的同步操作。

public class SingletonClass {

	private static SingletonClass sSoleInstance;

	//private constructor.
	private SingletonClass(){

    	//Prevent form the reflection api.
    	if (sSoleInstance != null){
        	throw new RuntimeException("Use getInstance() method to get the single instance of this class.");
    	}
	}

	public static SingletonClass getInstance() {
    	//Double check locking pattern
    	if (sSoleInstance == null) { //Check for the first time
      
        	synchronized (SingletonClass.class) {   //Check for the second time.
          	//if there is no instance available... create new one
          	if (sSoleInstance == null) sSoleInstance = new SingletonClass();
        	}
    	}

    	return sSoleInstance;
	}
}
複製程式碼

使用 volatile 關鍵字

表面上看,這個方法看起來很完美,你只需要付出一次靜態程式碼塊的代價。但是除非你使用 volatile 關鍵字,否則單例仍然會被打破。

沒有 volatile 修飾符,另一個執行緒可能在變數 sSoleInstance 正在初始化尚未完成時引用它。但是通過 volatile 的保證 happens-before 關係,所有對於 sSoleInstance 變數的寫操作都會在讀操作之前發生。

public class SingletonClass {

	private static volatile SingletonClass sSoleInstance;

	//private constructor.
	private SingletonClass(){

    	//Prevent form the reflection api.
    	if (sSoleInstance != null){
        	throw new RuntimeException("Use getInstance() method to get the single instance of this class.");
    	}
	}

	public static SingletonClass getInstance() {
    	//Double check locking pattern
    	if (sSoleInstance == null) { //Check for the first time
      
        	synchronized (SingletonClass.class) {   //Check for the second time.
          	//if there is no instance available... create new one
          	if (sSoleInstance == null) sSoleInstance = new SingletonClass();
        	}
    	}

    	return sSoleInstance;
	}
}
複製程式碼

現在上面的單例類是執行緒安全的。在多執行緒應用環境中(比如安卓應用)保證單例類的執行緒安全是必需的。

讓單例類序列化安全

在分散式系統中,有些情況下你需要在單例類中實現 Serializable 介面。這樣你可以在檔案系統中儲存它的狀態並且在稍後的某一時間點取出。

讓我們測試一個這個單例類在序列化和反序列化之後是否仍然保持單例。

public class SingletonTester {
	public static void main(String[] args) {
  
  		try {
    	    SingletonClass instance1 = SingletonClass.getInstance();
    	    ObjectOutput out = null;

    	    out = new ObjectOutputStream(new FileOutputStream("filename.ser"));
    	    out.writeObject(instance1);
    	    out.close();

    	    //deserialize from file to object
    	    ObjectInput in = new ObjectInputStream(new FileInputStream("filename.ser"));
    	    SingletonClass instance2 = (SingletonClass) in.readObject();
    	    in.close();

    	    System.out.println("instance1 hashCode=" + instance1.hashCode());
    	    System.out.println("instance2 hashCode=" + instance2.hashCode());

    	} catch (IOException | ClassNotFoundException e) {
    	    e.printStackTrace();
    	}
  }
}


16:16:24.148 I/System.out: Instance 1 hash:247127865
16:16:24.148 I/System.out: Instance 2 hash:267260104
複製程式碼

可以看到例項的 hashCode 是不同的,違反了單例原則。序列化單例類之後,當我們反序列化時,會建立一個新的類例項。為了預防另一個例項的產生,你需要提供 readResolve() 方法的實現。readResolve()代替了從流中讀取物件。這就確保了在序列化和反序列化的過程中沒人可以建立新的例項。

public class SingletonClass implements Serializable {

	private static volatile SingletonClass sSoleInstance;

	//private constructor.
	private SingletonClass(){

    	//Prevent form the reflection api.
    	if (sSoleInstance != null){
    	    throw new RuntimeException("Use getInstance() method to get the single instance of this class.");
   	 }
	}

	public static SingletonClass getInstance() {
    	if (sSoleInstance == null) { //if there is no instance available... create new one
    	    synchronized (SingletonClass.class) {
    	        if (sSoleInstance == null) 	sSoleInstance = new SingletonClass();
    	    }
   	 }

    	return sSoleInstance;
	}

	//Make singleton from serialize and deserialize operation.
	protected SingletonClass readResolve() {
    	return getInstance();
	}
}
複製程式碼

結論

在文章的最後,你可以建立執行緒,反射和序列化安全的單例類,但這仍然不是完美的單例,你可以使用克隆或者多個類載入器來建立不止一個例項。但是對於大多數應用,上面的實現方法已經可以很好的工作了。

文章同步更新於微信公眾號: 秉心說 , 專注 Java 、 Android 原創知識分享,LeetCode 題解,歡迎關注!

如何建立完美的單例模式?

相關文章