什么是单例模式?
这学期开始教设计模式这门课程,其中第一节课所介绍的就是所有设计模式中最简单也最好理解的单例模式。单例模式是几大设计模式中最常被使用的模式之一,在游戏编程的实战中也有很多用途,比如说维护一个全局唯一的UI管理器、全局唯一的特效/音效控制器等等。该模式适合针对“有唯一需求,不希望存在多个”的系统。也就是说,单例模式的实例同时只能存在 0 个或者 1 个,实例化的过程分为懒实例(懒汉式)、饥渴实例(饥饿式)等。
《游戏编程模式》对于单例模式的定义为:“确保一个类只有一个实例, 并为其提供一个全局访问入口。”
类图
代码实现
final + 懒实例 + 线程锁实现法
final 关键字(等同于 C# 中的 sealed)的主要目的是防止单例类被继承。如果去掉 final,内部类可能会继承单例类,调用内部类的时候会造成多实例化的问题。然而在一些特定的情况下,你可以继承单例,这是一个强大但是经常被忽视的特性。
假设我们需要让文件封装类跨平台,为了实现这一点,我们可以将单例实现为一个抽象接口,并由它的子类提供各个平台上的实现。
什么是懒实例/懒汉式?简单地说,懒实例指的是延迟初始化,是一种延迟对象创建过程的技术。懒实例允许只在需要时才创建对象(而不是在程序开始时就创建完成),当开发者需要处理开销很大的流程来创建对象时,这种方法很有帮助。
至于为什么需要加线程锁(thread lock)呢?原因显而易见,因为我们的程序可能工作在多线程(multi threaded)环境下,线程安全至关重要。而如果两个线程同时实例化单例类的实例,可能造成线程冲突(thread conflict),从而得到两个单例类的实例,这显然是不符合单例期望的。所以在多线程环境下,当有两个或以上个线程访问程序时,给方法加上线程锁是必要的。这样的结果是:同一时间只能有一个线程访问代码,其余线程需要等待,避免的冲突的形成。
虽然 Unity 只有一个游戏主线程,但开发者可以通过 Thread 类自定义新开线程以利用 CPU 在多个核心上同时处理多个线程的能力。这些新线程并行运行,通常在完成后将其结果与主线程同步,因此使用单例时依旧需要注意线程安全的风险。
题外话:线程池
为什么使用线程池?线程池主要目的在于线程资源复用和自动管理。当面对一个任务时开发者可以采用开一个线程的方式来加速运算,然而当任务数量不断增加,自己创建线程的方式不再合理,主要原因有下:
- 创建和关闭新线程是需要消耗CPU时间的,因为操作系统需要分配给新开的线程地址空间、栈空间、寄存器等,在线程结束的时候,操作系统又将这些东西回收。线程池相比于线程来说,不需要频繁的创建和销毁线程,线程一旦创建之后,默认情况下就会一直保持在线程池中,等到有任务来了,再用这些已有的线程来执行任务
- 用户可能创建了大量的线程,而这些线程不是每时每刻都在工作(可能处于休眠状态等待事件发生),需要定期唤醒才能轮询更改或更新状态信息。线程池是复用已有线程来执行任务的,而线程是在有任务时才新建的,所以相比于线程来说,线程池能够更快的响应任务和执行任务。此外线程池可以创建固定的线程数,从而避免了无限创建线程的问题。
优点
- 通过双重检查和锁保证线程安全
- 加载时快速
- 只有在实例尚未初始化时才会进入锁定代码块,降低锁的开销。
缺点
- 代码冗长
- 程序执行过程中需要进行实例化,存在开销问题
在 C# 中,可以采用双重锁来实现线程安全:
1 | using System; |
测试类:
1 | namespace SingletonPattern { |
输出:
===单例模式===
A new UI Controller initialized.
Already has a UI controller
These 2 UI Controller re the same.
在现代 C# 开发中,还可以通过 Lazy<T> 的方式实现懒汉式单例。Lazy<T> 专门用于实现延迟初始化,并且内置了线程安全机制,相比于双重锁的写法更为简洁优雅,以下是例子:
1 | namespace SingletonPattern { |
饥渴实现法(Eager Initialization)
和懒实例化相对,饥渴实例(饿汉式)在程序加载之初就完成了实例化,之后在需要使用相关方法时直接返回实例。
优点
- 简单明了的代码
- 线程安全,虽然不用线程锁,但是在开始的时候就实例化完了
- 静态字段只初始化一次,程序执行过程中开销小
缺点
- 加载时间漫长,因为需要实例化所有单例
1 | using System; |
Bill Pugh法
Bill Pugh 法是一种优雅的实现单例模式的方式,特别适用于 Java。这种方法通过使用一个静态内部类 Helper 来延迟加载单例实例,静态内部类在调用它们的getInstance() 方法之前不会加载到内存中,只有当有人调用 getCaptain() 方法时,才需要考虑Helper类。同时 Bill Pugh 法避免锁的性能开销,并保证线程安全。
C# 没有静态内部类的概念,可以使用嵌套类模拟相同的行为:
1 | public class Singleton |
SingletonHelper 类不会在 Singleton 类加载时初始化,而是在 Instance 属性被调用时才加载。这一行为依赖于 .NET 的类型加载机制,即只有在使用静态成员时,静态类或嵌套类才会初始化。
C# 的静态构造函数由 CLR(公共语言运行时)负责初始化,并且保证线程安全。internal static readonly 字段的初始化是线程安全的,CLR 确保了不会出现竞争条件。
优点
- 线程安全,无须显式加锁,减少了代码复杂度。
- 仅在需要时才实例化单例对象。
- 由于没有锁和检查的开销,性能优于双重锁。
- 简单明了的代码
注意!
虽然单例模式看上去简单易用也很好用,但它实在太容易被滥用了。单例作为一个全局变量,在代码中可能导致一下问题:
-
它们令代码晦涩难懂。不小心在错误的地方错误地修改全局变量面临着高额的调试工作量,因为需要检查每一个引用。
-
全局变量加剧了耦合。
-
不利于并发,因为线程不安全的单例可能造成死锁。
大部分单例都以“Manager”、“System”、“Engine”一类的名字命名,它们有时也被称为保姆类。为了尽可能避免单例调用分布在代码库的各个角落,我们可以试着只在基类中使用单例。我们也可以考虑将所有单例对象包装到一个现有的类里,只通过这个类来访问各个系统(虽然依然会散布在各处,但查找起来只需要查这个类)。
参考资料
[美] Robert Nystrom 尼斯卓姆. 游戏编程模式 (游戏设计与开发). 人民邮电出版社.
https://blog.csdn.net/weixin_43405845/article/details/105022859