Spiga

模式开发之旅(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 本身是对象级别线程安全的,也就避免了多线程情况下写日志会互相覆盖的问题。

表示全局唯一类

从业务概念上,如果有些数据在系统中只应保存一份,那就比较适合设计为单例类。比如,配置信息类。在系统中,我们只有一个配置文件,当配置文件被加载到内存之后,以对象的形式存在,也理所应当只有一份。

单例存在哪些问题

  1. 单例对 OOP 特性的支持不友好
    OOP 的四大特性是封装、抽象、继承、多态。单例这种设计模式对于其中的抽象、继承、多态都支持得不好。Singleton的使用违背了基于接口而非实现的设计原则,也就违背了广义上理解的 OOP 的抽象特性。除此之外,单例对继承、多态特性的支持也不友好。这里我之所以会用“不友好”这个词,而非“完全不支持”,是因为从理论上来讲,单例类也可以被继承、也可以实现多态,只是实现起来会非常奇怪,会导致代码的可读性变差。
  2. 单例会隐藏类之间的依赖关系
    代码的可读性非常重要。在阅读代码的时候,我们希望一眼就能看出类与类之间的依赖关系,搞清楚这个类依赖了哪些外部类。
    通过构造函数、参数传递等方式声明的类之间的依赖关系,我们通过查看函数的定义,就能很容易识别出来。但是,单例类不需要显示创建、不需要依赖参数传递,在函数中直接调用就可以了。如果代码比较复杂,这种调用关系就会非常隐蔽。在阅读代码的时候,我们就需要仔细查看每个函数的代码实现,才能知道这个类到底依赖了哪些单例类。
  3. 单例对代码的扩展性不友好
    单例类只能有一个对象实例。如果未来某一天,我们需要在代码中创建两个实例或多个实例,那就要对代码有比较大的改动。你可能会说,会有这样的需求吗?既然单例类大部分情况下都用来表示全局类,怎么会需要两个或者多个实例呢?
    实际上,这样的需求并不少见。我们拿数据库连接池来举例解释一下。
    在系统设计初期,我们觉得系统中只应该有一个数据库连接池,这样能方便我们控制对数据库连接资源的消耗。所以,我们把数据库连接池类设计成了单例类。但之后我们发现,系统中有些 SQL 语句运行得非常慢。这些 SQL 语句在执行的时候,长时间占用数据库连接资源,导致其他 SQL 请求无法响应。为了解决这个问题,我们希望将慢 SQL 与其他 SQL 隔离开来执行。为了实现这样的目的,我们可以在系统中创建两个数据库连接池,慢 SQL 独享一个数据库连接池,其他 SQL 独享另外一个数据库连接池,这样就能避免慢 SQL 影响到其他 SQL 的执行。
    如果我们将数据库连接池设计成单例类,显然就无法适应这样的需求变更,也就是说,单例类在某些情况下会影响代码的扩展性、灵活性。所以,数据库连接池、线程池这类的资源池,最好还是不要设计成单例类。实际上,一些开源的数据库连接池、线程池也确实没有设计成单例类。
  4. 单例对代码的可测试性不友好
    单例模式的使用会影响到代码的可测试性。如果单例类依赖比较重的外部资源,比如 DB,我们在写单元测试的时候,希望能通过 mock 的方式将它替换掉。而单例类这种硬编码式的使用方式,导致无法实现 mock 替换。
  5. 单例不支持有参数的构造函数
    单例不支持有参数的构造函数,比如我们创建一个连接池的单例对象,我们没法通过参数来指定连接池的大小。

单例模式的替换方案

实际上,类对象的全局唯一性可以通过多种不同的方式来保证。我们既可以通过单例模式来强制保证,也可以通过工厂模式、IOC 容器来保证,还可以通过程序员自己来保证(自己在编写代码的时候自己保证不要创建两个类对象)。这就类似C# 中内存对象的释放由CLR 来负责,而 C++ 中由程序员自己负责,道理是一样的。

有人把单例当作反模式,主张杜绝在项目中使用。我个人觉得这有点极端。模式没有对错,关键看你怎么用。如果单例类并没有后续扩展的需求,并且不依赖外部系统,那设计成单例类就没有太大问题。对于一些全局的类,我们在其他地方 new 的话,还要在类之间传来传去,不如直接做成单例类,使用起来简洁方便。