关于C#/Unity中单例的探讨

C#原生类(非MonoBehaviour)

C# 规范规定**:静态构造函数(也就是初始化静态字段的代码)由 CLR(公共语言运行时)保证只执行一次,并且是线程安全的。**

当你第一次访问一个类的静态成员(如 Instance 属性)时,CLR 会做以下事情:

  1. 检查该类的静态构造函数是否已经执行过。
  2. 如果没执行过,CLR 会自动加锁 (内部实现,比普通的 lock 更高效)。
  3. 在这个锁的保护下,执行静态字段的初始化代码。
  4. 初始化完成后,释放锁。
  5. 之后所有线程访问该静态字段,直接读取内存中的值,不需要再加锁。

经典线程安全写法(利用静态构造函数):

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),所以这种写法基本上是安全的。

原理分析:

  1. 原子性:对于引用类型(class),赋值操作(写引用)在硬件层面是原子的(一次 CPU 指令就能完成)。
  2. 内存屏障 :C# 编译器和 JIT 编译器会在这里插入内存屏障,确保:
    • 线程 A 创建的对象的内存写入(构造函数执行)先于_instance 的赋值。
    • 线程 B 读取 _instance 时,能看到线程 A 写入的完整对象(不会看到半初始化的对象)。
  3. 竞态条件(Race Condition)
    • 虽然 ??= 很巧妙,但它不是一个不可分割的原子操作。它实际上分两步:
      1. 读取 _instance(检查是否为 null)。
      2. 如果为 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();
    }
}
相关推荐
win x1 小时前
JVM类加载及双亲委派模型
java·jvm
毕设源码-赖学姐1 小时前
【开题答辩全过程】以 滑雪场租赁管理系统的设计与实现为例,包含答辩的问题和答案
java
Javatutouhouduan2 小时前
SpringBoot整合reids:JSON序列化文件夹操作实录
java·数据库·redis·html·springboot·java编程·java程序员
wen__xvn2 小时前
模拟题刷题3
java·数据结构·算法
bug攻城狮2 小时前
Spring Boot应用内存占用分析与优化
java·jvm·spring boot·后端
無限進步D2 小时前
Java 循环 高级(笔记)
java·笔记·入门
小六溜了2 小时前
模块二十三.网络编程&正则表达式&设计模式
java
今天你TLE了吗2 小时前
JVM学习笔记:第八章——执行引擎
java·jvm·笔记·后端·学习