设计模式的征途—1.单例(Singleton)模式
单例模式属于创建型模式的一种,创建型模式是一类最常用的设计模式,在软件开发中应用非常广泛。创建型模式将对象的创建和使用分离,在使用对象时无需关心对象的创建细节,从而降低系统的耦合度,让设计方案更易于修改和扩展。每一个创建型模式都在视图回答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模式)