C#原生类(非MonoBehaviour)
C# 规范规定**:静态构造函数(也就是初始化静态字段的代码)由 CLR(公共语言运行时)保证只执行一次,并且是线程安全的。**
当你第一次访问一个类的静态成员(如 Instance 属性)时,CLR 会做以下事情:
- 检查该类的静态构造函数是否已经执行过。
- 如果没执行过,CLR 会自动加锁 (内部实现,比普通的
lock更高效)。 - 在这个锁的保护下,执行静态字段的初始化代码。
- 初始化完成后,释放锁。
- 之后所有线程访问该静态字段,直接读取内存中的值,不需要再加锁。
经典线程安全写法(利用静态构造函数):
cs
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;),静态构造函数就会立即执行,创建实例。如果这个类很重,会影响启动速度。
简洁写法:
cs
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>实现最优写法:
cs
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没有绝对线程安全的单例。
cs
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)和运行时检查来保证逻辑正确性。
正确写法是将逻辑层和表现层分离:
逻辑层
cs
public class GameManagerLogic // 普通 C# 类
{
private static readonly Lazy<GameManagerLogic> _instance = ...;
public static GameManagerLogic Instance => _instance.Value;
public void SaveData() { ... }
}
表现层
cs
public class GameManagerBridge : MonoBehaviour
{
void Awake()
{
// 只是确保自己不销毁
DontDestroyOnLoad(gameObject);
}
public void OnSaveButtonClick()
{
// 转发调用给纯 C# 单例
GameManagerLogic.Instance.SaveData();
}
}