news 2026/10/3 10:36:48

Unity C#事件订阅内存泄漏:GC为何救不了你

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Unity C#事件订阅内存泄漏:GC为何救不了你

1. 从一次线上事故说起:GC转得飞快,内存却在涨

先说一个我亲身经历的场景。几年前接手一个Unity手游项目,上线之后收到一批崩溃反馈,集中在低端安卓机上。看后台数据,PSS内存随着游戏时长稳步爬升,玩得越久越危险,最后OOM被系统干掉。但奇怪的是,Profiler里GC Alloc每帧都很低,GC Collect调用频率也正常,托管堆看起来干干净净。

这就是典型的"有GC却内存泄漏"的现场。很多刚接触C#的开发者会有一个直觉:C#有垃圾回收,不用手动free,怎么还会泄漏?这个直觉在纯托管世界里基本成立,但Unity不是纯托管世界。你的对象活在托管堆上,可它引用的东西——GameObject、Texture、Mesh、Native容器——活在非托管侧。GC只负责回收"没有任何引用指向"的托管对象,它管不了非托管资源,也管不了"你以为已经不要了、但引用链还活着"的对象。

而事件订阅,恰恰是制造"引用链还活着"的头号嫌疑人。一个委托字段挂在那里,被订阅者持有,订阅者又被别的长生命周期对象持有,整条链就锁死了。GC看到的是"还有人引用",于是判定这个对象还活着,永远不会回收。内存就这么一点点被吃掉。

这篇内容我想把这件事讲透:为什么GC救不了你、事件订阅是怎么把对象钉在内存里的、怎么用工具定位、怎么改代码、以及有哪些容易忽略的变体。适合已经写过一段时间C#、被内存问题折磨过、或者正在做Unity性能优化的朋友。不需要你是GC专家,但需要你愿意动手改代码。

2. GC到底管什么、不管什么:先把边界划清楚

2.1 托管堆的回收逻辑:可达性分析

C#的GC用的是标记-清除加压缩的思路,核心是可达性分析。它从一组根对象出发——静态字段、线程栈上的局部变量、CPU寄存器、GC Handle等——沿着引用链一路标记,凡是能被标记到的对象都算"活着",剩下的才回收。

关键点在于:GC判断的不是"你还需不需要这个对象",而是"还有没有引用能到达这个对象"。这两件事经常不一致。你逻辑上早就不要它了,但只要还有一条引用链从根能走到它,GC就认为它活着。这就是所谓逻辑泄漏——不是内存真的丢了,而是被不该存在的引用锁住了。

注意:GC从不猜测你的意图。它只认引用图。所以"我明明没用了"这种说法,对GC毫无意义。

2.2 非托管资源:GC的盲区

Unity里大量对象是"托管壳 + 非托管核"的结构。比如Texture2D,你在C#里拿到的是一个托管对象,但它背后那块显存是引擎在Native层分配的。GC回收托管壳的时候,如果这个壳实现了IDisposable或者有Finalizer,理论上能触发释放,但时机完全不可控。

更麻烦的是,很多Native资源根本不走Finalizer这条路。你Destroy了一个GameObject,如果还有C#代码持有它的引用,托管壳不会立刻被回收,而它引用的Native部分可能已经被销毁,于是你拿到一个"假活"的对象,访问就报MissingReferenceException。反过来,如果你只把引用置空但没Destroy,Native资源也不会自动释放。

所以内存泄漏在Unity里通常分两类:托管堆泄漏(引用链没断,GC收不掉)和非托管泄漏(Native分配没释放,GC根本管不着)。事件订阅制造的绝大多数是前者,但它会间接导致后者——因为托管壳不释放,它引用的Native资源也跟着悬着。

2.3 为什么"GC Alloc低"不代表没问题

Profiler里的GC Alloc统计的是每帧新分配的托管内存量。它低,只说明你每帧产生的垃圾少,GC压力小。但它完全不反映"有多少老对象被引用链锁住无法回收"。一个泄漏的对象可能是在游戏启动时创建的,之后再也不分配新内存,GC Alloc一直是0,可它就是不走。

我见过有人盯着GC Alloc优化半天,把每帧分配从2KB压到200B,结果内存还是涨。原因就是泄漏发生在对象生命周期管理上,跟每帧分配量是两码事。判断有没有泄漏,要看的是托管堆总量随时间的趋势,以及特定类型对象的实例数是否只增不减。

3. 事件订阅是怎么把对象钉死在内存里的

3.1 委托的本质:订阅者被发布者持有

C#的事件底层是委托,委托底层是一个调用列表,里面存着每个订阅方法的Target(目标对象)和Method(方法信息)。当你写publisher.OnSomething += subscriber.Handle的时候,编译器实际做的是把subscriber这个实例的引用塞进了发布者的委托字段里。

这意味着:发布者持有了订阅者的强引用。只要发布者还活着,订阅者就别想被回收,哪怕订阅者逻辑上早就该销毁了。

画一下引用链:

静态/长生命周期对象 → 发布者 → 委托字段 → 调用列表 → 订阅者实例

这条链上任何一环不断,订阅者就出不去。而发布者往往是单例、Manager、静态类这种"活到游戏结束"的东西,于是订阅者就被永久钉住了。

3.2 一个最小复现:UI面板关不掉

看一段很常见的代码:

public class GameManager : MonoBehaviour { public static GameManager Instance; public event Action<int> OnScoreChanged; void Awake() { Instance = this; } public void AddScore(int v) => OnScoreChanged?.Invoke(v); } public class ScorePanel : MonoBehaviour { void Start() { GameManager.Instance.OnScoreChanged += Refresh; } void Refresh(int score) { /* 更新UI */ } void OnDestroy() { // 这里什么都没写 } }

ScorePanel被销毁(Destroy)之后,GameManager.Instance的委托列表里还挂着ScorePanel.Refresh,而Refresh的Target就是那个已经被销毁的ScorePanel实例。托管堆里这个实例永远不会被回收,它引用的所有UI组件、贴图、字符串也跟着悬着。

你反复打开关闭这个面板100次,就有100个ScorePanel实例堆在内存里。Profiler里看ScorePanel的实例数,只增不减,这就是铁证。

3.3 为什么"销毁了GameObject"不等于"对象被回收"

这是最容易混淆的一点。Destroy(gameObject)做的是销毁Unity层面的对象,把GameObject和它的组件标记为待销毁,在帧末真正移除。但C#的托管对象是另一套生命周期。Destroy之后,那个ScorePanel的C#实例还在托管堆上,只要还有引用指向它,GC就不会动它。

而且被销毁的MonoBehaviour有个特殊状态:它变成"假null"。你用== null判断会返回true(Unity重载了==运算符),但用ReferenceEquals(obj, null)或者直接看Profiler,它实实在在还在内存里。这个设计让很多人误以为对象已经没了,实际上引用链还锁着。

提示:判断一个MonoBehaviour是否真的被回收,别用== null,用Profiler的详细快照看实例数,或者用System.GC.GetTotalMemory配合强制GC观察趋势。

4. 定位泄漏:从Profiler快照到引用链追踪

4.1 用Memory Profiler抓两次快照做对比

Unity的Memory Profiler包(Package Manager里可以装)是排查这类问题的首选。核心用法是抓两次快照做Diff:

  1. 进入一个干净场景,等GC稳定,抓第一张快照。
  2. 执行可疑操作(比如反复开关某个面板20次)。
  3. 回到相同状态,抓第二张快照。
  4. 对比两张快照,看哪些类型的实例数增加了。

如果ScorePanel的实例数从1变成21,那基本可以锁定是它没被回收。这一步不需要你懂GC原理,纯粹是数量对比,非常直观。

4.2 顺着引用链找到"谁在持有它"

找到泄漏类型之后,Memory Profiler可以展开某个实例,看谁引用了它(Referenced By)。顺着这条链往上走,通常能走到一个静态字段或者单例。我排查过的一个案例,引用链是这样的:

ScorePanel实例 ← Action委托 ← GameManager.OnScoreChanged ← GameManager.Instance(静态)

看到静态字段那一刻,问题就清楚了:静态对象活到进程结束,它持有的东西自然也活到进程结束。

这里有个经验:引用链的终点如果是静态字段、单例、或者DontDestroyOnLoad的对象,基本就是泄漏源。因为这些对象的生命周期是"永久",它们不该持有短生命周期对象的引用。

4.3 用代码辅助:给可疑类型加计数

Profiler有时候不够细,或者你想在真机上监控。可以给可疑类型加个静态计数器:

public class ScorePanel : MonoBehaviour { static int _aliveCount; public static int AliveCount => _aliveCount; void Awake() { _aliveCount++; } void OnDestroy() { _aliveCount--; } }

然后在屏幕上或者日志里定期打印AliveCount。正常情况它应该在你关闭面板后回到基线值。如果只增不减,泄漏坐实。这个方法土,但在真机上特别管用,因为真机连Profiler经常卡顿甚至连不上。

4.4 一个容易忽略的点:闭包和匿名方法

事件订阅里用lambda或者匿名方法,会生成一个闭包类,这个闭包对象持有它捕获的所有外部变量。如果你在lambda里捕获了this,那闭包就持有了当前对象,订阅关系就变成了"发布者 → 闭包 → this"。

// 危险写法 button.onClick.AddListener(() => DoSomething(this)); // 相对安全:方法组,Target明确 button.onClick.AddListener(OnButtonClick);

闭包的问题在于,你很难在OnDestroy里精确地-=掉它,因为每次+=生成的闭包实例都不一样。所以能用方法组就别用lambda,这是我在项目里定下的硬规矩。

5. 修复方案:从手动退订到架构层面的解耦

5.1 最直接:OnDestroy里对称退订

最朴素的修法就是在OnDestroy里把订阅退掉:

void OnDestroy() { if (GameManager.Instance != null) GameManager.Instance.OnScoreChanged -= Refresh; }

注意这里要判空,因为GameManager可能比ScorePanel先销毁。这个写法能解决大部分问题,但它有个隐患:依赖开发者记得写。项目一大,几十个订阅点,漏一个就是一个泄漏。靠人肉纪律维持的东西,迟早出事。

5.2 用IDisposable封装订阅生命周期

更稳的做法是把订阅关系封装成一个可释放的对象:

public class EventSubscription : IDisposable { Action _unsubscribe; public EventSubscription(Action unsubscribe) { _unsubscribe = unsubscribe; } public void Dispose() { _unsubscribe?.Invoke(); _unsubscribe = null; } } // 使用 EventSubscription _sub; void Start() { _sub = new EventSubscription(() => GameManager.Instance.OnScoreChanged -= Refresh); GameManager.Instance.OnScoreChanged += Refresh; } void OnDestroy() { _sub?.Dispose(); }

这样订阅和退订成对出现,逻辑上更清晰。但说实话,这还是在"手动管理"的范畴里,只是把散落的退订收拢了。

5.3 弱引用事件:让发布者不持有强引用

真正从根上解决,是让发布者持有弱引用而不是强引用。这样订阅者被回收时,发布者不会阻止它。C#有WeakReference,但直接用比较麻烦,通常封装一个弱事件管理器:

public class WeakEvent<T> { readonly List<WeakReference<Action<T>>> _handlers = new(); public void Subscribe(Action<T> handler) { _handlers.Add(new WeakReference<Action<T>>(handler)); } public void Invoke(T arg) { for (int i = _handlers.Count - 1; i >= 0; i--) { if (_handlers[i].TryGetTarget(out var h)) h.Invoke(arg); else _handlers.RemoveAt(i); // 清理已回收的 } } }

这个方案的好处是订阅者不需要显式退订,被GC回收后自动从列表里清掉。代价是每次Invoke要遍历并检查弱引用,有性能开销,而且弱引用本身在Unity的IL2CPP下行为要实测确认。我一般只在订阅者生命周期极不确定的场景用它,普通UI还是老老实实退订。

5.4 架构层面:用消息总线切断直接引用

还有一种思路是引入消息总线,发布者和订阅者都只跟总线打交道,互相不持有引用:

public static class EventBus { static readonly Dictionary<Type, Delegate> _map = new(); public static void Subscribe<T>(Action<T> cb) { _map.TryGetValue(typeof(T), out var d); _map[typeof(T)] = (d as Action<T>) + cb; } public static void Unsubscribe<T>(Action<T> cb) { if (_map.TryGetValue(typeof(T), out var d)) _map[typeof(T)] = (d as Action<T>) - cb; } public static void Publish<T>(T evt) { if (_map.TryGetValue(typeof(T), out var d)) (d as Action<T>)?.Invoke(evt); } }

但要注意:消息总线本身也是静态的,它同样会持有订阅者的强引用。如果你订阅了不退订,泄漏照样发生,只是从"发布者持有"变成了"总线持有"。所以总线不是银弹,它解决的是耦合问题,不是生命周期问题。生命周期还得靠退订或者弱引用。

5.5 方案对比

方案解决泄漏开发成本性能开销适用场景
OnDestroy手动退订是(依赖纪律)低无订阅点少、生命周期明确
IDisposable封装是(依赖纪律)中无中等规模、想统一管理
弱引用事件是(自动)高每次Invoke有检查生命周期不确定的订阅者
消息总线否(仍需退订)中字典查找解耦需求强于生命周期管理

6. 那些比事件订阅更隐蔽的泄漏变体

6.1 协程持有this

协程也是重灾区。StartCoroutine启动的协程,如果里面yield return了一个长等待,而协程体里引用了this,那么在这个协程跑完之前,this不会被回收。更坑的是,StopCoroutine和StopAllCoroutines在对象销毁时不会自动调用,你得在OnDestroy里手动停。

Coroutine _co; void Start() { _co = StartCoroutine(Loop()); } void OnDestroy() { if (_co != null) StopCoroutine(_co); }

如果协程里是个while(true)加yield return new WaitForSeconds(1),对象销毁后协程还在跑,this一直被引用,泄漏就发生了。

6.2 静态集合只加不减

静态的List、Dictionary、Queue,如果只往里塞不清理,就是慢性泄漏。常见于对象池、缓存、事件队列。我见过一个项目用静态Dictionary做资源缓存,key是资源路径,value是加载好的对象,从来不清理,跑久了内存直接爆。

提示:任何静态集合都要有明确的清理策略。要么设上限做LRU,要么在场景切换时清空。

6.3 委托链上的"僵尸"订阅

有时候订阅者已经销毁,但退订代码因为判空逻辑写错没执行到。比如:

void OnDestroy() { // 如果GameManager先销毁,Instance变成假null,这行直接跳过 GameManager.Instance.OnScoreChanged -= Refresh; }

GameManager.Instance在它自己被销毁后,== null返回true,于是-=根本没执行。正确做法是把发布者的引用缓存下来,或者用ReferenceEquals判断。

6.4 匿名委托无法退订

前面提过,lambda每次生成新实例,-=不掉。如果你非要用lambda,得把它存成字段:

Action<int> _handler; void Start() { _handler = score => Refresh(score); GameManager.Instance.OnScoreChanged += _handler; } void OnDestroy() { GameManager.Instance.OnScoreChanged -= _handler; }

存成字段之后,+=和-=用的是同一个委托实例,才能正确退订。

7. 我踩过的坑和几条硬规矩

第一个坑是以为Destroy就够了。早期我写完Destroy(gameObject)就觉得万事大吉,结果内存照涨。后来才明白托管对象的生命周期和Unity对象是两套系统,必须显式断引用。

第二个坑是在OnDestroy里访问已经销毁的单例。前面说的判空问题,我吃过一次,退订代码静默失败,排查了半天才发现是单例先没了。现在的做法是订阅时把发布者引用缓存到字段,退订时用缓存的引用,不依赖单例的当前状态。

第三个坑是闭包捕获this。有次用lambda订阅,退订怎么都不生效,最后发现每次+=的闭包实例都不同。从那以后项目里定规矩:事件订阅一律用方法组,禁止lambda。

几条我现在坚持的硬规矩,分享出来:

  • 订阅和退订必须成对出现,写在相邻的位置,方便review时一眼看到。
  • 静态对象、单例、DontDestroyOnLoad对象禁止持有短生命周期对象的强引用,这是泄漏的高发区。
  • 任何静态集合都要有清理策略,没有例外。
  • 协程在OnDestroy里必须停,别指望它自己结束。
  • 定期用Memory Profiler做Diff快照,别等上线了才发现泄漏。

最后说个心态问题。内存泄漏这东西,写的时候不觉得,跑起来才要命,而且往往在低端机上先爆。与其上线后救火,不如在写订阅代码的那一刻就多想一步:这个订阅,谁持有谁,什么时候断。想清楚这一层,能省掉后面大量的排查时间。

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

Session存储选型:内存还是Redis?原理对比与Spring Boot迁移实操

做 Java Web 开发久了&#xff0c;session 这个问题迟早会撞到你脸上。我刚入行那会儿&#xff0c;项目还是个单体应用&#xff0c;所有 session 都老老实实躺在 Tomcat 内存里&#xff0c;一个用户登录了就有一份会话数据&#xff0c;简单直接&#xff0c;根本不用操心别的。直…

作者头像 李华
网站建设 2026/10/3 10:34:39

Redis接入AI实战:会话管理、语义缓存与Agent状态落地指南

1. 从“Redis 接入 AI”说起&#xff1a;这件事到底意味着什么Redis 这个名字&#xff0c;做后端开发的人基本都绕不开。缓存、分布式锁、消息队列、排行榜、会话存储&#xff0c;几乎每个中大型项目里都能看到它的身影。而“Redis 正式接入 AI”这个说法&#xff0c;最近在圈子…

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

ASTM D6653M高海拔包装测试:医疗制药空运安全的低压验证方法

1. 项目背景与标准核心价值1.1 为什么医疗制药包装要关心高海拔先抛一个问题&#xff1a;你的药品纸箱从上海发到乌鲁木齐&#xff0c;或者通过航空货运发到拉萨中转&#xff0c;包装会不会出问题&#xff1f;很多做供应链的人第一反应是“包装能有什么问题&#xff0c;箱子结实…

作者头像 李华
网站建设 2026/10/3 10:32:35

Open-Shell完全指南:在Windows 11上找回经典开始菜单与效率

先说明一下&#xff0c;这篇聊的是 Windows 平台上那个把经典开始菜单带回现代系统的开源项目Open-Shell&#xff08;前身叫 Classic Shell&#xff09;。如果你以为它是某个终端或者命令行工具&#xff0c;那多半会被误导&#xff0c;它在 Windows 用户圈子里几乎是"经典…

作者头像 李华
网站建设 2026/10/3 10:31:33

企业专知智库:把一线经验变成行业权力型内容的生产机制

不知道你注意到没有&#xff0c;行业里真正有话语权的企业&#xff0c;往往不是规模最大的那一家&#xff0c;而是那些总能用一套框架重新定义问题、回答趋势的“思想输出型”机构。它们发布的每一份报告、每一次演讲、每一个概念&#xff0c;都会被同行反复引用&#xff0c;甚…

作者头像 李华
网站建设 2026/10/3 10:31:13

张量类型转换与基本运算:从底层逻辑到工程实践

1. 张量类型转换与基本运算的完整拆解张量这个东西&#xff0c;刚接触深度学习框架的人十有八九会在它身上栽跟头。我见过太多人&#xff0c;模型结构写得漂漂亮亮&#xff0c;结果训练一跑就报错&#xff0c;翻来覆去查了半天&#xff0c;最后发现是张量类型不匹配——一个flo…

作者头像 李华