模式开发之旅(5):单例模式
2010-06-27 18:34:22单例模式:保证一个类仅有一个实例,并提供一个该实例的全局访问点。
如何实现一个单例
要实现一个单例,我们需要关注的点无外乎下面几个:
- 构造函数需要是 private 访问权限的,这样才能避免外部通过 new 创建实例;
- 考虑对象创建时的线程安全问题;
- 考虑是否支持延迟加载;
- 考虑 GetInstance() 性能是否高(是否加锁)。
1. 饿汉式
饿汉式的实现方式比较简单。在类加载的时候,instance 静态实例就已经创建并初始化好了,所以,instance 实例的创建过程是线程安全的。不过,这样的实现方式不支持延迟加载(在真正用到Singleton的时候,再创建实例),从名字中我们也可以看出这一点。具体的代码实现如下所示:
public sealed class Singleton //sealed为了阻止派生,因派生可以会增加实例
{
//在第一次引用类的任何成员时创建实例。公共语言运行库负责处理变量初始化
private static readonly Singleton instance = new Singleton();
private Singleton() { }
public static Singleton GetInstance()
{
return instance;
}
}
有人觉得这种实现方式不好,因为不支持延迟加载,如果实例占用资源多(比如占用内存多)或初始化耗时长(比如需要加载各种配置文件),提前初始化实例是一种浪费资源的行为。最好的方法应该在用到的时候再去初始化。
不过我认为如果初始化耗时长,那我们最好不要等到真正要用它的时候,才去执行这个耗时长的初始化过程,这会影响到系统的性能(比如,在响应客户端接口请求的时候,做这个初始化操作,会导致此请求的响应时间变长,甚至超时)。采用饿汉式实现方式,将耗时的初始化操作,提前到程序启动的时候完成,这样就能避免在程序运行的时候,再去初始化导致的性能问题。
2. 懒汉式
有饿汉式,对应的就有懒汉式。懒汉式相对于饿汉式的优势是支持延迟加载。具体的代码实现如下所示:
public class Singleton
{
private static volatile Singleton instance;
private static readonly object syncRoot = new object(); //程序运行时创建一个静态只读的进程辅助对象
private Singleton() { }
public static Singleton GetInstance()
{
//在同一个时刻加了锁的那部分程序只有一个线程可以进入
lock (syncRoot)
{
if (instance == null)
{
instance = new Singleton();
}
}
return instance;
}
}
不过懒汉式的缺点也很明显,我们给 GetInstance() 这个方法加了一把锁导致这个函数的并发度很低。量化一下的话,并发度是 1,也就相当于串行操作了。而这个函数是在单例使用期间,一直会被调用。如果这个单例类偶尔会被用到,那这种实现方式还可以接受。但是,如果频繁地用到,那频繁加锁、释放锁及并发度低等问题,会导致性能瓶颈,这种实现方式就不可取了。
3. 双重检测
饿汉式不支持延迟加载,懒汉式有性能问题,不支持高并发。那我们再来看一种既支持延迟加载、又支持高并发的单例实现方式,也就是双重检测实现方式。
public class Singleton
{
private static volatile Singleton instance;
private static readonly object syncRoot = new object();
private Singleton() { }
public static Singleton GetInstance()
{
//先判断实例是否存在,不存在再加锁处理
if (instance == null)
{
lock (syncRoot)
{
if (instance == null)
{
instance = new Singleton();
}
}
}
return instance;
}
}
现在这样不用让线程每次都加锁,而只是在实例未被创建的时候再加锁处理。同时也能保证多线程的安全。这种做法被称为双重锁定。
4. 静态内部类
其实在实际应用当中,C#与公共语言运行库也提供了一种“静态初始化”方法,这种方法不需要开发人员显示地编写线程安全代码,即可解决多线程环境下它是不安全的问题。
public sealed class Singleton
{
private Singleton() { }
private static class SingletonHolder {
public static readonly Singleton instance = new Singleton();
}
public static Singleton GetInstance()
{
return SingletonHolder.instance;
}
}
SingletonHolder 是一个静态内部类,当外部类 Singleton 被加载的时候,并不会创建 SingletonHolder 实例对象。只有当调用 GetInstance() 方法时,SingletonHolder 才会被加载,这个时候才会创建 instance。instance 的唯一性、创建过程的线程安全性,都由 CLR 来保证。所以,这种实现方法既保证了线程安全,又能做到延迟加载。
单例的应用
处理资源访问冲突
比如我们自定义实现了一个往文件中打印日志的 Logger 类。我们可以将 Logger 设计成一个单例类,程序中只允许创建一个 Logger 对象,所有的线程共享使用的这一个 Logger 对象,共享一个 FileWriter 对象,而 FileWriter 本身是对象级别线程安全的,也就避免了多线程情况下写日志会互相覆盖的问题。
表示全局唯一类
从业务概念上,如果有些数据在系统中只应保存一份,那就比较适合设计为单例类。比如,配置信息类。在系统中,我们只有一个配置文件,当配置文件被加载到内存之后,以对象的形式存在,也理所应当只有一份。
单例存在哪些问题
- 单例对 OOP 特性的支持不友好
OOP 的四大特性是封装、抽象、继承、多态。单例这种设计模式对于其中的抽象、继承、多态都支持得不好。Singleton的使用违背了基于接口而非实现的设计原则,也就违背了广义上理解的 OOP 的抽象特性。除此之外,单例对继承、多态特性的支持也不友好。这里我之所以会用“不友好”这个词,而非“完全不支持”,是因为从理论上来讲,单例类也可以被继承、也可以实现多态,只是实现起来会非常奇怪,会导致代码的可读性变差。 - 单例会隐藏类之间的依赖关系
代码的可读性非常重要。在阅读代码的时候,我们希望一眼就能看出类与类之间的依赖关系,搞清楚这个类依赖了哪些外部类。
通过构造函数、参数传递等方式声明的类之间的依赖关系,我们通过查看函数的定义,就能很容易识别出来。但是,单例类不需要显示创建、不需要依赖参数传递,在函数中直接调用就可以了。如果代码比较复杂,这种调用关系就会非常隐蔽。在阅读代码的时候,我们就需要仔细查看每个函数的代码实现,才能知道这个类到底依赖了哪些单例类。 - 单例对代码的扩展性不友好
单例类只能有一个对象实例。如果未来某一天,我们需要在代码中创建两个实例或多个实例,那就要对代码有比较大的改动。你可能会说,会有这样的需求吗?既然单例类大部分情况下都用来表示全局类,怎么会需要两个或者多个实例呢?
实际上,这样的需求并不少见。我们拿数据库连接池来举例解释一下。
在系统设计初期,我们觉得系统中只应该有一个数据库连接池,这样能方便我们控制对数据库连接资源的消耗。所以,我们把数据库连接池类设计成了单例类。但之后我们发现,系统中有些 SQL 语句运行得非常慢。这些 SQL 语句在执行的时候,长时间占用数据库连接资源,导致其他 SQL 请求无法响应。为了解决这个问题,我们希望将慢 SQL 与其他 SQL 隔离开来执行。为了实现这样的目的,我们可以在系统中创建两个数据库连接池,慢 SQL 独享一个数据库连接池,其他 SQL 独享另外一个数据库连接池,这样就能避免慢 SQL 影响到其他 SQL 的执行。
如果我们将数据库连接池设计成单例类,显然就无法适应这样的需求变更,也就是说,单例类在某些情况下会影响代码的扩展性、灵活性。所以,数据库连接池、线程池这类的资源池,最好还是不要设计成单例类。实际上,一些开源的数据库连接池、线程池也确实没有设计成单例类。 - 单例对代码的可测试性不友好
单例模式的使用会影响到代码的可测试性。如果单例类依赖比较重的外部资源,比如 DB,我们在写单元测试的时候,希望能通过 mock 的方式将它替换掉。而单例类这种硬编码式的使用方式,导致无法实现 mock 替换。 - 单例不支持有参数的构造函数
单例不支持有参数的构造函数,比如我们创建一个连接池的单例对象,我们没法通过参数来指定连接池的大小。
单例模式的替换方案
实际上,类对象的全局唯一性可以通过多种不同的方式来保证。我们既可以通过单例模式来强制保证,也可以通过工厂模式、IOC 容器来保证,还可以通过程序员自己来保证(自己在编写代码的时候自己保证不要创建两个类对象)。这就类似C# 中内存对象的释放由CLR 来负责,而 C++ 中由程序员自己负责,道理是一样的。
有人把单例当作反模式,主张杜绝在项目中使用。我个人觉得这有点极端。模式没有对错,关键看你怎么用。如果单例类并没有后续扩展的需求,并且不依赖外部系统,那设计成单例类就没有太大问题。对于一些全局的类,我们在其他地方 new 的话,还要在类之间传来传去,不如直接做成单例类,使用起来简洁方便。