設計模式——單例模式

2022-07-25 18:00:08

引言

  今天來談談設計模式中的單例模式,溫故知新,以免生疏。

  軟體設計領域的四位世界級大師Gang Of Four (GoF):Erich Gamma,Richard Helm,Ralph Johnson,John Vlissides四人合著了《Design Patterns - Elements of Reusable Object-Oriented Software》一書,(中文譯名:《設計模式:可複用物件導向軟體的基礎》)。該書首次提到了軟體開發中設計模式的概念,對物件導向軟體設計產生了巨大影響。

  • 建立型模式

  單例模式屬於建立型模式,那麼這裡就要簡述一下建立型模式。顧名思義,就是建立物件的設計模式。頻繁地使用基本物件建立方式(比如new操作)會使系統的耦合性變高,導致某些設計上的問題。建立型模式對類的範例化進行抽象,將物件的建立與物件的使用分離,隱藏了類的範例化過程。

  • 單例模式的由來

  在系統中,有一些物件其實只需要一個,例如執行緒池、快取、登入檔、紀錄檔物件、充當印表機顯示卡等裝置驅動程式的物件。同時這些類比較龐大複雜,並且這些物件完全可以複用。若建立多個範例或頻繁建立銷燬範例物件,會導致程式行為異常、資源使用過量、或者不一致性的結果,這就有了單例模式。

  • 單例模式含義

    確保一個類只有一個範例,並提供該範例的全域性存取點,後半句通俗點講,就是向整個系統提供這個範例。

類的構成

  • 建構函式: private Singleton(){},私有。因為一個類只能有一個範例,不可被外部再次範例化,構造方法不可能是public,只能是private。保證了不能通過構造器進行建立範例物件。

  • (成員)變數:private static Singleton instance,私有,靜態變數。由於類中僅有一個範例,屬於當前類的靜態變數,外部無法直接存取。

  • 方法:public static Singleton getInstance(){},公有,靜態方法。要向整個系統提供該單例,就要建立一個公有的靜態方法向外界提供當前類的範例。

實現方式

1. 懶漢式(執行緒不安全)

  • 描述:這是最基本的實現方式,範例物件在第一次被呼叫的時候才會被建立,屬於懶載入,延遲建立單例。
  • 優點:懶載入模式下,如果沒有使用到該類,那麼就不會範例化物件,節約了資源。
  • 缺點:這種實現方式不支援多執行緒,這是最主要的問題,因為沒有加鎖 synchronized,無法實現執行緒安全。在執行if (Instance == null)時,如果多個執行緒同時進入,並且此時Instance 為 null,那麼這些執行緒就會在執行new語句,導致多次範例化物件,這是我們不希望看到的。
public class Singleton {  
    private static Singleton instance;  //宣告靜態變數
    private Singleton (){}   //構造器
  
    public static Singleton getInstance() {  
        if (instance == null) {  
            instance = new Singleton();  
        }  
        return instance;  
    }  
}

2. 懶漢式(執行緒安全)

  • 描述:範例物件在也是第一次被呼叫的時候才會被建立,屬於懶載入,通過靜態同步方法解決執行緒安全問題。
  • 優點:懶載入,節約記憶體資源。使用同步方法,在某個時間點只能有一個執行緒能夠進入方法,避免了多次範例化的問題,因此支援多執行緒,保證了執行緒安全。
  • 缺點:我們希望在建立範例的時間點進行加鎖同步,用靜態同步方法會使得同步的範圍太大,另外每次要建立物件都要爭搶鎖,未進入方法的執行緒必須等待效能會有損耗,效率不高
public class Singleton {  
    private static Singleton instance;  
    private Singleton (){}  
    public static synchronized Singleton getInstance() {  //使用同步方法
        if (instance == null) {  
            instance = new Singleton();  
        }  
        return instance;  
    }  
}

3. 餓漢式(執行緒安全)

  • 描述:執行緒不安全問題主要是由於Instance可被多次範例化,因此,在類載入時就直接範例化Instance就可以保證執行緒安全問題。它基於 累載入機制避免了多執行緒的同步問題,不過這時候初始化instance顯然沒有達到懶載入(lazy loading)的效果。
  • 優點:未使用同步鎖,執行效率會有所提高,執行緒安全。
  • 缺點:直接在類載入時自動範例化物件,失去了懶載入機制下節約資源的優勢,消耗記憶體。
public class Singleton {  
    private static Singleton instance = new Singleton();  //類載入時就進行範例化
    private Singleton (){}  
    public static Singleton getInstance() {  
    return instance;  
    }  
}

注:懶漢式與餓漢式最主要的區別在於建立單例的時機不同,懶漢式根據是否需要範例,手動建立;餓漢式在類載入時自動建立單例

4. 雙重校驗鎖方式(執行緒安全)

  • 描述:雙重校驗鎖(double-checked locking,DCL)也叫雙檢鎖,JDK1.5出現的功能。這種方式採用雙鎖機制,同時加鎖操作只需要對範例化那部分程式碼進行,只有當Instance沒有被範例化時(Instance == null) ,才需要進行加鎖。

  • 優點:懶載入,多執行緒環境下可保證執行緒安全,效能較高。

  • 缺點:相比前幾種方式,實現較為複雜。

  • 雙重校驗鎖的完善過程:

  1. 由於使用懶漢式同步方法會消耗過多效能,我們只在構建範例物件的時候進行同步。在呼叫getInstance()時,存取的執行緒不需要競爭鎖,都可以直接進入。再進行下一步判斷,若此時範例物件還沒有被構建,執行緒開始競爭鎖,搶到鎖的執行緒開始建立單例。
public class Singleton {
    private static Singleton Instance;
    private Singleton() {}

    public static Singleton getInstance() {
        if (Instance == null) {
            synchronized (Singleton.class) {  //在需要構建單例的時候給Class物件加鎖
               Instance = new Singleton();
            }
        }
        return Instance;
    }
}

問題:在多個執行緒執行判斷條件時,雖然只有一個執行緒能夠搶到鎖取建立單例,但是可能有其他執行緒已經進入了if程式碼塊,之後會再進行if判斷了,而這些執行緒等待釋放鎖後,隨即又會建立範例物件,最終範例會被多次被建立。顯然執行緒不安全。

  1. 再增加一條判斷條件,這也是雙重校驗鎖中「雙重」的由來。我們假設執行緒A搶到同步鎖,然後建立範例,建立完畢釋放鎖。這時,執行緒B搶到鎖,進行判斷範例是否被建立,發現範例instance已經被執行緒A初始化了,不可能等於null,直接退出,返回A執行緒建立的單例。
public class Singleton {
    private static Singleton Instance;
    private Singleton() {}

    public static Singleton getInstance() {
        if (Instance == null) {
            synchronized (Singleton.class) {  //在需要構建單例的時候給Class物件加鎖
            if (Instance == null) {          //增加了判空條件
              	  Instance = new Singleton();
            	}
            }
        }
        return Instance;
    }
}
  1. 在執行 Instance = new Singleton();時,大致可以分為三步:1)給Instance範例分配記憶體;2)初始化Instance的構造器;3)將instance物件指向分配的記憶體地址(這一步Instance就非null了)。由於JVM為了優化指令,提高程式的執行效率,允許指令重排,導致在程式實際執行的時候,順序變為1)>> 3)>> 2),這在單執行緒的情況下是沒有問題的。但是,在多執行緒的環境下,執行緒有可能拿到一個尚未被初始化的範例,程式必然報錯使用 volatile 關鍵字可以禁止 JVM 的指令重排,保證在多執行緒環境下也能正常執行。
public class Singleton {  
    private volatile static Singleton Instance;  //增加volatile關鍵字,防止JVM指令重排
    private Singleton (){}  
    
    public static Singleton getInstance() {  
    if (Instance == null) {  
        synchronized (Singleton.class) {  
            if (Instance == null) {  
                Instance = new Singleton();  
            }  
        }  
    }  
    return singleton;  
    }  
}

5.靜態內部類方式

  • 描述:這種方式能達與雙檢鎖功能相似,且實現更簡單。由於靜態內部類的載入是在程式中呼叫靜態內部類的時候載入的,和外部類的載入沒有必然關係,因此當 Singleton 類載入時,靜態內部類 SingletonHolder 並沒有被載入進記憶體。只有當呼叫 getInstance() 方法從而觸發 SingletonHolder.INSTANCE 時 ,SingletonHolder 才會被載入,此時初始化INSTANCE範例。實現了延遲載入。 這種方式只適用於靜態域的情況,雙檢鎖方式可在範例域需要延遲初始化時使用。
  • 優點:延時載入,按需載入,節約資源;由於JVM提供了對執行緒安全的支援,只會載入一遍,執行緒安全得到保證。
  • 缺點:這種方式只適用於靜態域的情況。
public class Singleton {
    private Singleton() {
    }
	//靜態內部類
    private static class SingletonHolder {
        private static final Singleton INSTANCE = new Singleton();
    }

    public static Singleton getInstance() {  
        return SingletonHolder.INSTANCE;    //存取靜態內部類中靜態成員
    }
}

6.列舉方式

  • 描述:這是實現單例模式的最佳方法。這種方式是《Effective Java》作者 Joshua Bloch 提倡的方式,它不僅能避免多執行緒同步問題,而且還自動支援序列化機制,防止反序列化重新建立新的物件,絕對防止多次範例化。出現反射攻擊時,通過 setAccessible() 方法可以將私有建構函式的存取級別設定為 public,然後呼叫建構函式從而範例化物件。如果要防止這種攻擊,需要在建構函式中新增防止範例化第二個物件的程式碼。解決序列化和反射攻擊很麻煩,而列舉實現不會出現這兩種問題,因此說列舉實現單例模式式最佳實踐方法。
  • 優點:單例模式的最佳實踐,它實現簡單,並且在面對複雜的序列化或者反射攻擊的時候,不能呼叫private方法,能夠防止多次範例化,是目前最安全的實現單例的方法。
  • 缺點:這種方式尚未被廣泛採用,實際工作中,很少會被採用。
public enum Singleton {  
    INSTANCE;  
    public void whateverMethod() {  //任意方法
    }  
}

結語

  一般情況下,不建議使用兩種懶漢式實現單例模式;明確使用靜態方法和實現懶載入效果時,會採用靜態內部類方式;涉及到反序列化建立物件的時候,可以使用列舉方式;一般而言,餓漢式以及雙重校驗鎖比較常用。