单例模式是一种很常用的设计模式,下面介绍了单例模式的几种应用场景,然后以C#为例给出标准且可靠的实现方式,最后通过Q&A的方式解释一些疑问,便于理解单例模式的实现细节和背后深层次的原理。

应用场景

(以下内容来自ChatGPT,总结的不错。。。)单例模式的应用场景包括以下几个方面:

  1. 系统中某个类只需要存在一个实例,例如线程池、缓存、日志等。
  2. 当一个对象需要被多个对象共享访问时,例如数据库连接池。
  3. 需要严格控制某个类的实例个数,避免资源浪费,例如操作系统中的任务管理器。

实现示例

下面是网上流行的两种标准实现,在其它语言(如java、C++)里也大致相同:

  • 饿汉式

    public abstract class SingletonBase<T> where T : class, new()
    {
    private static volatile T instance = new T();

    protected SingletonBase() { }

    public static T Instance
    {
    get
    {
    return instance;
    }
    }
    }
  • 懒汉式(双重检查锁)

    public class SingletonBase<T> where T : class, new()
    {
    private static volatile T instance;
    private static readonly object lockObject = new object();

    protected SingletonBase() { }

    public static T Instance
    {
    get
    {
    if (instance == null)
    {
    lock (lockObject)
    {
    if (instance == null)
    {
    instance = new T();
    }
    }
    }
    return instance;
    }
    }
    }

Q&A

接下来我们以上述C#的实现为例,用Q&A的方式探讨一些细节、原理和缺陷:

  • 懒汉式实现中加锁的作用是什么?为什么饿汉式实现不需要加锁?
    懒汉式加锁是为了保证访问实例的原子性,避免在多线程环境下创建多个实例;饿汉式不需要加锁是因为在类的静态构造函数中构造实例,CLR运行时会保证其线程安全。
  • instance使用volatile关键字修饰的作用是什么?
    instance作为跨线程共享的变量需要使用volatile关键字修饰来确保它的可见性,因为即使在加锁的情况下,线程A对instance的写入可能对线程B不可见。volatile关键字是一种针对编译器(包括C#的JIT编译器)的提示,和硬件无关。在变量被volatile修饰后,对于该变量的访问,编译器只会生成直接读写内存的CPU指令,永远不会将该变量优化为从寄存器访问。否则可能会出现这种情况:懒汉式实现中线程A对instance变量的写入被优化成先写入寄存器,然后在函数返回时才回写到内存,如果恰好在写入内存之前线程A被操作系统挂起,此时线程B调用Instance时会通过instance的判空并再次创建instance实例,最终导致创建多个实例。尽管在有的场景中,这种优化并不一定会导致逻辑错误,但是原则上只要是跨线程共享的变量都要避免这种优化。为了实现线程安全,原子性和可见性缺一不可。

    注意:volatile的语义是”易变的”,并不包括原子、同步等语义。

  • 懒汉式实现中为什么要在lock之后再次判空?
    在单线程逻辑中是不需要第二次判空的,但是在多线程环境中,线程A的lock可能会导致其阻塞,如果线程B在线程A判空过后、lock之前获取了lock并创建了instance,线程A不进行第二次判空的话会导致再次创建实例。
  • 为什么SingletonBase的构造函数声明为protected?
    因为SingletonBase的目的是被其他需要实现单例模式的类继承,从设计角度上来说不允许直接单独创建SingletonBase这个基类的实例。所以我们将构造函数设置为protected后将只允许它的派生类调用它的构造函数,限制其他类直接创建SingletonBase<T>的实例。
  • 是否继承自SingletonBase的类型就可以确保只创建一个实例?有哪些情况会导致非预期结果出现?
    不是,上述实现的SingletonBase只是作为一个基类给出了一个减少重复代码的抽象实现,真正实现完全的只存在单个实例还需要依赖使用方的”自觉”(但是我们知道约定和规范是不可靠的),所以上述实现多少有些”一厢情愿”,使用如下方式仍然可以创建多个实例:
    1. 如果外部使用new关键字直接创建子类的实例,则可以绕过单例模式创建多个实例。
    2. 在懒汉式实现中,当子类的构造函数内部有条件的递归调用Instance时,也可能会创建多个实例。因为无条件递归调用Instance会造成死循环,最终导致栈溢出;当有条件递归调用时,在栈溢出之前一定会有实例创建成功,然后收束完成前面所有的实例创建。我们无法保证子类的构造函数中不会出现这种逻辑。

      有时候创建了多个单例并不意味着程序一定会产生错误,是否产生非预期结果取决于这个单例类型的构造函数是否会产生副作用,但是如果永远不会创建多个单例,则一定不会产生非预期结果。单例模式的实现一部分去依赖子类实现方和调用方去遵守约定终究是不可靠的。

鉴于上面Q&A中提到的一些陷阱和不足之处,实际上我们实现单例模式要还要表现出以下行为:

  • 在外部使用new关键字创建子类单例时,SingletonBase将会抛出异常。
  • 子类的构造函数内部递归调用Instance时,SingletonBase将会抛出异常。
  • 子类构造函数抛出异常时,SingletonBase将会将会抛出异常。

改进实现

下面给出改进后的实现(已上传github仓库https://github.com/handsomesnail/Singleton):

  • 饿汉式

    public abstract class SingletonBase<T> where T : class, new()
    {
    private static volatile T instance = new T();

    protected SingletonBase()
    {
    if(instance != null)
    {
    throw new InvalidOperationException();
    }
    instance = this as T;
    }

    public static T Instance
    {
    get
    {
    return instance;
    }
    }
    }
  • 懒汉式(双重检查锁)

    public abstract class SingletonBase<T> where T : class, new()
    {
    private static volatile T instance;
    private static volatile bool allowInstantiated = false;
    private static readonly object lockObject = new object();

    protected SingletonBase()
    {
    if (!allowInstantiated || instance != null)
    {
    throw new InvalidOperationException();
    }
    instance = this as T;
    }

    public static T Instance
    {
    get
    {
    if (allowInstantiated)
    {
    throw new InvalidOperationException();
    }
    if (instance == null)
    {
    lock (lockObject)
    {
    if (instance == null)
    {
    allowInstantiated = true;
    try
    {
    instance = new T();
    }
    catch
    {
    allowInstantiated = false;
    throw;
    }
    finally
    {
    allowInstantiated = false;
    }
    }
    }
    }
    return instance;
    }
    }
    }

总结

单例模式虽然只是一种很简单的设计模式,但是要正确且可靠的实现还是需要涉及很多方面的知识,不仅不同的语言实现起来有差异,甚至针对相同语言不同的编译器、不同的运行时都可能导致不同的行为,需要对语言的标准与规范、运行时的行为有准确且清晰的认识,稍有不慎都会写出有缺陷的代码。