C#原生类(非MonoBehaviour)
C# 规范规定:静态构造函数(也就是初始化静态字段的代码)由 CLR(公共语言运行时)保证只执行一次,并且是线程安全的。
当你第一次访问一个类的静态成员(如Instance属性)时,CLR 会做以下事情:
- 检查该类的静态构造函数是否已经执行过。
- 如果没执行过,CLR 会自动加锁(内部实现,比普通的
lock更高效)。 - 在这个锁的保护下,执行静态字段的初始化代码。
- 初始化完成后,释放锁。
- 之后所有线程访问该静态字段,直接读取内存中的值,不需要再加锁。
经典线程安全写法(利用静态构造函数):
public class Singleton<T> where T : class, new() { // 1. 私有静态字段 private static readonly T _instance; // 2. 静态构造函数(C# 保证线程安全) static Singleton() { _instance = new T(); // 在这里初始化 } // 3. 公共静态属性 public static T Instance { get { return _instance; } } }为什么这个 100% 线程安全?
因为_instance的赋值发生在static Singleton()构造函数中,而 CLR 保证了这个构造函数在多线程环境下只会被调用一次。哪怕 100 个线程同时第一次访问Instance,CLR 也会让它们排队,只有一个线程能执行new T(),其他线程等待初始化完成后直接拿结果。
但是这样的写法不是懒汉模式,只要代码中任何地方引用了这个类(哪怕只是定义了一个变量Singleton<MyClass> x;),静态构造函数就会立即执行,创建实例。如果这个类很重,会影响启动速度。
简洁写法:
private static T _instance; public static T Instance { get => _instance ??= new T(); private set; }在现代 .NET(.NET Framework 4.0+ / .NET Core / .NET 5+)中,对于引用类型,??=操作符的赋值部分是原子的,且具有内存屏障(Memory Barrier),所以这种写法基本上是安全的。
原理分析:
- 原子性:对于引用类型(class),赋值操作(写引用)在硬件层面是原子的(一次 CPU 指令就能完成)。
- 内存屏障:C# 编译器和 JIT 编译器会在这里插入内存屏障,确保:
- 线程 A 创建的对象的内存写入(构造函数执行)先于对
_instance的赋值。 - 线程 B 读取
_instance时,能看到线程 A 写入的完整对象(不会看到半初始化的对象)。
- 线程 A 创建的对象的内存写入(构造函数执行)先于对
- 竞态条件(Race Condition):
- 虽然
??=很巧妙,但它不是一个不可分割的原子操作。它实际上分两步:- 读取
_instance(检查是否为 null)。 - 如果为 null,执行
new T()并赋值。
- 读取
- 极端情况:线程 A 读到 null,正准备
new T(),此时线程 B 也读到 null(因为 A 还没赋值),线程 B 也new T()并赋值。然后线程 A 也new T()并覆盖了 B 的值。 - 后果:会创建两个对象,但只有一个被存到
_instance里,另一个变成垃圾。这不算“单例被破坏”(因为外部拿到的还是同一个引用),但造成了资源浪费(多 new 了一次)。
- 虽然
结论:
- 对于引用类型(class):
??=足够安全,不会出现拿到 null 或者类型转换错误,最多就是多创建了一个对象被丢弃(在单例场景下通常可接受)。 - 对于值类型(struct):绝对不安全!因为装箱和赋值不是原子操作,会出现撕裂赋值(Tearing)。
使用Lazy<T>实现最优写法:
using System; public class Singleton<T> where T : class, new() { // Lazy<T> 保证线程安全 + 懒加载 // ExecutionAndPublication 模式确保只有一个线程执行初始化代码 private static readonly Lazy<T> _lazyInstance = new Lazy<T>(() => new T(), System.Threading.LazyThreadSafetyMode.ExecutionAndPublication); public static T Instance { get { return _lazyInstance.Value; } // 第一次访问 .Value 时才会执行上面的 lambda } }- 100% 线程安全:内部使用了
lock和双重检查,保证只创建一次。 - 真正的懒加载:只有第一次访问
.Value时才执行new T()。 - 代码简洁:不需要自己写锁逻辑。
- 支持异常缓存:如果构造函数抛出异常,
Lazy<T>会缓存这个异常,下次访问直接抛出,不会每次都重试。
MonoBehaviour
因为MonoBehaviour是来自Unity的,无法使用new将托管权交给C#,所以无法使用上述方法实现单例,且MonoBehaviour没有绝对线程安全的单例。
using UnityEngine; public class Singleton<T> : MonoBehaviour where T : MonoBehaviour { private static T _instance; private static readonly object _lock = new object(); // 加锁防止多线程竞争(虽然Unity主线程,但防患未然) public static T Instance { get { // 快速路径:如果已经有实例,直接返回 if (_instance != null) return _instance; // 慢速路径:去场景里找(这一步很慢,所以加锁保护) lock (_lock) { if (_instance == null) { // 尝试在场景中查找 _instance = FindFirstObjectByType<T>(); // Unity 2023+ API if (_instance == null) { // 如果没找到,动态创建 GameObject go = new GameObject($"[Singleton] {typeof(T).Name}"); _instance = go.AddComponent<T>(); } } return _instance; } } } protected virtual void Awake() { // 核心逻辑:防止场景中有多个重复的单例 lock (_lock) { if (_instance != null && _instance != this) { // 如果已经有一个实例了,销毁当前这个重复的 Destroy(gameObject); return; } // 设置为当前实例 _instance = this as T; // 跨场景不销毁 DontDestroyOnLoad(gameObject); } } }这是在我写的单例的基础上加了锁。但是并非真正线程安全的。
因为MonoBehaviour必须用 Unity API 创建,而 Unity API只能在主线程用。C# 的静态构造函数/Lazy 可能在后台线程运行,直接调用 Unity API 会崩溃。所以 MonoBehaviour 单例只能牺牲一部分线程安全性,依赖 Unity 的生命周期(Awake)和运行时检查来保证逻辑正确性。
正确写法是将逻辑层和表现层分离:
逻辑层
public class GameManagerLogic // 普通 C# 类 { private static readonly Lazy<GameManagerLogic> _instance = ...; public static GameManagerLogic Instance => _instance.Value; public void SaveData() { ... } }表现层
public class GameManagerBridge : MonoBehaviour { void Awake() { // 只是确保自己不销毁 DontDestroyOnLoad(gameObject); } public void OnSaveButtonClick() { // 转发调用给纯 C# 单例 GameManagerLogic.Instance.SaveData(); } }