一起學習設計模式--01.單例模式

單例模式(Singleton Pattern):確保某個類只有一個實例,而且自行實例化並向整個系統提供這個實例。

單例模式是創建型模式的一種,是創建型模式中最簡單的設計模式
用於創建那些在軟件系統中獨一無二的對象。
雖然單例模式很簡單,但是它的使用頻率還是很高的。

學習難度:★☆☆☆☆ 使用頻率:★★★★☆

一、單例模式的動機

任務管理器相信大家都不陌生,大家可以用自己的電腦做個嘗試,在Windows的任務欄的右鍵菜單中多次點擊“任務管理器”,看能否打開多個任務管理器窗口。正常情況下,無論任務管理器啓動多少次,Windows系統始終只會打開一個任務管理器窗口,也就是說,在一個Windows系統中,任務管理器存在唯一性。

在實際的開發中也經常遇到過類似的情況,爲了節約系統資源,有時需要確保系統中某個類只有唯一一個實例,當這個唯一實例創建成功後,無法再創建一個同類型的其它對象,所有的操作都只能基於這個唯一實例。爲了確保對象的唯一性,可以通過單例模式來實現,這就是單例模式的動機所在。

二、單例模式的概述

1.單例模式的定義

單例模式(Singleton Pattern):確保某個類只有一個實例,而且自行實例化並向整個系統提供這個實例,這個類也稱爲單例類,它提供全局訪問的方法。單例模式是一種對象創建型模式。

2.單例模式的3個要點

  1. 某個類只能有一個實例
  2. 它必須自行創建這個實例
  3. 它必須自行向整個系統提供這個實例

3.結構圖

從上圖可以看出,單例類模式結構圖中只包含一個單例角色。
Singleton(單例):

  1. 在單例類的內部實現只生成一個實例,同時它提供一個靜態的GetInstance()方法,讓客戶可以訪問它的唯一實例。
  2. 爲了防止在外部對單例實例化,它的構造函數可見性爲private。
  3. 在單例類的內部定義了一個Singleton類型的靜態對象,作爲供外部共享訪問的唯一實例。

三、負載均衡器的設計

1.需求

A科技公司承接了一個服務器負載均衡(Load Balance)軟件的開發工作,該軟件運行在一臺負載均衡服務器上,可以將併發訪問和數據流量分發到服務器集羣中的多臺設備上進行併發處理,提高系統的整體處理能力,縮短響應時間。由於集羣中的服務器需要動態刪減,且客戶端請求需要統一分發,因此需要確保負載均衡器的唯一性,即只能有一個負載均衡器來負責服務器的管理和請求的分發,否則將會帶來服務器狀態的不一致以及請求分配衝突等問題。如何確保負載均衡器的唯一性是該軟件成功的關鍵。

2.結構圖

A科技公司的研發部開發人員通過分析和權衡,決定使用單例模式來設計該負載均衡器。結構如下圖:

3.實現

在上邊的結構圖中,將負載均衡器LoadBalancer設計爲單例類,其中包含一個存儲服務器信息的集合serverList,每次在serverList中隨機選擇一臺服務器來響應客戶端的請求,實現代碼如下:

    /// <summary>
    /// 負載均衡器:單例類,真實環境可能非常複雜,這裏只列出部分與模式相關的代碼
    /// </summary>
    public class LoadBalancer
    {
        //私有靜態成員變量,保存唯一實例
        private static LoadBalancer instance = null;
        //服務器集合
        private List<string> serverList = null;

        /// <summary>
        /// 私有構造函數
        /// </summary>
        private LoadBalancer()
        {
            serverList = new List<string>();
        }

        /// <summary>
        /// 公有靜態成員方法,返回唯一實例
        /// </summary>
        /// <returns></returns>
        public static LoadBalancer GetLoadBalancer()
        {
            if (instance == null)
                instance = new LoadBalancer();
            return instance;
        }

        //增加服務器
        public void AddServer(string server)
        {
            serverList.Add(server);
        }

        //刪除服務器
        public void RemoveServer(string server)
        {
            serverList.Remove(server);
        }

        //使用Random類隨機獲取服務器
        public string GetServer()
        {
            var random = new Random();
            var i = random.Next(serverList.Count);
            return serverList[i];
        }
    }

客戶端測試代碼:

    class Program
    {
        static void Main(string[] args)
        {
            //創建4個LoadBalancer對象
            LoadBalancer balancer1, balancer2, balancer3, balancer4;
            balancer1 = LoadBalancer.GetLoadBalancer();
            balancer2 = LoadBalancer.GetLoadBalancer();
            balancer3 = LoadBalancer.GetLoadBalancer();
            balancer4 = LoadBalancer.GetLoadBalancer();

            //判斷服務器負載均衡器是否相同
            if (balancer1 == balancer2 && balancer2 == balancer3 && balancer3 == balancer4)
            {
                Console.WriteLine("服務器負載均衡器具有唯一性!");
            }

            //增加服務器
            balancer1.AddServer("server 1");
            balancer1.AddServer("server 2");
            balancer1.AddServer("server 3");
            balancer1.AddServer("server 4");

            for (int i = 0; i < 10; i++)
            {
                var server = balancer1.GetServer();
                Console.WriteLine("分發請求至服務器:" + server);
            }
        }
    }

編譯並運行程序,結果如下:

從運行結果可以看出,雖然我們創建了4個LoadBalancer對象,但他們是同一個對象。因此,通過使用單例模式可以確保LoadBalancer對象的唯一性。

四、餓漢式單例和懶漢式單例

研發部的開發人員使用單例模式實現了負載均衡器的設計,但是在實際使用中出現了一個非常嚴重的問題。當負載均衡器在啓動過程中用戶再次啓動負載均衡器時,系統無任何異常,但是當客戶端提交請求時出現請求分發失敗。通過仔細分析發現原來系統中還是存在多個負載均衡器對象,導致分發時目標服務器不一致,從而產生衝突。

現在對負載均衡器的實現代碼進行再次的分析。當第一次調用 GetLoadBalancer() 方法創建並啓動負載均衡器時,instance 對象爲 null,因此係統將執行代碼 instance=new LoadBalancer() ,在此過程中,由於要對 LoadBalancer 進行大量初始化工作,需要一段時間來創建 LoadBalancer 對象。而此時如果再一次調用 GetLoadBalancer() 方法(通常發生在多線程環境中),由於 instance 尚未創建成功,此時仍然爲null,判斷條件“instance==null”仍然爲true,代碼 instance=new LoadBalancer() 將被再次執行,最終導致創建了多個 instance 對象,這違背了單例模式的初衷,也導致系統發生運行錯誤。

如何解決該問題?至少有兩種解決方案,這就是接下來的餓漢式單例類懶漢式單例類

1.餓漢式單例類(Eager Singleton)

餓漢式單例類是實現起來最簡單的單例類。
定義一個靜態變量,並在定義的時候就實例化單例類,這樣在類加載的時候就已經創建了單例對象。

代碼:

    /// <summary>
    /// 餓漢式單例
    /// </summary>
    public class EagerSingleton
    {
        //定義靜態變量並實例化單例類
        private static readonly EagerSingleton instance = new EagerSingleton();

        //私有構造函數
        private EagerSingleton()
        {
        }

        //獲取單例對象
        public static EagerSingleton GetInstance()
        {
            return instance;
        }
    }

如果使用餓漢式單例來實現負載均衡器LoadBalancer的設計,則不會出現創建多個單例對象的情況,可確保單例對象的唯一性。

2.懶漢式單例類與線程鎖定

除了餓漢式單例外,還有一種經典的懶漢式單例,也就是前邊最開始提到的負載均衡器的實現方式。

懶漢式單例在第一次調用GetInstance()方法時實例化,在類加載時並不自行實例化,這種技術又稱爲延遲加載(lazy Load)技術,即需要的時候再加載實例。

爲了避免多個線程同時調用GetInstance()方法,C#中可以使用 Lock 來進行線程鎖定

    /// <summary>
    /// 懶漢式單例類
    /// </summary>
    public class LazySingleton
    {
        //私有靜態成員變量,保存唯一實例
        private static LazySingleton instance = null;
        private static readonly object syncLocker = new object();

        private LazySingleton() {}

        //公有靜態成員方法,返回唯一實例
        public static LazySingleton GetInstance()
        {
            if (instance == null)
            {
                //鎖定代碼塊
                lock (syncLocker)
                {
                    instance = new LazySingleton();
                }
            }

            return instance;
        }
    }

問題似乎得到了解決,但事實並非如初。如果使用上邊的代碼來創建單例對象,仍然會出現單例對象不唯一的問題。原因如下:

假如某一瞬間線程A和線程B都在調用 GetInstance() 方法,此時 instance 對象爲null,均能通過“instance==null”的判斷。由於實現了加鎖機制,線程A進入鎖定的代碼塊中執行實例創建代碼,那麼此時線程B則處於排隊等待狀態,必須等線程A執行完畢後纔可以進入lock代碼塊。但是當線程A執行完畢後,線程B並不知道實例已經創建,所以會繼續進行新實例的創建,那麼將會導致產生多個單例對象,違背了單例模式的設計思想。因此需要進一步改進,需要在鎖定的代碼塊中再進行一次“instance==null”的判斷,判斷進入鎖定代碼塊後是否有其它線程已經創建了單例類就可以了,這種方式稱爲雙重檢查鎖定(Double-Check Locking)。代碼如下:

    /// <summary>
    /// 懶漢式單例類
    /// </summary>
    public class LazySingleton
    {
        //私有靜態成員變量,保存唯一實例
        private static LazySingleton instance = null;
        private static readonly object syncLocker = new object();

        private LazySingleton() {}

        /// <summary>
        /// 公有靜態成員方法,返回唯一實例
        /// </summary>
        /// <returns></returns>
        public static LazySingleton GetInstance()
        {
            //第一重判讀
            if (instance == null)
            {
                //鎖定代碼快
                lock (syncLocker)
                {
                    //第二重判斷
                    if (instance == null)
                        instance = new LazySingleton();
                }
            }

            return instance;
        }
    }

3.餓漢式單例類與懶漢式單例類比較

餓漢式單例類:在類被加載時就將自己實例化。
好處:

  1. 無需考慮多線程的訪問問題,可以確保實例的唯一性。
  2. 由於單例對象一開始就被創建好了,所以在調用速度上和反應時間上無需等待,這點要優於懶漢式。

缺點:

  1. 無論系統在運行時是否需要使用該單例對象,但是它一開始就被創建好了,如果該單例對象只是在某個地方纔用到,那麼一開始就創建單例對象將會造成資源浪費。
  2. 如果單例類實例化需要的時間比較長,程序運行的時候又用不到,那麼將會增加系統不必要的加載時間。

懶漢式單例類:在類第一次使用時創建。
好處:

  1. 無需一直佔用系統資源,實現了延遲加載。

缺點:

  1. 多線程同時訪問時,如果單例類的實例化比較耗時,那麼多個線程同時首次引用此類的概率就會變大,那麼每個線程都需要經過雙重檢查鎖定機制,這會給系統帶來性能的影響。

五、一種更好的單例實現方法

餓漢式單例類不能實現延遲加載,不管將來用不用,它始終佔據着內存;懶漢式單例類線程安全控制煩瑣,而且性能受影響。有沒有一種方法能夠同時將這兩種方式的缺點都克服呢?有!那就是靜態內部類單例

需要在單例類中增加一個靜態(static)內部類。在該類內部中創建單例對象,再將該單例對象通過GetInstance()方法返回給外部使用,代碼如下:

    /// <summary>
    /// 靜態內部類單例,線程安全
    /// </summary>
    public class StaticSingleton
    {
        //私有構造函數,防止從外邊實例化
        private StaticSingleton(){}

        //公有靜態成員方法,返回唯一實例
        public static StaticSingleton GetInstance()
        {
            return InnerClass.instance;
        }

        //內部類,第一次調用GetInstance()時加載InnerClass
        class InnerClass
        {
            //在類被實例化或靜態成員被調用的時候進行調用
            //這裏也就是當instance被調用的時候,會執行靜態函數,初始化成員變量
            static InnerClass(){}
            internal static readonly StaticSingleton instance = new StaticSingleton();
        }
    }

instance並沒有作爲StaticSingleton的成員變量直接實例化,所以在類加載的時候不會實例化StaticSingleton。第一次調用GetInstance()方法時,將加載內部類InnerClass,該內部類定義了一個static類型的變量instance,這時首先會初始化這個成員變量,由.NET框架來保證線程安全性,確保該成員變量只能初始化一次。由於GetInstance()並沒有被任何線程鎖定,因此不會造成任何性能影響。

靜態構造函數:

  1. 是由.Net框架來執行的
  2. 沒有參數,因爲框架不知道我們要傳什麼參數
  3. 必須以static標識,並且沒有 public 和 private
  4. 靜態構造函數中不能初始化實例變量
  5. 靜態構造函數的調用時機,是在類被實例化靜態成員被調用的時候進行調用,並且由.NET框架來調用靜態構造函數來初始化靜態成員變量
  6. 一個類中只能有一個靜態構造函數
  7. 無參的靜態構造函數和無參的構造函數可以共同存在
  8. 靜態構造函數只會被執行一次

六、單例模式的總結

單例模式作爲一種目標明確、結構簡單、理解容易的設計模式,在軟件開發中使用頻率非常高,在很多軟件和框架中都得以廣泛的應用。

1.主要優點

  1. 單例模式提供了對唯一實例的受控訪問。因爲單例類封裝了它的唯一實例,所以它可以嚴格控制客戶怎樣以及何時訪問它。
  2. 系統中只存在一個對象,因此可以節約系統資源。對於那些需要頻繁創建和銷燬的對象,單例模式無疑可以提高系統的性能。
  3. 允許可變數目的實例。基於單例模式,開發人員可以進行擴展,使用與控制單例對象相似的方法來獲得指定個數的實例對象,即節省系統資源,又解決了由於單例對象共享過多有損性能的問題。(注:自行提供執行數目實例對象的類可稱之爲多例類)比如:數據庫連接池、線程池等

2.主要缺點

  1. 由於單例模式中沒有抽象層,因此單例類的擴展有很大的困難
  2. 單例類的職責過重,在一定程度上違背了單一職責原則。因爲單例類中即提供了業務方法,又提供了創建對象的方法(工廠方法),將對象的創建和對象本身的功能耦合在一起。
  3. 現在很多面向對象語言(Java、C#)的運行環境都提供了自動垃圾回收技術,因此,如果實例化的共享對象長時間不被利用,系統就會認爲它是垃圾,會自動銷燬並回收資源,等到下次利用時又將重新實例化,這將導致共享的單例對象狀態的丟失。

3.適用場景

  1. 系統只需要一個實例對象。例如,系統需要提供一個唯一的序列號生成器或資源管理器,或者需要考慮資源消耗太大而只允許創建一個對象。
  2. 客戶調用類的單個實例只允許使用一個公共訪問點。除了該公共訪問點,不能通過其它途徑訪問該實例

如果您覺得這篇文章有幫助到你,歡迎推薦,也歡迎關注我的公衆號。

示例代碼:

https://github.com/crazyliuxp/DesignPattern.Simples.CSharp

參考資料:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章