目录

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

1. 模式简介

单例模式(Singleton Pattern):确保某一个类只有一个实例,而且自行实例化并向整个系统提供这个实例。

实现单例模式的方法:私有化构造函数,添加一个静态的字段保存类的唯一实例,并提供一个访问该实例的静态方法GetInstance()

单例模式分为两种:“懒汉式单例模式”和“饿汉式单例模式”。

懒汉式单例模式:第一次调用创建对象的方法GetInstance()时创建类的单例对象

饿汉式单例模式:类加载的时候创建单例对象

单例模式UML如下:
设计模式——单例模式-LMLPHP



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的类图:

设计模式——单例模式-LMLPHP



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 程序类图

设计模式——单例模式-LMLPHP



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. 参考及源码

04-09 12:15