☰
Unity GC 卡顿排查全指南:从原理到代码级优化,彻底告别掉帧
2026/9/24 21:44:55 网站建设 项目流程

做 Unity 性能优化这些年,我最大的感受不是“卡顿好难查”,而是“卡顿查出来之后更难修”。尤其是那种帧率图看上去像锯齿一样一上一下、一开某个功能就瞬间掉帧的情况,十次里有七八次都和 GC 有关。GC 之所以讨厌,是因为它不像 Draw Call、不像 Overdraw 那样有明确的指标,你甚至能在 Profiler 里看到一帧的耗时突然飙到 40ms、60ms,但就是不知道是谁捅的娄子。这篇是《Unity 卡顿·帧率保卫战》系列的第 4 篇,专门把 GC 这件事从头到尾拆一遍:它从哪来、怎么定位、怎么消除、哪些引擎选项能帮你兜底。适合正在做移动端优化、或者被线上版本突然掉帧折磨得睡不着觉的 Unity 开发者,照着做,至少能解决 80% 的 GC 卡顿问题。

1. GC 到底是什么,它为什么能卡掉你的帧

1.1 托管堆与垃圾回收的基本原理

GC 的全称是 Garbage Collection,也就是垃圾回收。Unity 的脚本层(C#)跑在 Mono 或 IL2CPP 上时,所有new出来的对象都会分配在托管堆(Managed Heap)里。比如你写List<int> list = new List<int>();,这个 List 对象本身、它的内部数组,都是在托管堆上分配的。堆是有限的,当里面的对象不再被引用,这块内存就成了垃圾,但 C# 不会像 C++ 那样立刻把它释放掉,而是等 GC 机制在某个时机把垃圾统一回收,腾出空间继续用。

Unity 的垃圾回收器是分代的,一般分为三代:第 0 代、第 1 代、第 2 代。新分配的对象先进第 0 代,存活过一轮回收就晋升到第 1 代,再存活就进第 2 代。GC 扫描的时候,优先收集第 0 代,因为第 0 代对象大多活不长,回收效率最高。只有第 0 代内存不够了,才会触发更高代际的回收。这个设计本身是好的,但问题在于,Unity 默认的 GC 是“全暂停”式的,也就是说一旦触发回收,整个游戏线程都会被冻结,直到回收完成。哪怕只是回收第 0 代,也会在某一帧里造成一个明显的耗时尖峰。

用一句大白话说:你辛辛苦把一帧的成本压到 8ms,结果 GC 在某帧里突然跳出来,用 30ms 打扫卫生,那这帧就铁定卡了。而且 GC 调度的时机不完全由你掌控,它可能出现在任何一次分配导致堆容量不足的时候。这就是 GC 卡顿最恶心的地方——它不是每帧都出现,而是“不定期发作”,排查起来特别像灵异事件。

1.2 为什么 GC 会引发掉帧:Stop The World

移动端游戏的 GC 卡顿,本质上就是 Stop The World 造成的。GC 回收时需要扫描对象引用关系、标记存活对象、清理死亡对象,这个过程为了保证一致性,必须暂停业务逻辑线程。Unity 是主线程驱动的,主线程一暂停,整个渲染管线、物理、动画、UI 全都得等着。暂停时间取决于堆里对象的数量和存活对象复杂度,可能只有 1ms,也可能是几十毫秒。

这里有个容易忽略的点:GC 暂停时间短不等于不卡。帧率目标如果在 60 FPS,那每帧预算只有 16.7ms,一个 5ms 的 GC 尖峰就占掉三分之一预算,叠加这一帧本来就有的渲染耗时,很容易突破 33ms 甚至更糟。所以在移动端,尤其是低端 Android 机上,GC 卡顿比在 iOS 上更明显,因为 CPU 频率低、内存带宽小、同一套 GC 逻辑跑得更慢。

更麻烦的是,GC 的回收和分配是联动的。如果你每帧都在堆上分配大量临时对象,堆容量会持续增长,GC 触发频率越来越高,代际也越来越深。等到第 2 代回收触发,那可不是几十毫秒能解决的,低端机上瞬间飙到几百毫秒都有可能。我见过一个项目,上线后一局游戏十几分钟,越到后面越卡,最后查出来就是每帧都在 new 各种字符串和浮点数组,把托管堆养到了几百 MB,隔一会儿就触发一次全量回收。

1.3 从 Unity Profiler 里认出 GC 卡顿

识别 GC 卡顿最直接的方式是看 Profiler 的 CPU 数据。打开 Profiler 连接到真机,抓一段游戏中的帧数据,按耗时排序,如果发现某个 GC 相关方法(在 Mono 下常见的是GarbageCollect、GC.Collect,在 IL2CPP 下可能叫il2cpp::gc::GarbageCollector::Collect)出现在金标题里,那基本就是 GC 卡顿没跑了。

不过光看到GC.Collect还不够,你还要判断这次回收是哪种代际。Unity Profiler 的 CPU Timeline 视图里,可以展开 GC 调用的子节点,看到 collect 的代数信息。另外,Profiler 的 Memory 模块里也有 Managed Heap 的 Used 和 Reserved 曲线,如果 Used 曲线呈阶梯状持续上升,说明分配速度大于回收速度。这里我给一个实战经验:在真机 Profiler 里看 GC 卡顿,一定不要只抓 10 秒钟 30 帧的数据,太短抓不到问题,建议抓 60 秒以上,把曲线整体截下来,再回头分析哪些帧 GC 压力大、和哪些玩法事件相关。这样定位范围会小很多。

2. 定位 GC 压力:不用瞎猜,先拿数据说话

2.1 Profiler 的真机抓取姿势(Android/iOS)

很多新手犯的错是只在编辑器里看 Profiler。编辑器里的 GC 行为和真机差异其实非常大,因为 Editor 本身会吞掉一部分分配,CPU 频率、内存带宽也和手机完全不同。正确的做法是接真机。Android 上,推荐用 Unity Profiler 通过 ADB Over WiFi 连接,或者直接把 Development Build 包打出来,用 Profiler 的Attach to Player。iOS 上则需要插 USB,在 Xcode 里跑起来,再用 Profiler 连接。

连接的时候记得勾选 Deep Profile,不然很多方法内部的小分配你是看不出来的。但 Deep Profile 会显著拖慢帧率,所以抓 GC 和抓常规性能数据要分开抓,不要一边开 Deep Profile 一边看帧率曲线。我习惯的做法是:第一次不开 Deep Profile,先确认帧率尖峰出现的时间和 GC 调用是否吻合;第二次开 Deep Profile,专门抓那几个尖峰帧,展开完整调用树,找出分配源头。

2.2 理解 GC Allocation、Reserved、Used

Profiler 的 Memory 模块里有几个概念很多人分不清,这里统一讲清楚。Managed Heap Used 是当前托管堆里实际被对象占用的内存,Reserved 是堆从操作系统申请到的总内存。Reserved 可能远大于 Used,因为堆不会频繁还给系统,而是留着复用。当 Used 逼近 Reserved 时,GC 会尝试扩展 Reserved 大小,也就是向操作系统申请更多内存,这个过程本身也会卡一下。

GC Allocation 表示单位时间内托管堆上的分配总量。如果你看到某帧的 GC Allocation 突然涨到几 MB,说明这一帧里有大量临时对象被创建。用 Profiler 的 Hierarchy 视图按 GC Alloc 排序,就能定位到具体是哪个函数创建了这些对象。这里有个细节:GC.Alloc这一列只有在开启 Deep Profile 时才有准确数据,所以前面说的两次抓取流程是必要的。

2.3 通过 Memory Profiler 排查托管堆问题

除了内置 Profiler,我强烈建议给项目接入 Unity 官方的 Memory Profiler 包。它在 Package Manager 里搜com.unity.memoryprofiler就能装。Memory Profiler 最大的价值是能抓一个完整的堆快照(Snapshot),然后在专业视图里看到所有托管对象的新旧对比,还能看出哪些对象是“泄漏型”的——比如从未被释放的、持续增长的。

实操技巧:在游戏里操作一个典型流程后,连续抓两份快照,用 Memory Profiler 的 Diff 模式对比,就能快速看到两次操作之间新增了哪些对象、谁在持有它们。常见的字符串缓存、事件订阅导致的委托链增长、静态集合里只增不减的对象,在这个 Diff 视图下都无所遁形。注意抓快照的瞬间也会触发 GC,所以想把快照当证据,就别在抓取前干那种会改变堆状态的操作。

3. 代码层优化:把“隐式分配”全部揪出来

3.1 字符串、装箱、LINQ 与闭包陷阱

代码层的 GC 优化,核心就一句话:消灭每一帧里“看不见”的堆分配。最常见的三大元凶是字符串拼接、装箱和 LINQ/闭包。

字符串拼接是重灾区。string a = b + c + d;在 C# 里会生成多个中间字符串对象,每个都是独立分配。哪怕你只在 UI 上显示一个 “Score: 100”,每秒更新一次,如果每帧都在拼字符串,那就是每帧都在分配垃圾。优化方案很简单:能用StringBuilder的地方用StringBuilder,并且把 StringBuilder 实例缓存在字段里,用完了调Clear()继续用,不要频繁new。另一种更狠的做法是预拼接所有可能的字符串,运行时直接查表,适合状态枚举、固定文案这类场景。

装箱就是值类型被隐式转换成object。比如Debug.Log(score),如果 score 是 int,这里就会发生一次装箱,在堆上生成一个对象。你在代码里写得爽,GC 在后面哭。排查时重点看box关键字,在 Profiler 调用树里它是明确显示的。消灭装箱的办法是重载匹配的方法,或者用string.Format的重载、用值类型的.ToString()显式转换、用泛型约束避免object类型参数。还有一个隐藏点:枚举类型经常拿来和字符串拼接,或者作为字典 key,也容易产生装箱,建议改用int或HashCode。

LINQ 就不用说了,Where、Select、OrderBy这些扩展方法背后全是迭代器对象和闭包。在 UI 逻辑里偶尔用一次问题不大,但在 Update 里每帧跑 LINQ 就是灾难。闭包也类似,lambda 表达式只要捕获了外部变量,就会在堆上生成闭包对象。优化方式是把循环体改成普通 for/foreach,把临时变量提出来,或者用struct实现IComparer<T>传进去,避免产生 GC 分配。这些优化虽然琐碎,但在一个大型项目里,代码量上去之后,净收益非常可观。

3.2 常用 API 的隐藏分配:物理、音效、UI

除了 C# 自身的语法陷阱,Unity 引擎 API 也藏着一堆隐式分配。最典型的是Physics.Raycast返回的RaycastHit数组?不是,单个RaycastHit是 struct 不会分配,但Physics.RaycastAll返回的就是RaycastHit[]数组,每次调用都会分配。解决方案是使用Physics.RaycastNonAlloc系列接口,传入预分配的数组,然后根据返回值判断命中数量。音效方面,AudioSource.PlayOneShot(Clip)如果你在 Update 里反复调用,也会导致某些内部对象分配,务必将 Clip 引用缓存,而不是每次都从 Resources 加载。

UI 是最容易被忽略的重灾区。UGUI 的Text.text每次改值都会触发文本网格重建,如果字符串本身是拼接出来的,GC 压力和重建开销会叠加。LayoutGroup的自动布局、ContentSizeFitter每帧计算也会产生分配。所以 UI 优化的原则是:尽量只在数据变化时更新 UI,不要在 Update 里轮询刷新;用对象池管理面板、列表项;用TMP_Text而不是老的Text,因为 TMP 的内部重建性能远好于 UGUI Text。

3.3 对象池与缓存策略实战

对象池几乎是应对 GC 的标配手段。它解决的痛点是:频繁创建和销毁对象会让堆产生大量碎片,并且每次new都会产生分配。对象池的思路是把不再用的对象回收到池里,需要时再取出复用。Unity 的ObjectPool<T>类在较新版本里已经很成熟,不用自己写轮子。但要注意几个坑:出池时一定要把对象身上的状态重置,尤其是 Transform、Rigidbody 的物理状态;回池时要禁用 GameObject、清空子物体、取消事件监听;池的初始容量要预估好,避免运行中反复扩容。

除了 GameObject 对象池,数据结构对象的复用也值得重视。比如你有个List<Vector3>用来存放路径点,每一帧new一个再填充,这显然不合理。正确做法是把 List 声明成字段,调用list.Clear()后重新填充。字典、队列、栈同理。内存分配少一次是一次,这个积累效应在长时间运行的游戏里会被放大十倍百倍。

3.4 协程、事件、委托中的分配优化

协程是另一个容易藏分配的地方。StartCoroutine(MyCoroutine())会创建一个协程对象,IEnumerator状态机内部还会分配对象。如果你在一个频繁触发的地方(比如点击按钮、击中目标)启动协程,那每触发一次就会多一堆堆分配。优化思路:能用 Update/固定轮询替代的就别用协程;必须用协程时,尽量缓存 IEnumerator 实例而不是每次CreateCoroutine新建。不过要注意当你停止协程再重新启动时,缓存实例的状态可能没重置,需要加个标志位处理。

事件和委托同样有坑。+=进行事件订阅是堆分配,-=取消订阅也是。在怪物死亡、玩家拾取物品这类高频逻辑里,如果频繁加退事件,垃圾量会很大。更隐蔽的是,Unity 的某些接口如Button.onClick.AddListener(() => {})每次传 lambda 都会分配一个委托对象,如果这个按钮在运行时经常被创建销毁,垃圾就不断产生。建议办法是:遵循对称原则,在 AddListener 的地方一定要对应的 RemoveListener 或 RemoveAllListeners;对复用型对象,把监听方法包装成带缓存委托的字段,避免重复分配;能用UnityEvent的序列化绑定就尽量用编辑器绑定,不要在运行时动态挂。

4. 引擎层选型与进阶手段

4.1 Incremental GC 与 Burst/ECS 的取舍

Unity 从若干版本开始支持 Incremental GC(增量式垃圾回收)。它的原理是把原本一次完成的回收任务切分成多个小块,分散到多帧执行,从而避免单帧的长时间暂停。听起来很美好,但它不是银弹:首先,增量 GC 会把本应一帧完成的工作摊薄到后续帧里,所以整体 GC 次数可能变多,单帧方差变小,但总 CPU 开销可能增加。其次,对于一帧内分配极其巨大的项目,增量 GC 仍然会卡,因为回收能力跟不上分配速度。

所以在移动端,我的建议是先做代码层优化,把分配压下去,再开增量 GC 兜底,不要反过来。Burst 和 ECS 就比较激进了:Burst 编译器把 C# 代码编译成高度优化的原生代码,配合 DOTS 的 ECS 架构,可以把大量逻辑搬到非托管内存中,完全绕开托管堆,从根源上消灭 GC。但 DOTS 有学习成本,工程改造量也大,不是说切就能切。我的判断是:如果你正在做一个新项目,且目标就是低端机流畅运行,那 DOTS 值得投入;如果是存量项目,优先做代码层优化、对象池、增量 GC 这三板斧。

4.2 自定义堆管理:allocator 与 NativeArray

在要保留 Mono/IL2CPP 代码架构的前提下,我们还有半托管路线:把关键数据放到 NativeArray、NativeList 这类 Unity 的 Native 容器里。它们分配在非托管内存,不受 GC 控制,用完你需要手动Dispose()。很多团队在项目里把高频数据传输、点云、路径数据全部改用 NativeArray,这样托管堆上的活动对象数量大幅下降,GC 触发频率自然就降下来了。

使用 NativeArray 要小心生命周期管理。如果你在一个需要长期存活的地方分配 NativeArray,又在另一个地方使用,跨越了多个帧,很容易踩中Dispose在错误时机调用的坑。实战里我建议用NativeArray<T>搭配using语句,或者在类的OnDestroy里统一释放。同时要注意,NativeArray 默认分配的是堆内存,频繁创建销毁也会有非托管内存的碎片和分配开销,所以同样要有对象池思路,缓存一批 NativeArray 按需取用。

4.3 什么时候该用 IL2CPP、什么时候用 Mono

这个问题经常有人问。IL2CPP 相比 Mono,最大的优势是 AOT 编译后运行效率更高、代码更安全、兼容性一致性更好,而且 IL2CPP 的 GC 实现比 Mono 在某些场景下更稳定。但是名字里的 “2CPP” 不代表没有 GC,它只是把 IL 转成 C++;GC 还是在的,只是实现方式换成 IL2CPP 自带的 Boehm GC 增强实现。所以不要以为切了 IL2CPP GC 就消失了。

从优化角度来看,发布移动端游戏都应该用 IL2CPP。AOT 之后 JIT 版本的运行时开销减少,一些隐式装箱优化反而更友好。真机测试时,如果发现同样的代码在真机上 GC 尖峰比编辑器高一大截,很可能就是 IL2CPP 的 GC 实现更积极。遇到这种情况,除了代码排查,也可以尝试开启 Player Settings 里的 “Low Memory Mode” 之类的选项,有些平台的 IL2CPP 会启用更保守的内存策略。

5. 常见问题与排查技巧实录

5.1 典型问题速查表

症状典型原因快速验证方法修复方向
帧率每隔几十秒骤降一次频繁大对象分配,触发第 1/2 代回收Profiler 里抓 GC 调用节点,确认代际用对象池、缓存、避免大数组频繁创建
Update 里每次调用都分配小对象字符串、装箱、LINQ、闭包开 Deep Profile 按 GC Alloc 排序改成 StringBuilder、for 循环、预分配
某 UI 界面打开/关闭卡顿文本重建、LayoutGroup 重排、动态对象创建Memory Profiler 前后对比快照UI 对象池、Layout 改手动触发、文本缓存
长时间运行后越来越卡静态集合持有对象、事件订阅泄漏抓两次快照,按对象计数变化排序清理静态引用、RemoveListener、定时巡检
低端机卡成 PPT,高端机没事分配量太大,GC 暂停时间被放大在低端真机上抓 Profiler严格限制每帧分配量,使用 NativeArray
协程启动频繁导致卡顿每次 StartCoroutine 产生协程对象和状态机Profiler 看 IEnumerator 分配缓存协程实例或改用 Update 状态机

5.2 我踩过的几个坑

第一个坑是盲目给 UI 列表加对象池。一开始我把整个竖向列表的所有行全部池化,结果因为列表项内部的文本尺寸不同、布局不同,回池再出池时产生了意想不到的 Layout 重排,反而比直接创建更卡。后来我改成只池化行模板,内容变化时复用同一行,不再整页销毁重建,问题就解决了。从这里学到的经验是:对象池不一定适合所有对象,确认 “创建销毁成本高于复用重置成本” 再池化。

第二个坑是协程缓存。我曾经为了消除协程分配,写了个协程启动管理器,把 IEnumerator 实例缓存起来重复 Start。结果忘记在StopCoroutine后重置其中的循环变量,导致同一个协程第二次执行时直接从最后状态开始,角色行为直接错乱。后来我在缓存前强制给 IEnumerator 包一层包装器,让每次启动都用一个新实例,但这个新实例内部复用一个真正的对象才不会产生分配,最终算是改对了。

第三个坑和 Profiler 有关:我一度在图里看到 GC.Alloc 很高,就拼命调代码,结果调完 GC.Alloc 确实降了,但帧率没有任何改善。后来才发现,原来那一帧的 GC.Alloc 是 UI 的图集上传开了一个新 RenderTexture 引起的,真正的掉帧不是 GC 而是 GPU 上传。这就提醒我们,不要看见一个指标就埋头调,要结合帧耗时、渲染耗时、线程耗时一起看。

5.3 一套可落地的日常优化清单

检查项具体操作验收标准
分配量控制不开 Deep Profile 抓一次真机 Profiler,记录 GC Allocation 峰值移动端峰值 < 1 MB/帧(中大型游戏)
主循环无 Linq/闭包搜索 Update/FixedUpdate/LateUpdate 内的 LINQ 和 lambda全部改为 for/foreach 或缓存委托
UI 刷新策略用脏标记判断数据是否变化,再刷新 TMP 文本无 Update 里持续改文本的代码
对象池覆盖高频对象子弹、敌人、UI 弹窗、受击飘字运行中对象创建数量维持波动小
协程使用规范化梳理启动协程的点位,能不用则不用高频触发点无 StartCoroutine
事件生命周期所有 AddListener 都有对应 RemoveListener反复进出场景后内存曲线平稳
NativeContainer 生命周期检索 NativeArray、NativeList 的分配和 Dispose长时间运行无 Non-Allocated 泄漏

在项目里推行这套 GC 优化流程时,我的经验是不要一上来想着“彻底消灭分配”,这不现实也没必要。更合理的策略是:先把峰值降下来,让 GC 尖峰不出现在关键帧,然后把 GC 触发频率控制在让运行期堆大小稳定在一个合理范围内。只要这两点做到,玩家基本就感受不到 GC 的存在了。我用这套打法优化过好几个项目,从最初随便一帧分配 3MB、每秒卡顿 5 次,到后面峰值控制在 200KB 以内、连续作战一局不掉一点帧,中间靠的就是一句一句代码抠、一次一次抓 Profile 比对。最后再分享一个小技巧:每次优化完,记得在项目的 CI 流程里加一个 GC Allocation 上限的判断,超过阈值就标红,这样性能回归才能在发版之前被拦住,而不是等上线了再被玩家骂。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询