如何正确的使用单例模式
在最近的一个项目里面发现好多同事喜欢这样运用单例模式,样例代码如下
public class Demo { public static Demo Instance { get { return new Demo(); } } public string GetUserId() { return "001"; } public string GetUserName() { return "tauruswu"; } }
在调用这个类的时候,是这样操作的
var id = Demo.Instance.GetUserId(); var name = Demo.Instance.GetUserName();
粗略一看,可能觉得没有问题,最开始我也是这样,看别人都这么写,我也就这么写,其实这个时候你的直觉已经明显的欺骗你了,各位看官再仔细看看Demo类里面的静态属性Instance以及我们调用的方式,有没有看出什么端倪来?
很显然,上面的调用方法已经违背了单例模式的宗旨,或者可以说是披着单例模式的外衣,却不做单例模式该做的事情。单例模式的解释是:保证一个类仅有一个实例,并提供一个访问它的全局访问点。那么我们应该如何正确的使用单例模式了?
何为单例模式
再回头看上面解释单例模式的话,第一句话说“保证一个类仅有一个实例”,好,那么我们怎样能够保证一个类仅有一个实例了,幸好在C#里面,提供了私有构造器,我们在创建一个类的时候,往往会在类的构造函数里面初始化一些对象,这里的构造器是公开的,如下面
public Demo(){// to do }
那么很显然,私有构造器就是private的,如下面
private Demo(){// to do }
一旦有了私有构造器,那么这个类就会阻止类外面的代码创建实例,不相信我们就来尝试一下。还是用上面的Demo类
public class Demo { private Demo(){ // to do } }
然后再外面去实例化它,看截图
这样一来,你应该明白了私有构造器的作用了吧。
解释单例模式的前半句话上面说的很清楚了,然后在看看后半句“并提供一个访问它的全局访问点”,也就是说向外部提供访问该实例的方法或者属性,怎么写?我们将开篇的例子稍作修改
public class Demo { private Demo() { // to do } private static Demo _instance; public static Demo Instance { get { if (_instance == null)
{
Console.WriteLine(string.Format("线程{0}在{1}时刻发现Instance为null", Thread.CurrentThread.Name,DateTime.Now)); _instance = new Demo();
} return _instance; } } }
调用方式与开篇的一样,这个时候你在单步调试进去,看看发现了什么。到这里,我们因该能正确的理解单例模式以及如何使用单例模式了。
多线程环境下莫名其妙的错误
上面的例子在单线程环境下可以正常的运转,如果换做是多线程环境下,它还能正确的运转吗?
我们来做这样一个实验:1. 在一个程序启动时创建两个线程,线程A与线程B
2. 线程A与线程B分别调用Demo类
如果仅凭自觉的话,我们肯定会觉得只有一个线程来创建Demo的实例,那么事实是不是这样了?Demo类还是上面的那个类,未作任何修改。然后在另一个类中启动两个线程,分别调用类Demo
public class Invoke { public void Run() { Thread t1 = new Thread(new ThreadStart(fun1)); t1.Name = "AAA"; t1.Start(); Thread t2 = new Thread(new ThreadStart(fun2)); t2.Name = "BBB"; t2.Start(); } private void fun1() { while (true) { Demo1.Instance.GetUserId(); Thread.Sleep(1); } } private void fun2() { while (true) { Demo1.Instance.GetUserId(); Thread.Sleep(1); } } }
最后我们在控制台程序里面运行调用类Singleton,看效果图
哥,你目瞪口呆了吧?怎么会有这样的结果?事实证明上面的写法在多线程环境里面会出问题的,那么我们该怎么样去修改它了,让它能在多线程环境下正确的运行。
如何修正在多线程环境下的bug
这里我们会用到著名的双检锁技术,英文名就是“Double-Check Locking”,它是线程同步机制中的一种,它背后的思路是,如果对象已经构造好,就不需要线程同步,另外如果调用如上面提到的属性“Instance”的线程A发现对象没有创建好,就会获取一个线程同步锁来确保只有一个线程构造单例对象,基于这,我们将Demo类再稍微调整下
public class Demo1 { private Demo1() { // to do } private static Demo1 _lock = new Demo1(); private static Demo1 _instance; public static Demo1 Instance { get { if (_instance != null) return _instance; Monitor.Enter(_lock); if (_instance == null) { Console.WriteLine(string.Format("线程{0}在{1}时刻发现Instance为null", Thread.CurrentThread.Name, DateTime.Now)); _instance = new Demo1(); } Monitor.Exit(_lock); return _instance; } } }
然后在调用类中再启动多两个线程CCC,DDD,再次启动程序
这次的结果表明只有一个线程创建了Demo类的实例了。其实上面的写法不是很严谨的,就是当私有构造器未执行完,其他的线程已经发现Instance不为null了,不过这个问题很难模拟出来。未了解决这种问题,那么就要用到Interlocked.Exchange() 这个方法。
还有其他方式创建单例吗
除了双检索技术,还有其他方式实现单例模式吗?答案是肯定的。先来看些下面这种方式
public class Demo2 { private static Demo2 _demo2 = new Demo2(); private Demo2() { Console.WriteLine(string.Format("线程{0}在{1}时刻执行私有构造函数", Thread.CurrentThread.Name, DateTime.Now)); } public static Demo2 Instance { get { return _demo2; } } }
在看下执行结果图
那么它的原理是什么了?这里涉及到类型构造器了,由于当代码首次访问类的一个成员时,CLR 会自动调用一个类型的类构造器,所以当有一个线程访问属性Instance的时候,CLR会自动调用类构造器,从而创建这个对象的实例。
总结
这个话题已经被写乱了,如果我之前不仔细看项目里面的代码,我也不会发现这个问题,有些时候总是会被感觉所欺骗,所以最好的方法就是自己动手亲自实践一番,无非就是几个小时的事情而已。那么你看完这篇文章之后有没有什么感想了?