一起学习设计模式--01.单例模式

2022-12-06 18:39:04 浏览数 (2)

单例模式(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中随机选择一台服务器来响应客户端的请求,实现代码如下:

代码语言:javascript复制
    /// <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];
        }
    }

客户端测试代码:

代码语言:javascript复制
    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)

饿汉式单例类是实现起来最简单的单例类。定义一个静态变量,并在定义的时候就实例化单例类,这样在类加载的时候就已经创建了单例对象。

代码:

代码语言:javascript复制
    /// <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 来进行线程锁定

代码语言:javascript复制
    /// <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)。代码如下:

代码语言:javascript复制
    /// <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()方法返回给外部使用,代码如下:

代码语言:javascript复制
    /// <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

参考资料:

0 人点赞