读书笔记-设计模式-可复用版-Singleton 单例模式
Singleton单例设计模式是最简单,最常用,最易于理解的一种设计模式
你几乎可以在任何项目中看到Singleton使用的地方,只要对象是“独一无二”的,我们都可以设置成Singleton模式
(单例模式应该是所有模式中,最有的讲的,因为涉及到多线程会牵扯出来不少的额外的知识)
关键词:
1.lock&deadlock
2.lazy&greedy
3.memory barrier
4.beforeFieldInit
比如游戏中的各种管理器(GameManager,AchievementManager,LevelManager,LoginManager....),工具类,甚至是某些全局常驻的UI视图类,只要他是独一无二,
都可以定义为Singleton
概念:
保证当前类的实例,在整个程序的运行周期中,有且仅有一个实例,并提供一个访问它的全局接口。
单例模式,有如下几个特点:
1.这个类是无法直接进行new初始化的,类的构造函数需要设置为private私有
2.通常单例的类是密封的sealed(JIT优化),不可以派生重写,否则实例就不唯一了
3.单例要对外提供一个获取Instance实例的接口,且是静态的
最简单的单例模式例子:
我们直接通过Singleton.Instance.xxxx就可以方便的调用指定的方法了,在C#中,可以将Instance设置为属性,这样连()都不需要了
这是最简单的单例模式的代码,但这种写法非常的糟糕,下面会说明原因。
难道单例设计模式只有这一点儿可讲吗?
如果涉及到多线程,就需要处理同步的问题,并且在实际应用中,很常见,只要涉及到网络,通常都会涉及到多线程。
也就是说,上面的写法糟糕在它不是线程安全的(Thread Unsafe)!
他会带来的问题是,当我们去调用Instance()时,instance = new Singleton()可能会被初始化多次,这样实例就不唯一了!
举例说明:


那么,如何处理多线程模式下,单例模式是线程安全的(Thread Safe),即有没有更简
单的方法来处理指令重排序的问题?
通过lock语句,实际上,lock锁定,隐含的执行了Memory Barrier(内存屏障)
这篇文章进行了很好的讲解,在大话设计模式也有不错的解释
http://csharpindepth.com/Articles/Singleton#exceptions
(可以直接参考这两部分的资料)
线程安全的实现
通过lock语句,获取一个共享对象上的锁,保证当前只能有一个线程进入lock代码块,其它线程需要等待,lock语句执行时,会加锁,lock语句在结束以后,会释放锁,这样其它的进程才可以再进来,这样就保证了Instance不会被实例化多次。
在上面的网址中有这样一段话:
(as locking makes sure that all reads occur logically after the lock acquire, and unlocking makes sure that all writes occur logically before the lock release)
locking确保了所有逻辑上读取会在lock acquire(获取lock)之后发生,unlocking确保了所有逻辑上的写入会在lock release(lock释放)之前发生,这样就保证了,我在解锁前,instance的值会被写入到内存中,同时也就保证了下一个线程可以正确的获取instance的值,保证了有序性,也就不会出现Instance被创建两次的情况!
在之前的测试代码中,将方法做如下修改:

关于lock(xxx)中锁定的对象:
lock 关键字可确保当一个线程位于代码的临界区时,另一个线程不会进入该临界区。 如果其他线程尝试进入锁定的代码,则它将一直等待(即被阻止),直到该对象被释放。
lock 关键字在块的开始处调用 Enter,而在块的结尾处调用 Exit。
private static readonly object padlock = new object();
是定义的私有的只读的共享对象,默认是CLR在启动装载时,就会创建,但在有些代码中,还可以见到这种写法:
lock(this)
lock(typeof(type))
lock("xxx")
三者一样糟糕
MSDN的文档中,并不建议这样做,因为一切public的地方,都是不安全的,超出了代码的控制范围,可能会产生deadlock (死锁)
应避免锁定 public 类型,否则实例将超出代码的控制范围。
Stackoverflow中也有讨论:
https://stackoverflow.com/questions/251391/why-is-lockthis-bad
大致翻译下,通常来讲,最好避免去锁定一个公共的类型(某个具体的类 typeof(type)),或者超出控制范围的对象实例(即公共的实例),例如,如果实例可以被公共public说,那么,lock(this)会出现问题,因为这样,其它部分的代码也可以lock这个实例,这会带来的直接问题就是死锁,两个或多个线程等待同一个对象的释放。
锁定一个公共的数据类型(data type),而不是对象,会引起相同的问题
锁定字符串是尤其危险的,字符串比较特殊,他是”暂留”在CLR运行时中,意味着,相同的字符串,实际上是全局同一个对象的,也会引起相同的问题,死锁(deadlock)
使用危险的字符串,模拟一个死锁的例子:
创建两个线程,分别执行DeadLock1,DeadLock2两个方法,运行结果是:
thread_1 get lock A
thread_2 get lock B
产生死锁!
解释下死锁的产生:
假设线程一先执行,线程一执行了DeadLock1,进入方法内部,lock("A")//获取引用对象“A"的锁,这时候,另外一个线程也执行了DeadLock2,lock("B")//获取引用对象“B"的锁
A和B均被锁住(locking)
假设A先继续向下执行,执行到lock("B"),但此时B被线程二锁住,线程一处于等待,
线程二继续执行,执行到lock("A"),但A被线程一锁住,尴尬情况就出现了,线程一在等待
线程二释放B,而线程二在等待线程一释放A,就这么僵持,死锁!
相应的,lock(this),lock(typeof(type)) 也会引起相同的问题,这里在使用中,一定要注意!
那么什么是最佳的写法?
最佳的方法就是提供一个private/protected的静态成员,控制他的访问范围,专门用于locking
private static readonly object padlock = new object();
lock(padlock)
{
....
}
现在接手的一个项目中,就是使用的lock(this),同事通过代码质量管理工具SonarQube,有提示此句有错误,当时不以为然......
通过locking,解决了同步的问题,但遗憾的是,通过上面可知,lock语句这么强大,肯定是有性能消耗的,所以这种方式在频繁的调用中,每次都要lock acquire/lock release,性能堪忧,尤其是放在Update中的时候......所以下面是一个优化的方案:
采用双重检查锁定(double-check locking)
乍看上去,代码很奇怪,为何要在lock的外面,再加一层if(instance==null)的判定
原因是当其中一个线程持有共享对象的锁,并进入lock语句,完成instance的创建,然后释放锁(这个过程的读取和写入都是在after lock acquire和before lock release发生的,也就确保内存上的数据会更新),下一个线程执行时,执行到if(instance==null)时会返回,因为上一个线程已经完成了Instance的创建,所以下一个线程就不会再执行lock语句了,这样就提高了性能,上面的代码是每一次都会调用lock,而加上double-check,就可以避免每次都调用lock了
通过双重检查锁定机制,性能有了一定的提升,但他还是不够好,在参考的文档中有说到,他没法在Java中执行等,还有更好的方法,即不使用lock
没有使用lock,但也是线程安全的(thread-safe),这里使用到了静态构造函数
static Singleton()
{}
加上静态构造函数的原因是什么?
看上面有一段注释 :
// Explicit static constructor to tell C# compiler
// not to mark type as beforefieldinit
显示声明静态构造函数,告诉 C#编译器,不要将类型(type)标记为beforefieldInit,可以理解为字段初始化之前。也就是说,默认是beforefieldInit
什么是beforeFieldInit?
这里有一篇文章进行了很详细的解释
http://csharpindepth.com/Articles/BeforeFieldInit
这是一个.Net中关于类型构造器执行时机的问题,有两种方案:
beforefiledinit(默认)
precise
这两个模式的切换只需要添加一个static构造函数即可,存在静态构造函数则是precise方案,
没有static构造函数则是beforefieldinit方案
C#编译器,默认是beforefieldInit(为了提高性能),类型的初始化会在静态字段调用之前执行或者说一进方法就会执行。比如:
当我们调用Start函数时,输出结果如下:
方法中,Main execute明明应该先执行,现在是GetNow execute!先执行了,也就是MyClass.Time 静态成员先进行了初始化,然后再是Main execute!
最后是输出具体的时间
对于单例模式,这种执行顺序有的时候不是我们希望的,所以,为了解决这个问题,我们需要添加一个static静态构造函数
static MyClass()
{}
再次运行输出:
通常这才是我们需要的结果,所以在单例模式中,我们经常会看到一个静态构造函数(通常是空的!),就是为了解决静态字段提前初始化的原因。
默认是beforefieldinit的原因是性能更好,因为beforefieldinit,JIT只需要检查一次类型是否被初始化,而precise,JIT则需要每次都要检查类型是否被初始化。
这种初始化的方式叫lazy initilizer,可以翻译为延时初始化,或是懒汉初始化,相应的也会有lazy load
lazy的意思是:只有在我调用的时候,我才去初始化它!
不调用的时候,他就一直处于未初始化的状态。
一定要添加static静态构造函数吗? 答案是否定的,如果不带有静态构造函数,上面例子已经给出了,我们在方法中会调用该类的实例时,会先进行类型的初始化,这种方式被称为Greedy在,即饿汉式,只要我引用类中任意一个静态成员,调用之前,静态字段就会分配内存,而相应的Lazy是只有我调用的时候,才进行初始化。
严格意义上来讲,上面的不能算是完成的Full Lazy,在截图上说not quite as Lazy(没那么Lazy),原因如果类中有其它的静态字段,那么调用任意的静态字段,其它的字段也会被初始化。
比如,添加如下方法并调用:
}
只调用了MyClass.Time1,则Time静态字段也会被初始化

所以,他不能算严格意义上的Full Lazy.
所以他又提供了另外一个Full Lazy Version:
添加了一个Nested嵌套内部静态类,我只有调用Singleton.Instance时,Nested的静态字段才会被初始化。如果Singleton中有其它的静态方法,Nested均不会被初始化。
但通常,我们并不需要Full Lazy的版本,Fourth Version就可以满足了。
文档中还提供了最后一个实现版本:
如果你使用.Net 4或是更高的版本,可以使用System.Lazy<T> 实现Lazy版本,非常的简单,你要做的传递一个delegate,直接写一个Lamada表达式,里面初始化具体的Instance就好,简单且性能很好,并且你可以通过 IsValueCreated属性去判断Instance是否已经被创建。
上面的代码隐式地使用LazyThreadSafetyMode.ExecutionAndPublication作为Lazy<Singleton>的线程安全模式。
但很多Unity项目依然是使用Stable .Net 3.5的版本,所以只能等.Net 4(or higher)以后才可以使用。
在文章的最后做了一些关于性能方面的讨论,到底哪个方案最好,如果说你要在Update中,每帧调用,带有lock的会被认为最为低效的,但为什么不声明一个变量先保存他的引用,然后再在Update中调用呢,如果是这样的话,性能最差的版本,也可以获得不错的表现:)
这也是为何很多版本中,经常能够看到单一lock的实现方式,有的文章说double-check更安全,其实并不是,是为了提高性能,避免反复的lock,通常这种性能上的差异可以忽略不计,正如上面最后那段话说的那样。
我个人偏向于single lock,not quite as lazy,full lazy 版本,如果是在.Net4.0(or highter),毫无疑问,我会选择System.Lazy<T>的版本
最后,如果当前的项目中,大量的应用了单例设计模式(只要满足实例全局独一无二),会引起什么问题?
1.调用方面,单例过多,不易于管理,可以通过维护一个关联列表或是使用外观设计模式(后面会讲到),提供统一的接口,减少依赖性
2.重复代码过多,单例部分的实现都是一样的,定义Instance,GetInstnace(),过多重复的代码显然不合理,可以通过泛型来提高复用性,减少重复的代码,且利于维护
范型单例:
调用代码:
InstanceTest.Instance.SaySomething("hello my buddy!");
对于静态构造函数:
通常不需要添加,没有那么严格的使用环境
Singleton<T>泛型的实现,还可以使用lock(single check or double check):
效果是一样的,只是性能上,相比第一个肯定要差一些,但上面的讨论中也提到,如果你一定要在Update中循环调用,应该声明一个引用来缓存它。这样两者之间的差别就可以忽略不计了。
最后说一说在Unity中的Singleton泛型如何实现,游戏中会有众多的MonoBehaviour,同样,我们也不需要每一个都实现重复的代码,下面的代码是Unity中实现Singleton的泛型模板,大致说下原理。
最后提下在最近的工作上,碰到了一个关于单例设计模式的坑,上面有提到,只要是”独一无二的“的对象,通常都可以做成单例模式
因为接手的是一个第三方CP的项目,犹豫前期对代码的不了解以及一些疏忽,游戏中的GameComponent组件(游戏功能菜单),我们为了方便使用,设计成了单例模式(游戏并没有基于观察者模式去实现UI上的交互)
因为当前的场景中,只有一个GameComponent,这样使用单例是没有问题的,后来发现,当我们加载多人游戏的Scene时,我们调用GameComponent接口,功能不正常了,原因是多人游戏场景的Prefab中也绑定了一个GameComponent
这样就会倒置,GameComponent.Instance静态实例,在加载了多人游戏的Scene时,指向了多人游戏的GameComponent
void Awake()
{
instance = this;
}
而且在退出多人游戏后,逻辑上并没有直接Unload Scene,仅仅只是隐藏了Scene,GameComponent.Instance始终还是指向多人游戏的GameConponent,这样我们在调用GameComponent的接口时,影响的还是多人游戏,而单人游戏的GameComponent,并没有变化,这种问题很难发现,所以一定要注意这种情况!
先到此为止,没有想到的是小小的单例模式牵扯出来这么多的内容,周末晚愉快,take a snooze,然后去吃张记烤羊腿~)
参考文献:
1.设计模式-可复用版
2.大话设计模式
3.http://csharpindepth.com/Articles/Singleton#exceptions
4.http://www.cnblogs.com/tianguook/p/3663651.html
5.https://blog.csdn.net/gdou_yun/article/details/53131781
感谢您的阅读, 如文中有误,欢迎指正,共同提高
欢迎关注我的技术分享的微信公众号,Paddtoning帕丁顿熊,期待和您的交流