[TOC]
单例模式是软件工程中最着名的模式之一。从本质上讲,单例是一个只允许创建自身的单个实例的类,并且通常可以简单地访问该实例。最常见的是,单例不允许在创建实例时指定任何参数——否则对实例的第二个请求但具有不同的参数可能会有问题!(如果对于具有相同参数的所有请求都应访问相同的实例,则工厂模式更合适。)本文仅处理不需要参数的情况。通常,单例的要求是它们是懒惰地创建的——即直到第一次需要时才创建实例。
在C#中实现单例模式有各种不同的方法。我将以优雅的相反顺序呈现它们,从最常见的、不是线程安全的版本开始,一直到完全延迟加载的、线程安全的、简单且高性能的版本。
然而,所有这些实现都有四个共同特征:
- 单个构造函数,它是私有且无参数的。这可以防止其他类实例化它(这将违反模式)。请注意,它还可以防止子类化——如果一个单例对象可以被子类化一次,那么它就可以被子类化两次,如果每个子类都可以创建一个实例,则违反了该模式。如果您需要基类型的单个实例,则可以使用工厂模式,但是确切的类型要到运行时才能知道。
- 类是密封的。严格来说,由于上述原因,这是不必要的,但是可以帮助JIT进行更多的优化。
- 一个静态变量,用于保存对单个已创建实例的引用(如果有的话)。
- 公共静态意味着获取对单个已创建实例的引用,必要时创建一个实例。
请注意,所有这些实现还使用公共静态属性Instance
作为访问实例的方法。在所有情况下,可以轻松地将属性转换为方法,而不会影响线程安全性或性能。
// 糟糕的代码!不要使用!
public sealed class Singleton
{
private static Singleton instance = null ;
private Singleton()
{
}
public static Singleton Instance
{
get
{
if (instance == null)
{
instance = new Singleton();
}
return instance;
}
}
}
如前所述,上述内容不是线程安全的。两个不同的线程都可以评估测试if (instance==null)
并发现它为true
,然后两个都创建实例,这违反了单例模式。请注意,实际上,在计算表达式之前可能已经创建了实例,但是内存模型不保证其他线程可以看到实例的新值,除非已经传递了合适的内存屏障(互斥锁)。
public sealed class Singleton
{
private static Singleton instance = null;
private static readonly object padlock = new object();
Singleton()
{
}
public static Singleton Instance
{
get
{
lock (padlock)
{
if (instance == null)
{
instance = new Singleton();
}
return instance;
}
}
}
}
此实现是线程安全的。线程取消对共享对象的锁定,然后在创建实例之前检查是否已创建实例。这会解决内存屏障问题(因为锁定确保在获取锁之后逻辑上发生所有读取,并且解锁确保在锁定释放之前逻辑上发生所有写入)并确保只有一个线程将创建实例(仅限于一次只能有一个线程可以在代码的那一部分中——当第二个线程进入它时,第一个线程将创建实例,因此表达式将计算为false
)。不幸的是,每次请求实例时都会获得锁定,因此性能会受到影响。
请注意,不是typeof(Singleton)
像这个实现的某些版本那样锁定,而是锁定一个私有的静态变量的值。锁定其他类可以访问和锁定的对象(例如类型)会导致性能问题甚至死锁。这是我通常的风格偏好——只要有可能,只锁定专门为锁定目的而创建的对象,或者为了特定目的(例如,等待/触发队列)而锁定的文档。通常这些对象应该是它们所使用的类的私有对象。这有助于使编写线程安全的应用程序变得更加容易。
// 糟糕的代码!不要使用!
public sealed class Singleton
{
private static Singleton instance = null;
private static readonly object padlock = new object();
Singleton()
{
}
public static Singleton Instance
{
get
{
if (instance == null)
{
lock (padlock)
{
if (instance == null)
{
instance = new Singleton();
}
}
}
return instance;
}
}
}
该实现尝试是线程安全的,而不必每次都取出锁。不幸的是,该模式有四个缺点:
-
它在Java中不起作用。这似乎是一个奇怪的事情,但是如果您需要Java中的单例模式,这是值得知道的,C#程序员也可能是Java程序员。Java内存模型无法确保构造函数在将新对象的引用分配给
Instance
之前完成。Java内存模型经历了1.5版本的重新改进,但是在没有volatile变量(如在C#中)的情况下,双重检查锁定仍然会被破坏。 -
在没有任何内存障碍的情况下,ECMA CLI规范也打破了这一限制。有可能在.NET 2.0内存模型(比ECMA规范更强)下它是安全的,但我宁愿不依赖那些更强大的语义,特别是如果对安全性有任何疑问的话。使
instance
变量volatile变得有效,就像明确的内存屏障调用一样,尽管在后一种情况下,甚至专家也无法准确地就需要哪些屏障达成一致。我尽量避免专家对对错意见也不一致的情况! -
这很容易出错。该模式需要完全如上所述——任何重大变化都可能影响性能或正确性。
-
它的性能仍然不如后续的实现。
public sealed class Singleton
{
private static readonly Singleton instance = new Singleton();
// 显式静态构造函数告诉C#编译器
// 不要将类型标记为BeforeFieldInit
static Singleton()
{
}
private Singleton()
{
}
public static Singleton Instance
{
get
{
return instance;
}
}
}
正如您所看到的,这实际上非常简单——但是为什么它是线程安全的,它有多懒惰?C#中的静态构造函数仅在创建类的实例或引用静态成员时执行,并且每个AppDomain只执行一次。考虑到无论发生什么情况,都需要执行对新构造的类型的检查,这比在前面的示例中添加额外检查要快。然而,还有一些小缺陷:
- 它并不像其他实现那样懒惰。特别是,如果您有
Instance
之外的静态成员,那么对这些成员的第一次引用将涉及到创建实例。这将在下一个实现中得到纠正。 - 如果一个静态构造函数调用另一个静态构造函数,而另一个静态构造函数再次调用第一个构造函数,则会出现复杂情况。查看.NET规范(目前是分区II的第9.5.3节),了解有关类型初始化器的确切性质的更多详细信息——它们不太可能会影响您,但是有必要了解静态构造函数在循环中相互引用的后果。
- 类型初始化器的懒惰性只有在.NET没有使用名为
BeforeFieldInit
的特殊标志标记类型时才能得到保证。不幸的是,C#编译器(至少在.NET 1.1运行时中提供)将所有没有静态构造函数的类型(即看起来像构造函数但被标记为静态的块)标记为BeforeFieldInit
。我现在有一篇文章,详细介绍了这个问题。另请注意,它会影响性能,如在页面底部所述的那样。
您可以使用此实现(并且只有这一个)的一个快捷方式是将 Instance
作为一个公共静态只读变量,并完全删除该属性。这使得基本的框架代码非常小!然而,许多人更愿意拥有一个属性,以防将来需要采取进一步行动,而JIT内联可能会使性能相同。(注意,如果您需要懒惰的,静态构造函数本身仍然是必需的。)
public sealed class Singleton
{
private Singleton()
{
}
public static Singleton Instance { get { return Nested.instance; } }
private class Nested
{
// 显式静态构造告诉C#编译器
// 未标记类型BeforeFieldInit
static Nested()
{
}
internal static readonly Singleton instance = new Singleton();
}
}
在这里,实例化是由对嵌套类的静态成员的第一次引用触发的,该引用只发生在Instance
中。这意味着实现是完全懒惰的,但是具有前面实现的所有性能优势。请注意,尽管嵌套类可以访问封闭类的私有成员,但反之则不然,因此需要instance
在此处为内部成员。不过,这不会引起任何其他问题,因为类本身是私有的。但是,为了使实例化变得懒惰,代码要稍微复杂一些。
如果您使用的是.NET 4(或更高版本),则可以使用 System.Lazy 类型使惰性变得非常简单。您需要做的就是将委托传递给调用Singleton构造函数的构造函数——使用lambda表达式最容易做到这一点。
public sealed class Singleton
{
private static readonly Lazy<Singleton> lazy =
new Lazy<Singleton>(() => new Singleton());
public static Singleton Instance { get { return lazy.Value; } }
private Singleton()
{
}
}
它很简单,而且性能很好。它还允许您检查是否已使用IsValueCreated 属性创建实例(如果需要的话)。
上面的代码隐式地将LazyThreadSafetyMode.ExecutionAndPublication
用作Lazy<Singleton>
的线程安全模式。根据您的要求,您可能希望尝试其他模式。
在许多情况下,您实际上并不需要完全懒惰——除非您的类初始化做了一些特别耗时的事情,或者在其他地方产生了一些副作用,否则最好忽略上面所示的显式静态构造函数。这可以提高性能,因为它允许JIT编译器进行一次检查(例如在方法的开头)以确保类型已经初始化,然后从那时开始设定它。如果在相对紧密的循环中引用单例实例,则会产生(相对)显著的性能差异。您应该决定是否需要完全延迟实例化,并在类中适当地记录此决策。
这个页面存在的很多原因是人们试图变得聪明,因此提出了双重检查锁定算法。我们常常认为锁定是昂贵的,这被误导的。我写了一个非常快速的基准测试,在一个循环中获取10亿次单例实例,并尝试不同的变体。这并不是很科学,因为在现实生活中,您可能想知道如果每次迭代都涉及到对获取单例的方法的调用,那么速度有多快。然而这确实显示了一个重要的观点。在我的笔记本电脑上,最慢的解决方案(大约5倍)是锁定解决方案(解决方案2)。这很重要吗?可能不会,当您记住它仍然能够在40秒内获取10亿次Singleton
。(注意:这篇文章最初是在很久以前写的——现在我希望有更好的性能。)这意味着,如果你是“仅仅”每秒获得40万次单例实例,那么花费的成本将是1%的性能——所以不会做很多事情去改进它。现在,如果你经常 获得单例实例——你是否可能在循环中使用它?如果您非常关心如何提高性能,为什么不在循环外声明一个局部变量,先获取一次Singleton
,然后再循环呢。Bingo,即使是最慢的实现性能也足够了。
我非常有兴趣看到一个真实的应用程序,在这个应用程序中,使用简单锁定和使用一种更快的解决方案之间的差异实际上会带来显著的性能差异。
有时,您需要在单例构造函数中执行一些操作,这可能会抛出异常,但可能不会对整个应用程序造成致命影响。您的应用程序可能能够解决此问题,并希望再次尝试。在这个阶段,使用类型初始化器来构造单例会出现问题。不同的运行时处理这种情况的方式不同,但我不知道有哪些运行时执行了所需的操作(再次运行类型初始化程序),即使有一个运行时这样做,您的代码也会在其他运行时被破坏。为了避免这些问题,我建议使用文章里列出的第二种模式 ——只需使用一个简单的锁,并每次都进行检查,如果尚未成功构建实例,则在方法/属性中构建实例。
在C#中实现单例模式有各种不同的方法。读者已经写信给我详细说明了他已经封装了同步方面的方法,虽然我承认这可能在一些非常特殊的情况下有用(特别是在你想要非常高性能的地方,以及确定单例是否已经创建,并完全懒惰,而不考虑其他静态成员被调用)。我个人并不认为这种情况会经常出现,值得在这篇文章中进一步改进,但如果你处于这种情况,请发邮件给我。
我的个人的偏好是解决方案4:我通常唯一一次不采用它是因为我需要能够在不触发初始化的情况下调用其他静态方法,或者如果我需要知道单例是否已经被实例化。我不记得上次我处于那种情况是什么时候了,假设我有过,在那种情况下,我可能会选择解决方案2,这仍然是很好的,很容易正确实现。
解决方案5很优雅,但是比2或4更复杂,正如我上面所说,它提供的好处似乎只是很少有用。解决方案6是一种更简单的方法来实现懒惰,如果你使用.NET 4.它还有一个优势,它显然是懒惰的。我目前仍然倾向于使用解决方案4,这仅仅是出于习惯——但如果我与没有经验的开发人员合作,我很可能会选择解决方案6作为一种简单且普遍适用的模式。
(我不会使用解决方案1,因为它是有缺陷的,我也不会使用解决方案3,因为它的好处没有超过5。)
原文作者:Jon Skeet