
这个问题我被人问过无数次尤其是在项目优化阶段和上线前压测的时候。很多人拿着Profiler截图跑过来“内存涨得很快但不知道是谁在涨。”翻代码翻半天最后往往卡在同一个地方——Unity里的内存泄漏跟你在教科书上看到的C内存泄漏不是同一个东西甚至跟其他C#服务端项目也不太一样。这篇东西我打算从概念、典型原因、排查流程、修复手段一直讲到真实案例希望能帮你把这块彻底打通。1. 先厘清一个概念Unity里的“内存泄漏”和C/Java里说的泄漏不是一回事1.1 教科书意义上的内存泄漏在Unity C#里其实很少见在C/C的世界里内存泄漏的意思是你malloc了一块内存指针丢了既没法释放也没法访问这块内存就永远消失了。在Java和C#这种带GC的环境里这种“指针丢失型”泄漏几乎不存在因为GC会扫描所有可达引用不可达的对象自然会被回收。Unity的C#环境也类似所以严格来说你是很难写出“内存永久无法回收”的代码的。但实际项目里我们依然会说“这里泄漏了”指的是什么呢是对象本应该死但还有人拿着它的引用导致GC认为它“活着”于是它和它引用的所有东西——纹理、网格、动画、UI组件——全部留在托管堆和原生内存里越积越多。这就是常说的“意外存活”或“逻辑泄漏”。它比真正的指针丢失更隐蔽因为你找不到“谁malloc了没free”只能找到“谁还握着这个对象不放”。1.2 除了托管堆Unity还有原生资产这一层内存很多人排查内存时只看Managed Heap这是不够的。Unity有一套原生内存体系专门用来存放Texture、Mesh、AudioClip、AnimationClip、Shader等资产数据。这些数据虽然由C#对象比如Texture2D包裹但资产本体在引擎底层以原生内存形式存在。这里有个关键区别C#对象可以被GC回收但如果C#对象还在引用着Texture那么Texture的原生内存就不会被释放。反过来你把Texture2D对象置为null原生内存也未必马上归还得等引擎执行卸载逻辑。所以你会看到一种情况托管堆不大但整体内存一直在高位原因就是原生侧资产滞留。1.3 还要警惕托管堆“只涨不缩”的碎片化问题即使你的代码没有任何泄漏也会遇到一个现象内存一旦涨上去就算你把所有对象都释放了进程占用的内存也不一定能降回来。Unity的Mono/IL2CPP托管堆在扩容后未必会把内存段还给操作系统它会保留以备后用。这带来的实际后果是就算不是泄漏你也会看到内存“阶梯式”上升。如果团队里有人对这块不理解很容易把正常增长误判成泄漏然后白排查好几天。所以我的建议是做内存分析前先得把Profiler里每条曲线的含义搞清楚不然方向就偏了。2. 导致问题最常见的几类泄漏源头事件、静态引用、协程和资源滞留2.1 事件订阅后没解除这是托管堆最普遍的内存杀手C#的事件和委托本质上是对象引用。当你写evt SomeMethod的时候事件发布者就持有了一个指向SomeMethod所属对象的强引用。如果发布者活得比订阅者久订阅者就永远无法被GC回收。来看一个典型例子public class GameManager : MonoBehaviour { public static event System.Action OnPlayerDied; } public class PlayerHUD : MonoBehaviour { private void OnEnable() { GameManager.OnPlayerDied HandlePlayerDied; } private void OnDisable() { // 漏写了这一行 // GameManager.OnPlayerDied - HandlePlayerDied; } private void HandlePlayerDied() { } }GameManager是静态的App启动后一直存在。PlayerHUD每次打开界面都会调用OnEnable如果只加不减那么每一次打开界面静态事件表里就多一个对旧HUD实例的引用。旧的HUD永远收不到GC它引用的整个UI树也一块儿被留下来。你打开界面多少次就等于积攒了多少套不可见的UI实例。这种情况在Profiler里的表现是Managed Heap Used持续上升内存快照里能查到大量重复的UI组件实例。后面我会讲怎么通过Memory Profiler把这些重复实例揪出来。2.2 静态字段、单例和“全局管理器”的长期持有静态字段是整个进程中生命周期最长的引用路径因为它不随场景卸载而清空。最常见的坑有这么几类静态List或Dictionary里缓存了场景对象比如static ListEnemy AllEnemies敌人死亡后没有从列表移除。单例里存了当前场景的引用切场景后单例还活着但它指向的却是已经被卸载的旧场景对象。用静态变量存了某个UI面板或者大型数据对象用完后忘了置null。这里有个容易被忽视的点static不会因为场景切换而自动清理。你在Scene A创建了一个对象赋给了某个静态字段切到Scene B后A被卸载但这个对象因为静态字段还指向它于是它就一直躲在内存角落里。解决思路也很直白能不用静态就不用必须用的话约定好生命周期在切场景或OnDestroy里主动清理。2.3 协程和异步操作里的隐式引用协程在Unity中本质上是实现了IEnumerator的状态机对象。当你写private IEnumerator AttackLoop() { while (true) { Attack(); yield return new WaitForSeconds(1f); } }这个协程会持续持有它所属的那个MonoBehaviour实例。如果你在OnDestroy里没有主动停止协程而协程又是一个永远不结束的循环那这个MonoBehaviour以及它挂载的GameObject都会一直存活。同理async/await方法如果内部有未完成的任务或者捕获了拥有较长时间生命周期的对象也会产生类似问题。我看到过有人在UI面板里写await Task.Delay(...)面板关掉了那个async方法还挂着闭包里捕获了面板的其他组件引用整个面板就成了“僵尸对象”。处理办法是涉及生命周期的对象在关停时统一走一个清理入口把所有协程停掉所有异步回调置空。如果你用UniTask尽量用支持取消的版本把CancellationToken一路传递下去。2.4 UnityEngine.Object的“假泄漏”与原生资源滞留跟纯C#对象不一样UnityEngine.Object的子类GameObject、Component、Texture、Mesh等有自己的一套生命周期管理。一个常见误解是把对象置null就立刻释放了。实际情况是脚本中对UnityEngine.Object的引用底层还会映射到一个原生对象。只有原生对象被销毁或者场景卸载并执行资源回收之后内存才会真正归还。如果你只是把C#引用置null但引擎侧还没执行销毁资源会滞留在内存里。还有一类更隐蔽的大型资产被public字段引用。比如你给组件挂了个public Texture2D bigTexture在Inspector里拖进去了那么这个资产就是“被引用”状态。哪怕你场景里已经看不到了只要这个组件实例还存在资产就不会被卸载。这种问题配合Resources文件夹使用的时候尤其严重因为Resources里的资产本来就一直加载在内存里。解决这类问题要区分情况动态加载的用Addressables管理引用计数动态创建的Mesh、Texture一定要在OnDestroy里调用DestroyResources卸载则需要配合Resources.UnloadUnusedAssets()。下面用一张表把这几个类型区分开泄漏类型内存区域根本原因典型表现主要定位工具托管对象滞留Managed Heap事件/静态/缓存持有未释放引用堆持续上升快照里出现重复实例Memory Profiler、GC Alloc原生资产滞留Native Memory资产被C#对象引用且未卸载整体内存高托管堆却不高Memory Profiler、设备原生工具协程/异步状态机Managed Heap未终止的操作持有实例关闭对象后内存不回落代码审查、断点确认堆不收缩/碎片化Managed HeapGC保留已扩大的内存内存高点后不下降Profiler曲线3. 用Profiler和Memory Profiler把“嫌疑对象”从代码里揪出来3.1 先打开Profiler把Memory这一栏看明白定位内存泄漏第一步不是翻代码而是让数据说话。Unity的Profiler窗口Window Analysis Profiler有CPU、GPU、Memory、Rendering等多个模块排查内存问题把注意力集中在Memory模块就行。在Memory模块里你会看到几项关键数值Managed Heap Used托管堆实际使用的字节数。如果这个数值随时间持续上升说明有托管对象被留在堆里。Total Allocated从启动到现在的总分配量。它很大很正常关键看它是否还在快速增长。GC Allocation (B/帧)每帧新增的托管堆分配。这个数如果是持续大于0意味着每帧都在产生垃圾垃圾不一定泄漏但如果配合Managed Heap只涨不降就很可疑。排查手法上我一般先在编辑器里运行操作目标功能然后观察Managed Heap Used曲线。如果曲线“台阶式”上升且回不来说明有东西被留住了。3.2 用GC Alloc定位“每帧都在分配”的热点代码GC Alloc列能告诉你当前帧里每个函数分配了多少字节。虽然分配不等于泄漏但持续分配往往意味着某个高频调用在创建临时对象比如字符串拼接、LINQ表达式、装箱操作。打开Profiler的CPU Usage模块选择编辑器模式下运行在Hierarchy视图里可以按“GC Alloc”列排序。如果看到一个函数每帧分配几十KB甚至几百KB那就是需要重点优化的地方。常见的隐藏分配源包括$text{value}这种字符串插值每次都在new string对int/float字段做装箱比如把数字直接塞进object类型参数LINQ的Where、Select、ToList很多都有临时分配用params object[]拼接日志GC Alloc定位的是“的分配”不是泄漏本身但控制了每帧分配量之后GC压力会大幅下降内存曲线也会平稳很多。3.3 用Memory Profiler抓两张快照做对比这是定位泄漏最有效的一步只凭Profiler的曲线你能知道“有泄漏”但不知道“谁泄漏”。想找到具体的持有者推荐用Unity官方的Memory Profiler包。安装方式是在Package Manager里搜索com.unity.memoryprofiler或者在manifest.json里手动加一行。安装后打开Window Analysis Memory Profiler。我最常用的排查流程是这样的先在游戏刚启动、处于稳定状态时Capture一个内存快照作为基准。反复执行你认为有问题的那套操作比如连续打开关闭背包20次。停止操作后等两三帧稳定再Capture一个快照。在两个快照之间点击Compare查看Diff结果。在Diff结果里重点找那些数量明显增加的“目标类型”如果你操作的是背包UI就找UI相关类如果操作了20次理论上UI对象应该只有1份活着的实例结果Diff里显示20份那每份就是一次泄漏。Memory Profiler还提供“Referenced By”视图。选中一个疑似泄漏对象点击“Select”然后在Details面板里看谁引用了它展开引用路径就能看到完整的持有链。这一步基本等于破案了路径上写着PlayerHUD - GameManager.OnPlayerDied - Listener你一眼就能知道该去哪里改代码。3.4 真机远程调试编辑器里正常不代表真机正常有些内存问题在编辑器里怎么复现都出不来一到低端手机上就露馅。最常见的原因是编辑器环境下资源和纹理加载策略跟真机不一样或者平台相关的AssetBundle加载路径有差异。这种情况需要用USB连接真机在Profiler里选择Autoconnect Profiler以Development Build模式打包。这样就能看到真机上的实时内存数据。另外真机上除了Unity托管堆还得关注系统级原生内存。Android上可以用Android Studio的Memory ProfileriOS上可以在Xcode里用Allocations instrument交叉验证一下某些大块内存到底是不是Unity分配的。有时候你推算出来几百MB的“泄漏”最后发现是某个音频解码库或者第三方SDK在原生层占着内存这种情况Unity Profiler是看不到细节的。4. 修复与预防从一行代码到团队规范4.1 事件订阅的规范加号必须配对减号最简单的修复就是保证每个都有对应的-。我通常在事件绑定方法里同时写好解绑方法public class PlayerHUD : MonoBehaviour { private void OnEnable() { GameManager.OnPlayerDied HandlePlayerDied; } private void OnDisable() { GameManager.OnPlayerDied - HandlePlayerDied; } private void HandlePlayerDied() { } }用OnEnable/OnDisable配对比用OnDestroy更稳。因为对象被禁用时事件就解绑了不会出现在隐藏状态下还被回调的情况。还有一种情况是Lambda表达式evt () Foo()。这个匿名委托是没办法用-解除的因为每次Lambda都会生成一个新的委托实例。如果你要解绑就得先把委托存到一个字段里private System.Action onDiedHandler; private void Awake() { onDiedHandler () HandlePlayerDied(); } private void OnEnable() { GameManager.OnPlayerDied onDiedHandler; } private void OnDisable() { GameManager.OnPlayerDied - onDiedHandler; }如果你用的是UnityEvent比如Button的onClick也是一样的逻辑AddListener和RemoveListener必须成对出现。特别是UI监听里用了闭包捕获循环变量这个问题后面真实案例里会详细展开。4.2 静态字段和单例能不持有就不持有静态字段不是不能用而是必须有明确的归属和清理时机。常见做法有两种静态字段只存值类型或不可变数据不存场景对象的引用。如果一定要存给静态字段写一个Reset()或者Release()方法在场景切换时调用。比如public static class Registry { public static ListEnemy ActiveEnemies new ListEnemy(); public static void Clear() { ActiveEnemies.Clear(); } }然后在场景卸载入口调用Registry.Clear()。单例也一样如果你的Singleton被设计成跨场景存活那它就不要引用任何场景里生成的对象。4.3 协程和异步的清理给对象一个“关闭钩子”对于协程我一般约定任何会长期运行的协程在OnDisable或OnDestroy都要StopAllCoroutines()。如果你的协程是多段独立控制的用Coroutine对象保存句柄单独停止。private Coroutine attackRoutine; private void StartAttackLoop() { attackRoutine StartCoroutine(AttackLoop()); } private void OnDisable() { if (attackRoutine ! null) { StopCoroutine(attackRoutine); attackRoutine null; } }异步调用如果是async void生命周期很难追踪我建议能避免就避免。如果是UniTask或标准Task把CancellationToken传进去对象销毁时取消任务防止状态机一直存在。4.4 动态资源的创建和销毁要配对动态创建的Mesh、Texture、RenderTexture、Material在不需要时必须显式调用Destroy。这类资产不走普通GC它们的生命周期由引擎管理你在C#侧把引用置null是没有用的。public class DynamicMeshExample : MonoBehaviour { private Mesh generatedMesh; private void Generate() { generatedMesh new Mesh(); // ...填充顶点数据 } private void OnDestroy() { if (generatedMesh ! null) { Destroy(generatedMesh); } } }对于用Resources.Load加载的资产调Resources.UnloadUnusedAssets()可以释放掉不再被引用的资源。要注意这个操作比较重不能每帧调用一般放在场景切换后或大界面关闭后。如果你用Addressables核心是掌握引用计数每一次LoadAssetAsync对应一次ReleaseHandle泄漏就等于资产泄漏。可以在Addressables组件的Inspector里看到每个资产的引用计数排查时留意那些“Load了但从未Release”的资源。4.5 在开发流程里给内存上一道闸一个项目只要上了规模靠个人自觉是防不住内存问题的。我见过的比较好的做法是在CI/本地工具里加一道简单检查用Memory Profiler的API写一个自动化测试重复执行某个关键路径比如打开关闭UI、切换场景20次。对比启动基线和操作后的Managed Heap Used如果涨幅超过阈值测试失败。把测试纳入日常提交检查至少保证核心路径不出现明显的内存增长。代码规范上我也建议加两条死规定凡是写了必须在附近能看到-凡是Resources.Load或Addressables的Load必须标明Release的位置。代码Review时可以重点盯这两条。5. 一个真实案例连续开关20次背包后游戏卡成了PPT5.1 现象描述与最初猜想之前做一个卡牌类项目测试反馈说背包界面连续打开关闭20次之后游戏明显变卡切换界面要等一两秒。查看内存图标的占用从一开始的200多MB涨到接近700MB而且关掉背包界面后完全不会降。当时团队第一反应是“背包物品的图标纹理泄漏了”。先检查了加载逻辑每一个图标都用了图集加载完也有释放看起来没问题。又怀疑是Resources.UnloadUnusedAssets()没调用但就算不调用也不该每个图标都累积一份。后来打开Memory Profiler抓了两张快照一张在启动后一张在开关背包20次之后Diff结果里出现了大量的BackpackItemView。数量正好是20的倍数——这就说明不是图集泄漏而是每次打开背包创建的UI对象没有被回收。5.2 顺着Referenced By揪出闭包在Memory Profiler里选中一个BackpackItemView实例展开Referenced By发现引用链指向一个按钮的onClick事件事件的所有者又是上一次打开背包时创建的临时闭包对象。代码大致是这样的private void OpenBackpack() { foreach (var item in items) { var view CreateItemView(item); view.button.onClick.AddListener(() OnItemClicked(item)); } }这段代码的坑在于() OnItemClicked(item)这个Lambda捕获了item和当前方法上下文匿名委托又挂在按钮上。背包虽然关了但按钮对象本身因为被某个静态管理器持有着连带这个闭包一起被保留闭包又引用着view于是整个背包UI对象就全被留下来了。每次打开背包都会创建一批新的委托和新的UI对象旧的又清理不掉叠加20次后自然变成PPT。5.3 修复只改了一行代码修复方案是把匿名委托改成实例方法并且在View销毁时移除监听public class BackpackItemView : MonoBehaviour { private ItemData boundItem; private Button button; private void Awake() { button GetComponentButton(); } public void Bind(ItemData item) { boundItem item; button.onClick.AddListener(OnClick); } private void OnClick() { // 使用boundItem处理逻辑 } private void OnDestroy() { button.onClick.RemoveListener(OnClick); } }然后创建处改成view.Bind(item);这样监听者和UI对象生命周期一致UI销毁时事件自然解除闭包也就没有机会存活了。改完之后重复开关背包20次内存曲线几乎是一条直线。这个案例给我们的教训是排查时不要一上来就猜纹理或资源泄漏先看托管对象数量有没有异常增加。很多时候你以为是大资源没释放实际只是一个小闭包把整个UI树一起带走了。6. 最后我在实际项目里总结的内存排查checklist最后分享一份我自己排查内存问题时遵循的checklist算是对前面内容的浓缩也是真正能落地到日常开发的习惯先看趋势再看细节切到Profiler的Memory模块记录Managed Heap Used曲线判断是“持续上升”还是“正常波动”。区分托管泄漏和原生滞留托管堆高就查C#引用链托管堆低但整体内存高查Asset和原生对象。快照对比是核心任何内存质疑都先用Memory Profiler抓baseline和after两张快照用Diff结果说话比翻代码快得多。看到重复对象时直接看引用路径数量增加但类型不变就是典型的“实例被集齐”。修复后一定复测同样的操作再来一遍确认曲线回落才算结束。预防比排查更值钱把事件订阅配对、动态资源释放当成代码规范在Review和CI里卡住。Unity内存问题之所以麻烦不是因为工具不强大而是因为它的机制跟“传统泄漏”不太一样。只要理解了对象引用的保活原理再配合快照对比这套排查方法大部分泄漏都是可以定位的。如果你下次再遇到莫名其妙的内存上涨别在代码里瞎猜了先抓两张快照再说。