news 2026/10/31 23:12:28

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
关于C#/Unity中单例的探讨

C#原生类(非MonoBehaviour)

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

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

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

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

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

原理分析:

  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>实现最优写法:

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(); } }
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/18 14:42:20

理论分享 | 随机振动分析(2):随机振动响应的理论基础与工程实现

文章目录1、自相关函数与功率谱密度1.1 自相关函数1.2 功率谱密度的定义与计算2、随机振动的工程计算实现2.1 模态叠加思想2.2 工程案例实现及仿真结果对比3、谐响应分析、响应谱分析与随机振动分析的区别与联系4、总结最后上篇文章《理论分享 | 随机振动分析&#xff08;1&…

作者头像 李华
网站建设 2026/10/18 14:59:00

2026 企业网盘哪家强?主流 5 款网盘深度测评与避坑指南

企业选型网盘时常陷入一个误区&#xff1a;是选一个“买一送十”的功能全家桶&#xff0c;还是选一个“把一件事做到极致”的基础设施&#xff1f; 自 2011 年上线至今&#xff0c;坚果云企业网盘已稳定运营 15 年&#xff0c;服务超过 10w 知名企业和机构&#xff08;包括中国…

作者头像 李华
网站建设 2026/10/18 15:23:33

油库周界入侵报警系统哪家好?豪景系统在天津油库表现如何?

在为天津油库这类高安全等级场所选择周界入侵报警系统时&#xff0c;必须将可靠性、抗干扰能力和智能化水平放在首位。结合天津油库临海、环境复杂的特点&#xff0c;理想的周界入侵报警系统需要能有效抵御风雨、雾气等干扰&#xff0c;精准识别真实入侵&#xff0c;避免误报。…

作者头像 李华
网站建设 2026/10/18 15:15:14

微信支付JSAPI开发(通常指在微信内打开的H5页面支付)主要分为后端下单和前端调起两个核心环节。

以下是详细的开发教程与关键代码示例。 一、 后端开发:统一下单接口 后端需要调用微信支付统一下单接口,生成用于前端调起支付的参数。 1. 核心参数 调用接口前,后端需要准备以下关键参数: appid:公众号或小程序的AppID。 mch_id:微信支付商户号。 openid:用户在公…

作者头像 李华
网站建设 2026/10/18 14:52:52

计算机毕业设计springboot小型餐厅点餐系统 基于Spring Boot的餐饮门店在线订餐平台 智慧食堂数字化服务系统的设计与实现

计算机毕业设计springboot小型餐厅点餐系统e50yt &#xff08;配套有源码 程序 mysql数据库 论文&#xff09; 本套源码可以在文本联xi,先看具体系统功能演示视频领取&#xff0c;可分享源码参考。随着移动互联网技术的深入普及与餐饮行业的数字化转型加速&#xff0c;传统的人…

作者头像 李华