從壹開始 [ Design Pattern ] 之二 ║ 單例模式 與 Singleton
前言
這一篇來源我的公眾號,如果你沒看過,正好直接看看,如果看過了也可以再看看,我稍微修改了一些內容,今天講解的內容如下:
一、什麼是單例模式
【單例模式】,英文名稱:Singleton Pattern,這個模式很簡單,一個型別只需要一個例項,他是屬於建立型別的一種常用的軟體設計模式。通過單例模式的方法建立的類在當前程序中只有一個例項(根據需要,也有可能一個執行緒中屬於單例,如:僅執行緒上下文內使用同一個例項)。
1、單例類只能有一個例項。
2、單例類必須自己建立自己的唯一例項。
3、單例類必須給所有其他物件提供這一例項。
那咱們大概知道了,其實說白了,就是我們整個專案週期內,只會有一個例項,當專案停止的時候,例項銷燬,當重新啟動的時候,我們的例項又會產品。
上文中說到了一個名詞【建立型別】的設計模式,那什麼是建立型別的設計模式呢?
建立型(Creational)模式:負責物件建立,我們使用這個模式,就是為了建立我們需要的物件例項的。
那除了建立型還有其他兩種型別的模式:
結構型(Structural)模式:處理類與物件間的組合
行為型(Behavioral)模式:類與物件互動中的職責分
這兩種設計模式,以後會慢慢說到,這裡先按下不表。
咱們就重點從0開始分析分析如何建立一個單例模式的物件例項。
二、如何建立單例模式
實現單例模式有很多方法:從“懶漢式”到“餓漢式”,最後“雙檢鎖”模式,這裡咱們就慢慢的,從一步一步的開始講解如何建立單例。
1、正常的思考邏輯順序
既然要建立單一的例項,那我們首先需要學會如何去建立一個例項,這個很簡單,相信每個人都會建立例項,就比如說這樣的:
/// <summary> /// 定義一個天氣類 /// </summary> public class WeatherForecast { public WeatherForecast() { Date = DateTime.Now; } public DateTime Date { get; set; } public int TemperatureC { get; set; } public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); public string Summary { get; set; } } [HttpGet] public WeatherForecast Get() { // 例項化一個物件例項 WeatherForecast weather = new WeatherForecast(); return weather; }
我們每次訪問的時候,時間都是會變化,所以我們的例項也是一直在建立,在變化:
相信每個人都能看到這個程式碼是什麼意思,不多說,直接往下走,我們知道,單例模式的核心目的就是:
必須保證這個例項在整個系統的執行週期內是唯一的,這樣可以保證中間不會出現問題。
那好,我們改進改進,不是說要唯一一個麼,好說!我直接返回不就行了:
/// <summary> /// 定義一個天氣類 /// </summary> public class WeatherForecast { // 定義一個靜態變數來儲存類的唯一例項 private static WeatherForecast uniqueInstance; // 定義私有建構函式,使外界不能建立該類例項 private WeatherForecast() { Date = DateTime.Now; } /// <summary> /// 靜態方法,來返回唯一例項 /// 如果存在,則返回 /// </summary> /// <returns></returns> public static WeatherForecast GetInstance() { // 如果類的例項不存在則建立,否則直接返回 // 其實嚴格意義上來說,這個不屬於【單例】 if (uniqueInstance == null) { uniqueInstance = new WeatherForecast(); } return uniqueInstance; } public DateTime Date { get; set; }public int TemperatureC { get; set; } public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); public string Summary { get; set; } }
然後我們修改一下呼叫方法,因為我們的預設建構函式已經私有化了,不允許再建立例項了,所以我們直接這麼呼叫:
[HttpGet] public WeatherForecast Get() { // 例項化一個物件例項 WeatherForecast weather = WeatherForecast.GetInstance(); return weather; }
最後來看看效果:
這個時候,我們可以看到,時間已經不發生變化了,也就是說我們的例項是唯一的了,大功告成!是不是很開心!
但是,彆著急,問題來了,我們目前是單執行緒的,所以只有一個,那如果多執行緒呢,如果多個執行緒同時訪問,會不會也會正常呢?
這裡我們做一個測試,我們在專案啟動的時候,用多執行緒去呼叫:
[HttpGet] public WeatherForecast Get() { // 例項化一個物件例項 //WeatherForecast weather = WeatherForecast.GetInstance(); // 多執行緒去呼叫 for (int i = 0; i < 3; i++) { var th = new Thread( new ParameterizedThreadStart((state) => { WeatherForecast.GetInstance(); }) ); th.Start(i); } return null; }
然後我們看看效果是怎樣的,按照我們的思路,應該是隻會走一遍建構函式,其實不是:
3個執行緒在第一次訪問GetInstance方法時,同時判斷(uniqueInstance ==null)這個條件時都返回真,然後都去建立了例項,這個肯定是不對的。那怎麼辦呢,只要讓GetInstance方法只執行一個執行緒執行就好了,我們可以加一個鎖來控制他,程式碼如下:
public class WeatherForecast { // 定義一個靜態變數來儲存類的唯一例項 private static WeatherForecast uniqueInstance; // 定義一個鎖,防止多執行緒 private static readonly object locker = new object(); // 定義私有建構函式,使外界不能建立該類例項 private WeatherForecast() { Date = DateTime.Now; } /// <summary> /// 靜態方法,來返回唯一例項 /// 如果存在,則返回 /// </summary> /// <returns></returns> public static WeatherForecast GetInstance() { // 當第一個執行緒執行的時候,會對locker物件 "加鎖", // 當其他執行緒執行的時候,會等待 locker 執行完解鎖 lock (locker) { // 如果類的例項不存在則建立,否則直接返回 if (uniqueInstance == null) { uniqueInstance = new WeatherForecast(); } } return uniqueInstance; } public DateTime Date { get; set; } public int TemperatureC { get; set; } public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); public string Summary { get; set; } }
這個時候,我們再併發測試,發現已經都一樣了,這樣就達到了我們想要的效果,但是這樣真的是最完美的麼,其實不是的,因為我們加鎖,只是第一次判斷是否為空,如果建立好了以後,以後就不用去管這個 lock 鎖了,我們只關心的是 uniqueInstance 是否為空,那我們再完善一下:
/// <summary> /// 定義一個天氣類 /// </summary> public class WeatherForecast { // 定義一個靜態變數來儲存類的唯一例項 private static WeatherForecast uniqueInstance; // 定義一個鎖,防止多執行緒 private static readonly object locker = new object(); // 定義私有建構函式,使外界不能建立該類例項 private WeatherForecast() { Date = DateTime.Now; } /// <summary> /// 靜態方法,來返回唯一例項 /// 如果存在,則返回 /// </summary> /// <returns></returns> public static WeatherForecast GetInstance() { // 當第一個執行緒執行的時候,會對locker物件 "加鎖", // 當其他執行緒執行的時候,會等待 locker 執行完解鎖 if (uniqueInstance == null) { lock (locker) { // 如果類的例項不存在則建立,否則直接返回 if (uniqueInstance == null) { uniqueInstance = new WeatherForecast(); } } } return uniqueInstance; } public DateTime Date { get; set; } public int TemperatureC { get; set; } public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); public string Summary { get; set; } }
這樣才最終的完美實現我們的單例模式!搞定。
2、幽靈事件:指令重排
當然,如果你看完了上邊的那四步已經可以出師了,平時我們就是這麼使用的,也是這麼想的,但是真的就是萬無一失麼,有一個 JAVA 的朋友提出了這個問題,C# 中我沒有聽說過,是我孤陋寡聞了麼:
單例模式的幽靈事件,時令重排會偶爾導致單例模式失效。
是不是聽起來感覺很高大上,而不知所云,沒關係,咱們平時用不到,但是可以瞭解瞭解:
為何要指令重排?
指令重排是指的 volatile,現在的CPU一般採用流水線來執行指令。一個指令的執行被分成:取指、譯碼、訪存、執行、寫回、等若干個階段。然後,多條指令可以同時存在於流水線中,同時被執行。
指令流水線並不是序列的,並不會因為一個耗時很長的指令在“執行”階段呆很長時間,而導致後續的指令都卡在“執行”之前的階段上。
相反,流水線是並行的,多個指令可以同時處於同一個階段,只要CPU內部相應的處理部件未被佔滿即可。比如說CPU有一個加法器和一個除法器,那麼一條加法指令和一條除法指令就可能同時處於“執行”階段, 而兩條加法指令在“執行”階段就只能序列工作。
相比於序列+阻塞的方式,流水線像這樣並行的工作,效率是非常高的。
然而,這樣一來,亂序可能就產生了。比如一條加法指令原本出現在一條除法指令的後面,但是由於除法的執行時間很長,在它執行完之前,加法可能先執行完了。再比如兩條訪存指令,可能由於第二條指令命中了cache而導致它先於第一條指令完成。
一般情況下,指令亂序並不是CPU在執行指令之前刻意去調整順序。CPU總是順序的去記憶體裡面取指令,然後將其順序的放入指令流水線。但是指令執行時的各種條件,指令與指令之間的相互影響,可能導致順序放入流水線的指令,最終亂序執行完成。這就是所謂的“順序流入,亂序流出”。
這個是從網上摘錄的,大概意思看看就行,理解雙檢鎖失效原因有兩個重點
1、編譯器的寫操作重排問題.
例 : B b = new B();
上面這一句並不是原子性的操作,一部分是new一個B物件,一部分是將new出來的物件賦值給b.
直覺來說我們可能認為是先構造物件再賦值.但是很遺憾,這個順序並不是固定的.再編譯器的重排作用下,可能會出現先賦值再構造物件的情況.
2、結合上下文,結合使用情景.
理解了1中的寫操作重排以後,我卡住了一下.因為我真不知道這種重排到底會帶來什麼影響.實際上是因為我看程式碼看的不夠仔細,沒有意識到使用場景.雙檢鎖的一種常見使用場景就是在單例模式下初始化一個單例並返回,然後呼叫初始化方法的方法體內使用初始化完成的單例物件.
三、Singleton = 單例 ?
上邊我們說了很多,也介紹了很多單例的原理和步驟,那這裡問題來了,我們在學習依賴注入的時候,用到的 Singleton 的單例注入,是不是和上邊說的一回事兒呢,這裡咱們直接多多執行緒測試一下就行:
/// <summary> /// 定義一個心情類 /// </summary> public class Feeling { public Feeling() { Date = DateTime.Now; } public DateTime Date { get; set; } } // 單例注入 services.AddSingleton<Feeling>(); [HttpGet] public WeatherForecast Get() { // 多執行緒去呼叫 for (int i = 0; i < 3; i++) { var th = new Thread( new ParameterizedThreadStart((state) => { //WeatherForecast.GetInstance(); // 此刻的心情 Feeling feeling = new Feeling(); Console.WriteLine(feeling.Date); }) ); th.Start(i); } return null; }
測試的結果,情理之中,也是意料之外:
竟然和我們上邊說的是一樣的, Singleton是一種懶漢模式 的單例, 因為結論可以看出,有時候我們使用單例模式,並不是寫一個 Sigleton 就能滿足的。
四、單例模式的優缺點
【優】、單例模式的優點:
(1)、保證唯一性:防止其他物件例項化,保證例項的唯一性;
(2)、全域性性:定義好資料後,可以再整個專案種的任何地方使用當前例項,以及資料;
【劣】、單例模式的缺點:
(1)、記憶體常駐:因為單例的生命週期最長,存在整個開發系統內,如果一直新增資料,或者是常駐的話,會造成一定的記憶體消耗。
以下內容來自百度百科:
優點
一、例項控制 單例模式會阻止其他物件例項化其自己的單例物件的副本,從而確保所有物件都訪問唯一例項。 二、靈活性 因為類控制了例項化過程,所以類可以靈活更改例項化過程。缺點
一、開銷 雖然數量很少,但如果每次物件請求引用時都要檢查是否存在類的例項,將仍然需要一些開銷。可以通過使用靜態初始化解決此問題。 二、可能的開發混淆 使用單例物件(尤其在類庫中定義的物件)時,開發人員必須記住自己不能使用new關鍵字例項化物件。因為可能無法訪問庫原始碼,因此應用程式開發人員可能會意外發現自己無法直接例項化此類。 三、物件生存期 不能解決刪除單個物件的問題。在提供記憶體管理的語言中(例如基於.NET Framework的語言),只有單例類能夠導致例項被取消分配,因為它包含對該例項的私有引用。在某些語言中(如 C++),其他類可以刪除物件例項,但這樣會導致單例類中出現懸浮引用。
五、示例程式碼
https://github.com/anjoy8/DesignPattern/tree/master/SingletonPattern