ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

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

2026/10/3 10:36:52 拓冰建站 浏览量
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。反过来如果你只把引用置空但没DestroyNative资源也不会自动释放。所以内存泄漏在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 Actionint 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判断会返回trueUnity重载了运算符但用ReferenceEquals(obj, null)或者直接看Profiler它实实在在还在内存里。这个设计让很多人误以为对象已经没了实际上引用链还锁着。提示判断一个MonoBehaviour是否真的被回收别用 null用Profiler的详细快照看实例数或者用System.GC.GetTotalMemory配合强制GC观察趋势。4. 定位泄漏从Profiler快照到引用链追踪4.1 用Memory Profiler抓两次快照做对比Unity的Memory Profiler包Package Manager里可以装是排查这类问题的首选。核心用法是抓两次快照做Diff进入一个干净场景等GC稳定抓第一张快照。执行可疑操作比如反复开关某个面板20次。回到相同状态抓第二张快照。对比两张快照看哪些类型的实例数增加了。如果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 WeakEventT { readonly ListWeakReferenceActionT _handlers new(); public void Subscribe(ActionT handler) { _handlers.Add(new WeakReferenceActionT(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 DictionaryType, Delegate _map new(); public static void SubscribeT(ActionT cb) { _map.TryGetValue(typeof(T), out var d); _map[typeof(T)] (d as ActionT) cb; } public static void UnsubscribeT(ActionT cb) { if (_map.TryGetValue(typeof(T), out var d)) _map[typeof(T)] (d as ActionT) - cb; } public static void PublishT(T evt) { if (_map.TryGetValue(typeof(T), out var d)) (d as ActionT)?.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得把它存成字段Actionint _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快照别等上线了才发现泄漏。最后说个心态问题。内存泄漏这东西写的时候不觉得跑起来才要命而且往往在低端机上先爆。与其上线后救火不如在写订阅代码的那一刻就多想一步这个订阅谁持有谁什么时候断。想清楚这一层能省掉后面大量的排查时间。