單例模式屬於建立型模式的一種,建立型模式是一類最常用的設計模式,在軟體開發中應用非常廣泛。建立型模式將物件的建立和使用分離,在使用物件時無需關心物件的建立細節,從而降低系統的耦合度,讓設計方案更易於修改和擴充套件。每一個建立型模式都在檢視回答3個問題:3W -> 建立什麼(What)、由誰建立(Who)和何時建立(When)。
本篇是建立型模式的第一篇,也是最簡單的一個設計模式,雖然簡單,但是其使用頻率確是很高的。
單例模式(Singleton) | 學習難度:★☆☆☆☆ | 使用頻率:★★★★☆ |
一、單例模式的動機
相信大家都使用過Windows工作管理員,我們可以做一個嘗試:在Windows工作列的右鍵選單上多次點選“啟動工作管理員”,看能否開啟多個工作管理員視窗。正常情況下,無論我們啟動多少次,Windows系統始終只能彈出一個工作管理員視窗。也就是說,在一個Windows系統中,工作管理員存在唯一性。
在實際開發中,我們經常也會遇到類似的情況,為了節約系統資源,有時候需要確保系統中某個類只有唯一一個例項,當這個唯一例項建立成功之後,無法再建立一個同型別的其他物件,所有的操作都只能基於這個唯一例項。為了確保物件的唯一性,可以通過建立單例模式來實現,這也就是單例模式的動機所在。
二、單例模式概述
2.1 要點
單例(Singleton)模式:確保某一個類只有一個例項,而且自行例項化並向整個系統提供這個例項,這個類稱為單例類,它提供全域性訪問的方法。單例模式是一種物件建立模式。
單例模式有3個要點:
-
- 某個類只能有一個例項
- 它必須自行建立這個例項
- 它必須自行向整個系統提供這個例項
2.2 結構圖
從上圖中可以看出,單例模式結構圖中只包含了一個單例的角色。
Singleton(單例):
-
- 在單例類的內部實現只生成一個例項,同時它提供一個靜態的GetInstance()方法,讓客戶可以訪問它的唯一例項;
- 為了防止在外部對單例類例項化,它的建構函式被設為private;
- 在單例類的內部定義了一個Singleton型別的靜態物件,作為提供外部共享的唯一例項。
三、負載均衡器的設計
3.1 軟體需求
假設M公司成都分公司的IT開發部門承接了一個伺服器負載均衡器(Load Balance)軟體的開發,該軟體執行在一臺負載均衡伺服器上面,可以將併發訪問和資料流量分發到伺服器叢集中的多臺裝置上進行併發處理,提高系統的整體處理能力,縮短響應時間。由於叢集中的伺服器需要動態增減,且客戶端請求需要統一分發,因此需要確保負載均衡器的唯一性,即只能有一個負載均衡器例項來管理伺服器和分發請求,否則會帶來伺服器狀態的不一致以及請求的分配衝突等問題。
如何確保負載均衡器的唯一性成為了這個軟體成功地關鍵。
3.2 擼起袖子加油幹
成都分公司的開發人員通過分析和權衡,決定使用單例模式來設計這個負載均衡器,於是擼起袖子畫了一個結構圖如下:
在上圖所示的UML圖中,將LoadBalancer類設計為了單例類,其中包含了一個儲存伺服器資訊的集合serverList,每次在serverList中隨機選擇一臺伺服器來響應客戶端的請求,其實現程式碼如下:
/// <summary> /// 假裝自己是一個負載均衡器 /// </summary> public class LoadBalancer { // 私有靜態變數,儲存唯一例項 private static LoadBalancer instance = null; // 伺服器集合 private IList<CustomServer> serverList = null; // 私有建構函式 private LoadBalancer() { serverList = new List<CustomServer>(); } // 公共靜態成員方法,返回唯一例項 public static LoadBalancer GetLoadBalancer() { if (instance == null) { instance = new LoadBalancer(); } return instance; } // 新增一臺Server public void AddServer(CustomServer server) { serverList.Add(server); } // 移除一臺Server public void RemoveServer(string serverName) { foreach (var server in serverList) { if (server.Name.Equals(serverName)) { serverList.Remove(server); break; } } } // 獲得一臺Server - 使用隨機數獲取 private Random rand = new Random(); public CustomServer GetServer() { int index = rand.Next(serverList.Count); return serverList[index]; } } /// <summary> /// 假裝自己是一臺伺服器 /// </summary> public class CustomServer { public string Name { get; set; } public int Size { get; set; } }
現在我們在客戶端程式碼中新增一些測試程式碼,看看結果:
public class Program { public static void Main(string[] args) { LoadBalancer balancer, balancer2, balancer3; balancer = LoadBalancer.GetLoadBalancer(); balancer2 = LoadBalancer.GetLoadBalancer(); balancer3 = LoadBalancer.GetLoadBalancer(); // 判斷負載均衡器是否相同 if (balancer == balancer2 && balancer == balancer3 && balancer2 == balancer3) { Console.WriteLine("^_^ : 伺服器負載均衡器是唯一的!"); } // 增加伺服器 balancer.AddServer(new CustomServer() { Name = "Server 1" }); balancer.AddServer(new CustomServer() { Name = "Server 2" }); balancer.AddServer(new CustomServer() { Name = "Server 3" }); balancer.AddServer(new CustomServer() { Name = "Server 4" }); // 模擬客戶端請求的分發 for (int i = 0; i < 10; i++) { CustomServer server = balancer.GetServer(); Console.WriteLine("該請求已分配至 : " + server.Name); } Console.ReadKey(); } }
執行客戶端程式碼,檢視執行結果:
從執行結果中我們可以看出,雖然我們建立3個LoadBalancer物件,但是它們實際上是同一個物件。因此,通過使用單例模式可以確保LoadBalancer物件的唯一性。
3.3 餓漢式與懶漢式單例
在進行測試時,成都分公司的測試人員發現負載均衡器在啟動過程中使用者再次啟動負載均衡器時,系統無任何異常,但當客戶端提交請求時出現請求分發失敗,通過仔細分析發現原來系統中還是會存在多個負載均衡器的物件,從而導致分發時目標伺服器不一致,從而產生衝突。
開發部人員對實現程式碼進行再一次分析,當第一次呼叫GetLoadBalancer()方法建立並啟動負載均衡器時,instance物件為null,因此係統將會例項化其物件,在此過程中,由於要對LoadBalancer進行大量初始化工作,需要一段時間來建立LoadBalancer物件。而在此時,如果再一次呼叫GetLoadBalancer()方法(通常發生在多執行緒環境中),由於instance尚未建立成功,仍為null值,於是會再次例項化LoadBalancer物件,最終導致建立了多個instance物件,這也就違背了單例模式的初衷,導致系統發生執行錯誤。
So,如何解決這個問題?也就有了下面的餓漢式與懶漢式的解決方案。
(1)餓漢式單例
懶漢式單例實現起來最為簡單,在C#中,我們可以利用靜態建構函式來實現。於是我們可以改寫以上的程式碼塊:
public class LoadBalancer { // 私有靜態變數,儲存唯一例項 private static readonly LoadBalancer instance = new LoadBalancer(); ...... // 公共靜態成員方法,返回唯一例項 public static LoadBalancer GetLoadBalancer() { return instance; } }
C#的語法中有一個函式能夠確保只呼叫一次,那就是靜態建構函式。由於C#是在呼叫靜態建構函式時初始化靜態變數,.NET執行時(CLR)能夠確保只呼叫一次靜態建構函式,這樣我們就能夠保證只初始化一次instance。
餓漢式是在 .NET 中實現 Singleton 的首選方法。但是,由於在C#中呼叫靜態建構函式的時機不是由程式設計師掌控的,而是當.NET執行時發現第一次使用該型別的時候自動呼叫該型別的靜態建構函式(也就是說在用到LoadBalancer時就會被建立,而不是用到LoadBalancer.GetLoadBalancer()時),這樣會過早地建立例項,從而降低記憶體的使用效率。此外,靜態建構函式由 .NET Framework 負責執行初始化,我們對對例項化機制的控制權也相對較少。
(2)懶漢式單例
除了餓漢式之外,還有一種懶漢式。最開始我們實現的方式就是一種懶漢式單例,也就是說,在第一個呼叫LoadBalancer.GetLoadBalancer()時才會例項化物件,這種技術又被稱之為延遲載入(Lazy Load)。同樣,我們的目標還是為了避免多個執行緒同時呼叫GetLoadBalancer方法,在C#中,我們可以使用關鍵字lock/Moniter.Enter+Exit等來實現,這裡採用關鍵字語法糖lock來改寫程式碼段:
public class LoadBalancer { // 私有靜態變數,儲存唯一例項 private static LoadBalancer instance = null; private static readonly object syncLocker = new object(); ...... // 公共靜態成員方法,返回唯一例項 public static LoadBalancer GetLoadBalancer() { if (instance == null) { lock (syncLocker) { instance = new LoadBalancer(); } } return instance; } }
問題貌似得以解決,但事實並非如此。如果使用以上程式碼來建立單例物件,還是會存在單例物件不一致。假設執行緒A先進入lock程式碼塊內,執行例項化程式碼。此時執行緒B排隊吃瓜等待,必須等待執行緒A執行完畢後才能進入lock程式碼塊。但當A執行完畢時,執行緒B並不知道例項已經被建立,將繼續建立新的例項,從而導致多個單例物件。因此,開發人員需要進一步改進,於是就有了雙重檢查鎖定(Double-Check Locking),其改寫程式碼如下:
public class LoadBalancer { // 私有靜態變數,儲存唯一例項 private static LoadBalancer instance = null; private static readonly object syncLocker = new object(); ...... // 公共靜態成員方法,返回唯一例項 public static LoadBalancer GetLoadBalancer() { // 第一重判斷 if (instance == null) { // 鎖定程式碼塊 lock (syncLocker) { // 第二重判斷 if (instance == null) { instance = new LoadBalancer(); } } } return instance; } }
(3)一種更好的單例實現
餓漢式單例不能延遲載入,懶漢式單例安全控制繁瑣,而且效能受影響。靜態內部類單例則將這兩者有點合二為一。使用這種方式,我們需要在單例類中增加一個靜態內部類,在該內部類中建立單例物件,再將該單例物件通過GetInstance()方法返回給外部使用,於是開發人員又改寫了程式碼:
public class LoadBalancer { ...... // 公共靜態成員方法,返回唯一例項 public static LoadBalancer GetLoadBalancer() { return Nested.instance; } // 使用內部類+靜態建構函式實現延遲初始化 class Nested { static Nested() { } internal static readonly LoadBalancer instance = new LoadBalancer(); } ...... }
該實現方法在內部定義了一個私有型別Nested。當第一次用到這個巢狀型別的時候,會呼叫靜態建構函式建立LoadBalancer的例項instance。如果我們不呼叫屬性LoadBalancer.GetLoadBalancer()
,那麼就不會觸發.NET執行時(CLR)呼叫Nested,也就不會建立例項,因此也就保證了按需建立例項(或延遲初始化)。
可見,此方法既可以實現延遲載入,又可以保證執行緒安全,不影響系統效能。但其缺點是與具體程式語言本身的特性相關,有一些物件導向的程式語言並不支援此種方式。
四、單例模式總結
單例模式目標明確,結構簡單,在軟體開發中使用頻率相當高。
4.1 主要優點
(1)提供了對唯一例項的受控訪問。單例類封裝了它的唯一例項,所以它可以嚴格控制客戶怎樣以及何時訪問它。
(2)由於在系統記憶體中只存在一個物件,因此可以節約系統資源,對於一些需要頻繁建立和銷燬的物件,單例模式無疑可以提高系統的效能。
(3)允許可變數目的示例。基於單例模式,開發人員可以進行擴充套件,使用與控制單例物件相似的方法來獲得指定個數的例項物件,既節省系統資源,又解決了單例物件共享過多有損效能的問題。(Note:自行提供指定書目的例項物件的類可稱之為多例類)例如,資料庫連線池,執行緒池,各種池。
4.2 主要缺點
(1)單例模式中沒有抽象層,因此單例類的擴充套件有很大的困難。
(2)單例類的職責過重,在一定程度上違背了單一職責的原則。因為單例類既提供了業務方法,又提供了建立物件的方法(工廠方法),將物件的建立和物件本身的功能耦合在一起。不夠,很多時候我們都需要取得平衡。
(3)很多高階物件導向程式語言如C#和Java等都提供了垃圾回收機制,如果例項化的共享物件長時間不被利用,系統則會認為它是垃圾,於是會自動銷燬並回收資源,下次利用時又得重新例項化,這將導致共享的單例物件狀態的丟失。
4.3 適用場景
(1)系統只需要一個例項物件。例如:系統要求提供一個唯一的序列號生成器或者資源管理器,又或者需要考慮資源消耗太大而只允許建立一個物件。
(2)客戶呼叫類的單個例項只允許使用一個公共訪問點,除了該公共訪問點,不能通過其他途徑訪問該例項。
比如,在Flappy Bird遊戲中,小鳥這個遊戲物件在整個遊戲中應該只存在一個例項,所有對於這個小鳥的操作(向上飛、向下掉等)都應該只會針對唯一的一個例項進行。
參考資料
劉偉,《設計模式的藝術—軟體開發人員內功修煉之道》
何海濤,《劍指Offer—名企面試官精講典型程式設計題》(題目1-實現Singleton模式)