这个问题我被人问过无数次,尤其是在项目优化阶段和上线前压测的时候。很多人拿着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 List<Enemy> 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里调用Destroy;Resources卸载则需要配合Resources.UnloadUnusedAssets()。
下面用一张表把这几个类型区分开:
| 泄漏类型 | 内存区域 | 根本原因 | 典型表现 | 主要定位工具 |
|---|---|---|---|---|
| 托管对象滞留 | Managed Heap | 事件/静态/缓存持有未释放引用 | 堆持续上升,快照里出现重复实例 | Memory Profiler、GC Alloc |
| 原生资产滞留 | Native Memory | 资产被C#对象引用且未卸载 | 整体内存高,托管堆却不高 | Memory Profiler、设备原生工具 |
| 协程/异步状态机 | Managed Heap | 未终止的操作持有实例 | 关闭对象后内存不回落 | 代码审查、断点确认 |
| 堆不收缩/碎片化 | Managed Heap | GC保留已扩大的内存 | 内存高点后不下降 | 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 Profiler,iOS上可以在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 List<Enemy> ActiveEnemies = new List<Enemy>(); 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对应一次Release,Handle泄漏就等于资产泄漏。可以在Addressables组件的Inspector里看到每个资产的引用计数,排查时留意那些“Load了但从未Release”的资源。
4.5 在开发流程里给内存上一道闸
一个项目只要上了规模,靠个人自觉是防不住内存问题的。我见过的比较好的做法是在CI/本地工具里加一道简单检查:
- 用Memory Profiler的API写一个自动化测试,重复执行某个关键路径(比如打开关闭UI、切换场景)20次。
- 对比启动基线和操作后的Managed Heap Used,如果涨幅超过阈值,测试失败。
- 把测试纳入日常提交检查,至少保证核心路径不出现明显的内存增长。
代码规范上我也建议加两条死规定:凡是写了+=,必须在附近能看到-=;凡是Resources.Load或Addressables的Load,必须标明Release的位置。代码Review时可以重点盯这两条。
5. 一个真实案例:连续开关20次背包后,游戏卡成了PPT
5.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 = GetComponent<Button>(); } 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内存问题之所以麻烦,不是因为工具不强大,而是因为它的机制跟“传统泄漏”不太一样。只要理解了对象引用的保活原理,再配合快照对比这套排查方法,大部分泄漏都是可以定位的。如果你下次再遇到莫名其妙的内存上涨,别在代码里瞎猜了,先抓两张快照再说。