設計模式——單例模式


目錄

shanzm-2020年4月8日 22:37:28

1. 模式簡介

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

實現單例模式的方法:私有化構造函數,添加一個靜態的字段保存類的唯一實例,並提供一個訪問該實例的靜態方法GetInstance()

單例模式分為兩種:“懶漢式單例模式”和“餓漢式單例模式”。

懶漢式單例模式:第一次調用創建對象的方法GetInstance()時創建類的單例對象

餓漢式單例模式:類加載的時候創建單例對象

單例模式UML如下:
shanzm_singleton_UML



2. 實例1-窗口的單例模式

2.1 背景說明

示例源於《大話設計模式》,示例中完整源代碼下載

創建一個WinForm項目,其中有一個FromParent窗口,該窗口作為其他的子窗口的容器

FromParent窗口中有一個菜單欄,其中有一個ToolBox按鈕,點擊該按鈕創建一個FromToolBox子窗口

現在要求FromToolBox子窗口一次只能創建一個。

為了實現一個類只能實例化一個對象,我們可以將構造函數改為私有的,使得該類之外無法實例化該類,而將實例化對象的放在靜態函數GetInstanc()中

完整的Demo代碼下載

2.2 代碼實現

①FromToolBox窗口代碼

 public partial class FormToolBox : Form
{
    private FormToolBox()
    {
        InitializeComponent();
    }
    private static FormToolBox ftb = null;
    public static FormToolBox GetInstance()
    {
        //存儲唯一對象的字段為空,或者窗口對象已經被釋放
        if (ftb == null || ftb.IsDisposed)
        {
            ftb = new FormToolBox();
            ftb.MdiParent = FormParent.ActiveForm;
        }
        return ftb;
    }
}

②FromParent窗口代碼

public partial class FormParent : Form
{
    public FormParent()
    {
        InitializeComponent();
    }

    private void FormParent_Load(object sender, EventArgs e)
    {
        //FormParent作為子窗口的容器
        this.IsMdiContainer = true;
    }

    //點擊菜單-ToolBox按鈕,創建FromToolBox按鈕
    private void toolBoxToolStripMenuItem_Click(object sender, EventArgs e)
    {
        FormToolBox.GetInstance().Show();
    }
}

2.3 程序類圖

只展示一下單例窗口FormToolBox的類圖:

shanzm_singleForm



3. 實例2-讀取配置文件(懶漢式)

3.1 背景說明

這個實例是改編自《研磨設計模式》,示例中完整源代碼下載

在程序中讀取項目的配置文件App.Config

定義一個配置的類AppConfig,在該類中實現讀取App.Config中的所有配置數據

每一項的配置數據在AppConfig類中設置一個只讀屬性,若是系統中需要使用配置,則創建一個AppConfig對象,讀取該對象中的所有關於配置的只讀屬性。

這樣的AppConfig其實就是封裝着所有的配置數據。在系統中可能多處需要使用配置數據,若是每次使用配置數據,我們就創建一個AppConfig對象,則在系統的內存中會有多個AppConfig對象,非常的浪費系統的內存資源,尤其是配置數據較多的時候!

讀取配置文件的實例在系統中使用一個即可,即通過單例模式顯示實例唯一。

3.2 代碼實現

①新建一個控制台項目

添加引用:System.Configuration

在項目的配置文件App.config中添加自定義的配置數據如下:

<configuration>
  <appSettings >
    <add key="server" value ="."/>
    <add key="database" value ="db_Test"/>
    <add key="uid" value ="shanzm"/>
    <add key="pwd" value ="123456"/>
  </appSettings>
</configuration>

注意此處我只是拿數據庫連接字符串舉一個例子,
我們通常會數據庫連接字符串寫在<connectionStrings>標簽中

②新建一個AppConfig類

using System.Configuration;
public class AppConfig
{
    //定義私有字段,用於存儲唯一實例
    private static AppConfig appConfig = null;

    //定義只讀屬性,對應配置中的key
    public string Server { private set; get; }
    public string DataBase { private set; get; }
    public string UserId { private set; get; }
    public string PassWord { private set; get; }

    //構造函數私有化
    private AppConfig()
    {
        Server = ConfigurationManager.AppSettings["server"];
        DataBase = ConfigurationManager.AppSettings["databaser"];
        UserId = ConfigurationManager.AppSettings["uid"];
        PassWord = ConfigurationManager.AppSettings["pwd"];   
    }

    //獲取唯一實例
    public static AppConfig GetInstance()
    {
        if (appConfig == null)
        {
            appConfig = new AppConfig();
        }
        return appConfig;
    }
}

③客戶端調用

static void Main(string[] args)
{
    AppConfig appConfig1 = AppConfig.GetInstance();
    string connectionString = $"server={appConfig1.Server},databas={appConfig1.DataBase},uid ={appConfig1.UserId},pwd ={appConfig1.PassWord}";

    Console.WriteLine(connectionString);//print:server=.,database=db_Test,uid=shanzm,pwd=123456

    AppConfig appConfig2 = AppConfig.GetInstance();
    Console.WriteLine(object.ReferenceEquals(appConfig1, appConfig2));//print:true  
    //系統中所的appConfig對象都是同一個
    Console.ReadKey();
}

3.3 程序類圖

shanzm_singletonAppConfig



4. 重構實例2-對創建實例操作加鎖

其實多線程同時調用AppConfig.GetInstance()方法的時候,會在內存中創建多個實例。

比如說在實例2中,我們使用Parallel.Invoke(),並行調用GetInstance(),會發現有可能在內存中創建多個AppConfig對象。

所以其實也就破壞了單例模式。

static void Main(string[] args)
{
    AppConfig appConfig1 = null;
    AppConfig appConfig2 = null;
    Action createA = () => appConfig1 = AppConfig.GetInstance();
    Action createB = () => appConfig2 = AppConfig.GetInstance();
    Parallel.Invoke(createA, createB);
    Console.WriteLine(object.ReferenceEquals(appConfig1, appConfig2));
    //print:false。即系統中的appConfig對象不是同一個
    //注意這里有時可能是true,即  Parallel.Invoke(createA, createB)中的委托執行可能存在時間差
    //注意你使用異步是無法模擬出false的,因為異步不是同時去執行GetInstanc()
    Console.ReadKey();
}

解決方法:編寫線程安全的代碼,對創建AppConfig對象的操作加鎖!

private static readonly object asyncRoot = new object();
public static AppConfig GetInstance()
{
    if (appConfig == null)
    {
        lock (asyncRoot)
        {
            if (appConfig == null)
            {
                appConfig = new AppConfig();
            }
        }
    }
    return appConfig;
}

【代碼說明】:

  • 這里先判斷單例對象是否存后再對線程加鎖,即不是對線程每次都加鎖,只是單例對象不存在的時候再加鎖,這稱之為雙重鎖定(double check lock)

  • 在加鎖前先判斷單例對象是否已經存在,在加鎖后再次判斷單例對象是否存在,是有必要的。比如當前單例對象尚不存在,兩個線程中有一個通過線程A鎖,又通過對象為空的判斷,開始創建單例對象,而此時線程鎖外有一個線程B等待,如果沒有第二重的對象為空的判斷,線程B可以繼續創建一個新的對象,破壞了單例模式。



5. 重構實例2-餓漢式

那么問題來了,這里為什么要把存儲單例對象的字段定義為只讀的靜態字段呢?

首先回顧一下靜態字段

  1. 區分靜態字段和非靜態字段:

    使用 static 修飾符聲明的字段定義了一個靜態字段 (static field)。一個靜態字段只標識一個存儲位置。無論對一個類創建多少個實例,它的靜態字段永遠都只有一個副本。

    不使用 static 修飾符聲明的字段定義了一個實例字段 (instance field)。類的每個實例都為該類的所有實例字段包含一個單獨副本。

    簡而言之,靜態字段屬於類,非靜態字段屬於類的實例對象。

  2. 靜態字段和非靜態字段初始化:

    對於靜態字段,變量初始值設定項相當於在類初始化期間執行的賦值語句。
    對於實例字段,變量初始值設定項相當於創建類的實例時執行的賦值語句。

Java、C#等強類型語言提供了靜態初始化的方法,通過靜態初始化方法,靜態的字段在內存中只允許有一個賦值,所以就不需要擔心多線程同時調用GetInstance()創建了多個單例類的對象。這樣在這里就不需要程序員顯示的編寫線程安全代碼,即可避免多線程下的單例模式被破壞的問題。

這種靜態初始化的方式是在類自己被加載的時候將自己實例化,被稱之為“餓漢式單例類”,

而之前需要在類在第一次被引用的時候將自己實例化的方式稱之為“懶漢式單例類

對AppConfig類進行修改,實現懶漢式單例模式,如下:

public sealed class AppConfig//類定義為密封類,防止派生類創建對象
{
    //定義字段,用於存儲唯一實例
    private static readonly AppConfig appConfig = new AppConfig();
    //用於存儲實例的字段定義為只讀的,則只能在類靜態初始化給其賦值
    //給 readonly 字段的賦值只能作為字段聲明的組成部分出現,或在同一個類中的構造函數中出現。

    //對應配置文件設置相應的屬性,注意是只讀的
    public string Server { private set; get; }
    public string DataBase { private set; get; }
    public string UserId { private set; get; }
    public string PassWord { private set; get; }
    //構造函數私有化
    private AppConfig()
    {
        Server = ConfigurationManager.AppSettings["server"];
        DataBase = ConfigurationManager.AppSettings["databaser"];
        UserId = ConfigurationManager.AppSettings["uid"];
        PassWord = ConfigurationManager.AppSettings["pwd"];
    }
    //獲取唯一實例
    public static AppConfig GetInstance()
    {
        return appConfig;
    }
}

懶漢式和餓漢式的區別

  • 餓漢式單例模式:

    餓漢式即靜態初始化的方式,它是在類一加載的時候就實例化對象,所以要提前占用系統資源。即餓漢式沒有實現延遲加載,無論是否調用,都會占用系統資源

  • 懶漢式單例模式:

    懶漢式體現了延遲加載(Lazy Load),所謂延遲加載就是當在真正需要數據的時候,才真正執行數據加載操作,這樣可以盡可能的節約內存資源。

    懶漢式線程不安全,需要做雙重鎖定這樣的處理才可以保證安全。



6. 重構實例2-靜態內部類

懶漢式線程不安全,餓漢式沒有實現延遲加載,浪費資源

所以可以繼續對懶漢式和餓漢式進行修改,使用靜態內部類,既可以實現線程安全又可以實現延遲加載。

首先看一下靜態內部類的定義:

  • 類級內部類:在類(這個類稱之為外部類)內部定義一個靜態類,該類內部的靜態類稱之為類級內部類,也稱之為靜態內部類

  • 對象級內部類:在類內部定義一個非靜態類,則該內部類稱之為對象級內部類

在靜態內部類中創建外部類的單例對象,這樣一來既實現了靜態初始化(保證了線程安全),又確保在不使用外部類的時候是不會創建單例對象(實現了延遲加載)

修改AppConfig代碼如下:

仔細想想其實是非常巧妙的

public sealed class AppConfig
{
    //靜態內部類
    private static class AppConfigHolder
    {
        //注意將內部靜態類的默認構造函數改為靜態的
        static AppConfigHolder()
        {
        }
        //使用靜態初始化器,保證了線程安全。
        internal static AppConfig appConfig = new AppConfig();
    }
    //對應配置文件設置相應的屬性,注意是只讀的
    public string Server { private set; get; }
    public string DataBase { private set; get; }
    public string UserId { private set; get; }
    public string PassWord { private set; get; }
    //構造函數私有化
    private AppConfig()
    {
        Server = ConfigurationManager.AppSettings["server"];
        DataBase = ConfigurationManager.AppSettings["databaser"];
        UserId = ConfigurationManager.AppSettings["uid"];
        PassWord = ConfigurationManager.AppSettings["pwd"];
    }
    //獲取唯一實例
    public static AppConfig GetInstance()
    {
        //此時調用靜態內部類中的靜態字段,保證了只有在使用到AppConfig對象的時候才創建對象
        return AppConfigHolder.appConfig;
    }
}


7. 單例模式的擴展-有上限的多例模式

單例模式的本質就是控制實例對象的個數,所以當系統中需要某個類只有一個實例對象的時候,我們可以使用單例模式。

而如果一個類需要有幾個實例化對象共存,則我們可以對單例模式進行擴展,限制一個類產生固定數量的實例化對象。

這種限制類產生固定數量的實例對象的模式就叫做有上限的多例模式,它是單例模式的一種擴展。

采用有上限的多例模式,我們可以在設計時決定在內存中有多少個實例,方便系統進行擴展,修正單例可能存在的性能問題,提供系統的響應速度。

修改AppConfig類,允許AppConfig類最多有三個實例對象,修改如下:

class AppConfigExtend
{
    //定義字典類型字段保存所有的實例
    private static Dictionary<int, AppConfigExtend> dic = new Dictionary<int, AppConfigExtend>();
    //定義key
    private static int key = 1;
    //定義最大的實例數
    private static int MaxInstance = 3;

    //對應配置文件設置相應的屬性
    public string Server { private set; get; }
    public string DataBase { private set; get; }
    public string UserId { private set; get; }
    public string PassWord { private set; get; }
    //私有化構造函數
    private AppConfigExtend()
    {
        Server = ConfigurationManager.AppSettings["server"];
        DataBase = ConfigurationManager.AppSettings["database"];
        UserId = ConfigurationManager.AppSettings["uid"];
        PassWord = ConfigurationManager.AppSettings["pwd"];
    }
    //類外方法訪問實例的訪問點
    public static AppConfigExtend GetInstance()
    {
        if (!dic.ContainsKey(key))
        {
            dic.Add(key, new AppConfigExtend());
        }
        AppConfigExtend appConfig = dic[key];
        key++;
        if (key > MaxInstance)
        {
            key = 1;
        }
        return appConfig;
    }
}

注:這里實現的有上限多例模式並不是線程安全的,只做演示。

客戶端通過HashSet存儲AppConfigExtend實例對象,進行測試:

static void Main(string[] args)
{
    HashSet<AppConfigExtend> hashset = new HashSet<AppConfigExtend>();
    for (int i = 0; i < 10; i++)//多次獲取AppConfigExtend對象
    {
        hashset.Add(AppConfigExtend.GetInstance());
    }
    Console.WriteLine(hashset.Count());//print:3 即全局中AppConfigExtend就只有3個實例對象
    Console.ReadKey();
}


8. 總結分析

8.1 注意事項

  • 單例模式中一般使用GetInstance()方法作為獲取單例對象的方法,這個方法作為單例模式中全局唯一訪問類實例的訪問點。

    該方法必須為靜態,為何?因為該方法就是創建(獲取)對象的方法,若是非靜態的,那么怎么先創建一個對象再調用該方法呢?所以一定要靜態的方法。

  • 單例類中保存唯一對象的字段instance也必須為靜態的。為何?因為GetInstance()方法是靜態的,而該方法中使用了instance字段。(靜態方法屬於類級方法,所有其操作的變量也就必須是類級變量)

8.2 優點

  • 由於單例模式在內存中只有一個實例,減少了內存開支,特別是一個對象需要頻繁地創建、銷毀時,而且創建或銷毀時性能又無法優化,單例模式的優勢就非常明顯。

  • 提供了對唯一實例的受控訪問。因為單例類封裝了它的唯一實例,所以它可以嚴格控制客戶怎樣以及何時訪問它,並為設計及開發團隊提供了共享的概念。

8.3 缺點

  • 單例類的職責過重,在一定程度上違背了“單一職責原則”。因為單例類既充當了工廠角色(即創建對象),同時又充當了產品角色(即實例對象),包含一些業務方法,將產品的創建和產品的本身的功能融合到一起。

  • 濫用單例將帶來一些負面問題,如為了節省資源將數據庫連接池對象設計為單例類,可能會導致共享連接池對象的程序過多而出現連接池溢出;現在很多面向對象語言(如Java、C#)的運行環境都提供了自動垃圾回收的技術,因此,如果實例化的對象長時間不被利用,系統會認為它是垃圾,會自動銷毀並回收資源,下次利用時又將重新實例化,這將導致對象狀態的丟失。

8.4 適應場合

  • 籠統的說:某類要求只能生成一個對象的時候就可以使用單例模式。

  • 當對象需要被共享的場合。由於單例模式只允許創建一個對象,共享該對象可以節省內存,並加快對象訪問速度。如 Web 中的配置對象、數據庫的連接池等。

  • 當某類需要頻繁實例化,而創建的對象又頻繁被銷毀的時候,如多線程的線程池、網絡連接池等



9. 參考及源碼


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM