单例模式在C#中的实现
介绍
单例模式是软件工程中最为人熟知的一种设计模式。本质上讲,单例就是一个类,这个类只允许创建一个自己的实例,并且通常会提供一个对该实例的简单访问。最常见的情况是,单例在实例化时不允许指定任何参数,否则的话,在第二次请求时,如果使用不同的参数,就会出现问题!(如果所有带有相同参数的请求都会访问相同的实例,那么工厂模式会比较合适)本文只涉及不需要参数的情况。通常情况下,对单例创建的要求是延迟(Lazy)创建-即在第一次需要之前不会创建实例。
在C#中,有多种不同的方式来实现单例模式。我会按照由浅入深顺序介绍,从最常见的、非线程安全的版本开始,一直到完全延迟加载、线性安全、简单且高性能的版本。
然而,所有实现方式都有四个功能特点:
- 一个单一的私有无参构造函数,以防止其他类对其进行实例化(违反了单例模式)。请注意,这还能防止子类继承-如果一个单例能够被子类化一次,那么肯定可以子类化两次,那么每个子类都可以创建一个实例,显然违反了单例模式。如果你需要一个基类的单例,在运行前并不知道确切的类型,那么可以使用工厂模式。
- 单例类是密封类(sealed),严格来说,由于上面一条的限制,这一点完全没必要,但这可能会帮助JIT进行优化。
- 一个静态变量,如果存在的话,他持有单例创建出的实例的引用。
- 一个静态方法,用于获取单例实例的引用,必要时,需要创建实例。个人理解:实例存在则获取,不存在则创建
请注意,所有实现方式都要使用静态公共属性Instance作为访问实例的途径。在任何情况下,该属性都应该可以很容易的转换为一个方法,而不会对线程安全或性能造成影响。
版本一 - 非线程安全
1 | //错误的代码!不用使用! |
之前已经提过,上面的版本是非线程安全的。两个不同的线程有可能对 if (instance == null) 都判断为true,然后都创建了实例,这就违反了单例模式。请注意,其实在判断if表达式之前,实例可能就已经被创建了,但内存无法保证新创建的实例能被其他线程看到,除非有一道合适的内存壁垒(memory barrier)来保证这一点。个人理解:所谓合适的内存壁垒应该是指线程锁-lock
版本二 - 简单的线程安全
1 | public sealed class Singleton |
这种实现方式是线程安全的。线程会取出共享对象的锁(lock),然后再去在创建实例前检查实例是否已经创建。这样就解决了内存壁垒(memory barrier)的问题(因为锁定操作确保了所有的读取操作在逻辑上都发生在获取锁之后,而解锁操作确保了所有的写入操作在逻辑上都发生在释放锁之前),并确保只有一个线程会创建一个实例(因为一次只能有一个线程进入这部分代码–当第二个线程进入的时候,第一个线程已经创建了实例,对if表达式的判断为false)。不幸的是,由于每次请求实例时都会有一个获取锁的过程,导致性能会受到影响。
请注意,我没有像某些版本的实现那样,锁定typeof(Singleton),而是锁定了一个私有的静态变量。锁定一个其他类可以访问的对象(比如Type),有可能会出现性能问题,甚至是死锁(deadlocks)。我一般会偏爱这种风格–在可能的情况下,创建专门用于锁定的对象,只锁定该对象,或者为了特定的目的而锁定文件(document)(例如:用于等待/pulsing一个队列)。通常这样的对象应该是它所在类的私有对象。这样更易于编写线程安全的应用。
版本三 - 尝试使用双重锁保证线性安全
1 | //错误的代码!不用使用! |
这种模式试图在做到线程安全的同时不用每次都获取锁(lock)。不幸的是,这种模式有四个缺点:
- 这在Java中不起作用。这或许很奇怪,但是如果你在Java中也需要用到单例的话,还是有必要了解一下,毕竟C#程序员也有可能是Java程序员。Java内存模型并不能保证新对象的引用在被分配给实例之前,构造函数就已经完成了。Java内存模型在1.5版本进行了重构,但是在这之后,如果没有易失性变量(volatile variable),双重检查锁还是会被破坏(和C#一样)。
- 在没有任何内存壁垒(线程锁)的情况下,它也打破了ECMA CLI规范。在 .NET 2.0的内存模型下(比ECMA规范更强) 有可能是安全的,但我宁愿依赖那些更强的语义,尤其是对安全性存在怀疑的情况下。让实例变量变得不稳定(volatile)可以使其正常运转,显式的内存壁垒调用也可以正常运行,尽管在后一种情况下,即使专家也无法统一到底需要什么样的壁垒。我倾向于避免出现专家都在争论对错的情况!
- 很容易出错。这种模式和上一种模式差不多–出现任何重大变化都有可能影响其性能或正确性。
- 它的性能还是不如之后的实现。
版本四 - 不是典型的懒惰模式但线程安全没有用锁
1 | public sealed class Singleton |
如你所见,这真的是极其简单–但为什么它是线性安全的?又是如何做到延迟处理(lazy)的呢?在C#中静态构造函数被指定为只有在类的实例被创建或静态成员被引用时才执行,并且每个AppDomain只执行一次。考虑到对类型的新构造无论如何都会执行,这将比之前的添加额外检查的例子执行的更快。不过,还是存在一些小缺点:
- 它不像其他实现那么地延迟(lazy)。特别是,如果存在
Instance以外的静态成员,那么对这些成员的第一次引用将会导致创建实例。 - 会有一种复杂的情况:一个静态构造函数调用了另一个静态构造函数,另一个又调用了第一个(个人理解:两个静态构造函数相互调用)。可以在.Net规范中(9.5.3节的partition Ⅱ)查看更多关于类型初始化器的细节–别担心,不会太难,但需要注意在循环中静态构造函数互相引用的后果。
- 只有当类型被特殊标记为
beforefieldinit时,.Net才会保证类型初始器的延迟性(laziness)。不幸的是,C#编译器(至少在 .Net 1.1的运行时)会将所有没有静态构造函数的类型(即被标记为静态的构造函数)标记为beforefieldinit。我这里有一篇文章(原文 译文),详细介绍了这个问题。另外请注意,这会对性能造成影响,这一点会在文末进行讨论。
对于这个实现方式(也只有这个方式),可以采取一种快捷方式,就是直接把instance作为只读的公共静态变量,然后抛弃Instance属性。这样能使代码的基本骨架更小巧。但大多数人还是希望有一个属性,以便于后续添加进一步的操作,并且JIT内联很可能使性能相同。(注意,如果需要延迟性,静态构造函数还是需要的)
版本五 - 完全延迟加载实现
1 | public sealed class Singleton |
这种方式实例化是由嵌套类的静态成员首次引用触发的,只会发生在Instance调用中。这意味着实现了完全的延迟性,却保留了之前所有的性能优势。需要注意的是,虽然嵌套类可以访问包围类的私有成员,但反之则不行,因此需要将instance声明为internal。不过这不会引发其他问题,因为嵌套类本身是私有的。不过,为了让实例化延迟,代码会比较复杂。
版本六 - 使用.NET4的Lazy类型
如果你使用的是 .Net 4(或更高版本),你可以使用 System.Lazy
1 | public sealed class Singleton |
这很简单,性能也不错。如果需要的话,你可以使用IsValueCreated属性检查实例是否被创建。
上面的代码隐式使用了LazyThreadSafetyMode.ExecutionAndPublication作为Lazy<Singleton>的线程安全模式。你也可以根据自己的需求尝试其他模式。如果你的单例在一个相对频繁的循环中被引用,那么这样会是性能产生(相对)显著的差异。你需要决定是否需要完全延迟处理实例化,在类中的适当位置雅瑶记录这个决定。
性能与延迟(Laziness)
大多是情况下,实际上你并不需要完全的延迟处理–除非你在类初始化时做了一些特别耗时的操作,或者在别处做了一些有副作用的操作,否则不使用上文提到的显式静态构造函数也是可以的。这样可以提高性能,因为它允许JIT编译器进行一次简单的检查(例如在方法开始时),以确保类型已经被初始化,然后才开始使用它。
这一页之所以存在,很大程度上是大家自作聪明,想要想出一种双重锁检查的算法。有一个常见的误区,认为锁定(locking)是代价很大的操作。我有写过一个性能测试,它尝试用十亿种方式使用不同的变量在循环中获取单例。这样不是很科学,因为在现实中,你可能想知道,调用单例的方法在每次迭代中到底执行的有多快。不过,这确实说明了一个很重要的问题。在我的笔记本电脑上,最慢的解决方案(慢了有5倍)是使用锁定的那一个(方案2)。这一点重要吗?或许并不,你要知道,即使如此它仍然能在40秒内获取十亿次单例。(注:这篇文章距今已经有些时间了–我希望现在能有很好的表现)。这意味着,如果你每秒“只”获取四十万次单例,那么获取的成本只占性能的1%–所以提升它没太大用。现在,如果你要频繁的获取单例–是不是可以在循环中使用了?如果你那么在乎提高这一点性能,为何不在循环外声明一个局部变量,获取一次单例,再执行循环呢?对啦,这样即使最慢的实现也会变得轻松。
我非常乐意看到,在一个真实的应用中,使用简单锁定和使用更快方案之间,确实产生了显著的性能差异。
异常
有的时候,你需要在单例的构造函数中做一个工作,这有可能会造成异常,但可能对整个应用不会带来致命的影响。有可能,你的应用能够修复这个问题,然后想再尝试一次。在这个阶段使用类型初始化器来构造单例就会出现问题。不同的运行时对这种情况会有不同的处理,但我不知道那种运行时会做所需的事(再次运行类型初始化器),即使有,你的代码在别的运行时也可能被破坏。为了避免这个问题,我建议使用上页的第二种模式–只需要一个简单的锁,每次都检查一下,如果没有成功构建实例,就在方法/属性中构建实例。
感谢Andriy Tereshchenko提出这个问题。
结论
2006年1月7日略有修改;2011年2月12日更新
C#中有很多种实现单例模式的方法。有读者写信给我,详细介绍了他封装同步的方法,我承认这种方法可能在一些特殊情况下很有用(特别是你想要高性能,并且能够明确是否创建的单例,以及不管其他静态成员是够被调用都要延迟处理)。但我个人认为这种情况并不常出现,不值得在文章中继续讨论,但如果你碰到了这种情况,请给我发邮件。(原文作者邮箱)
我个人更倾向于方案4,我只会在下面的情况下抛弃方案4:如果我需要在不触发初始化的情况下调用其他静态方法,或者我需要知道单例是否实例化。我不记得上次遇到这种情况是什么时候了,假如我碰到了,我可能会选择方案2,方案2还是不错的,很容易就能搞定。
方案5很优雅,但比方案2和方案4棘手,正如我上面所说,它的优势没那么有用。方案6是一种比较简单的实现延迟的方法,如果你使用的是 .Net 4,它的优势就是显著的延迟处理。目前,仅仅出于个人习惯,我仍然倾向于使用方案4–但是如果我是和没有开发经验的开发人员一起工作,我可能会选择方案6,因为这是一个简单且普遍使用的模式。
(我不会用方案1,因为它不完善,我也不会用方案3,因为它的优势没有方案5显著)
本文是对Implementing the Singleton Pattern in C#这篇文章的翻译,作者是著有C# in Depth的大神Jon Skeet。非文章原文或本人对某段文字理解,会以斜体 个人理解:xxx 进行标注。本人翻译能力有限,强烈建议大家去看原版!