Unity GC卡顿深度解析:原因、排查与优化实战
做 Unity 开发这几年我调试过不少“诡异”的卡顿场景里物体不多、Draw Call 压得也到位、CPU 占用看着根本不吓人可一跑起来就是每隔几秒钟“哆嗦”一下帧率曲线图上冒出一根根尖锐的刺。后来翻来覆去地查罪魁祸首往往就两个字——GC。GC 在 Unity 里全称 Garbage Collection也就是垃圾回收。它不是某一个 Bug不是某行代码写错了而是整个托管堆在工作时“顺手”做的清扫动作。这篇文章是《Unity 卡顿·帧率保卫战》系列的第 4 篇这一篇我打算专门把“GC 导致卡顿”这顶大帽子摘下来揉碎了讲GC 为什么能和帧率过不去、怎么用工具把偷偷产生垃圾的代码揪出来、日常写代码时哪些习惯在埋雷以及最后我在真实项目里落地过的一些方案。读完这篇文章不管你做的是 2D 休闲还是 3D 动作只要还在用 C# 写游戏逻辑应该都能立刻上手照着查一遍自己的工程。内容我会尽量往实战靠不整虚的。为了把来龙去脉说清楚先得从 GC 的工作机制讲起。1. GC 为什么会成为帧率刺客托管堆的工作机制1.1 标记清扫这套动作代价相当大Unity 的 C# 脚本跑在 Mono 或 IL2CPP 运行时上它们默认使用的是 Boehm 垃圾回收算法的变体。这个算法说穿了并不复杂程序运行过程中不断产生对象存放在一片叫“托管堆”的内存区域里GC 每隔一段时间启动一次先找出所有还在被引用的对象把它们“标记”为存活再把这之外的对象当成垃圾清扫掉最后还可能需要把存活对象重新搬动整理释放出连续空间。这个过程听着挺有秩序可它有个致命问题GC 运行的瞬间程序里所有托管线程都要先停下来等它干完活。这个停顿在 Unity 主线程上就是一次实实在在的帧时间暴涨。再加上 Boehm GC 是非分代、非压缩式收集器它一旦触发往往会扫描和检查整片托管堆上的大量对象停顿时间跟堆里对象数量和活跃程度直接挂钩。堆里的数据越乱、对象越多这一次停顿就越夸张。打个比方你把仓库货架上的货物都编了号平时每放一件新货就往架子上加等到货架满了管理员得把所有货物全部翻出来核对一遍再决定哪些能扔、哪些要挪走这个过程里所有进出仓库的人都得在门口等着。玩游戏时我遇到 GC 造成的那一下“卡顿”本质上就是管理员正干活呢仓库门口堵死了。1.2 分配与收集是两回事别搞混很多人把“GC Alloc”和“GC Collect”画等号看到 Profiler 里 GC Alloc 有数字就慌。实际上每一帧分配几 KB 托管内存本身未必会立刻造成卡顿真正给帧率捅刀子的是分配量把 GC 收集器“逼”出来了。Unity 的内存分配有“批次”的概念每次调用new、字符串拼接、数组扩容等操作都会往托管堆上划一块内存。当托管堆剩余空间不够下一次分配时GC 就开始进行收集。也就是说如果代码每帧都分配 10 KB堆还能撑几个小时帧率可能暂时没事一旦某一帧碰巧需要大块空间堆又说“我不够了”GC 立刻启动这一帧就完蛋了。从 Profiler 里面看就是平时 GC 曲线安静如鸡某帧突然跳出一次GC.Collect同时伴随一个明显的 CPU 尖峰。这里还有个隐藏很深的坑托管堆一旦涨上去基本不会自动缩回来。GC 清理完垃圾之后原来的空间仍然归程序所有留给后续分配复用而不是直接归还系统。移动端上这往往表现为内存越吃越多每次 GC 停顿时堆也越查越慢如果项目里还反复创建大数组、大字符串甚至可能在堆碎片化严重的情况下明明剩余空间还有几十 MB却只能向系统再申请新的托管堆段让 GC 成本雪上加霜。1.3 增量式 GC 只是镇痛剂不是救命药从 Unity 2019.1 开始Player Settings 里提供了一个选项叫“Incremental Garbage Collection”增量式垃圾回收。它的思路是把一次大停顿拆成多个小片段的增量回收每帧只做一点点不让主线程一次性堵死。这个方案对某些“每隔几秒卡一下”的项目确实有明显的缓解作用但代价是每一小次回收都会带来额外的 CPU 开销且可能让 GC 总耗时变长。在我实测过的移动端项目里增量 GC 适合那种本就分配不多、只是偶尔冒一次大头的情况。如果你的代码本身每帧都疯狂制造垃圾增量 GC 只会让每一帧的平均耗时不降反升帧率变得更加不稳定。所以它只是缓解症状真正的核心还得回到控制分配这块源头上。2. 用 Profile 工具把看不见的 GC 揪出来2.1 先学会看 Profiler 里的三层数据排查 GC 问题第一件事不是翻代码而是打开 Unity Profiler把窗口切到 CPU Usage 模块。这里有三组数据很容易把人绕晕GC Alloc当前帧托管堆分配字节数、GC Used累积使用量、GC Collect回收时间。我们日常关注最多的是第一条按帧查看时它直接显示在 Hierarchy 视图中一眼就能看到哪一帧分配了多少。有一个经验标准是在 Profiler 里点击某一帧看耗时最长的那个函数同时切换到底部的“Hierarchy”视图按“GC Alloc”排序经常能直接定位到分配大头。操作具体是这样播放工程Profiler 保持录制手动触发一次容易卡顿的场景或操作暂停播放选中那个 CPU 时间明显暴涨的帧在 CPU Usage 的 Hierarchy 列表里点击GC Alloc列的排序箭头让分配量从高到低排列逐个展开排在最前面的项追到你的业务代码方法上。2.2 Deep Profile 是把双刃剑要是列表里只能看到MonoBehaviour.Update这种大的入口却定位不到具体哪几行代码在分配这时候就得靠编辑器菜单里的File - Build Settings - Player Settings - Configuration - Scripting Define Symbols之外的一个开关Profile窗口右上角或者直接启用Jobs Burst Open Inspector里看到那些带钩选项。更常用的做法是勾选 Profiler 窗口上的Deep Profile按钮。开启后Profiler 会像拆毛线团那样把所有函数的调用栈完整还原代码里的每一行调用都会被记录。但是 Deep Profile 的开销极大实测中游戏运行速度可能掉到正常情况的三分之一甚至更低本身就会改变性能分布而且生成的日志量非常恐怖。我一般只在“完全找不到分配来源”的时候开它而且只测一小段稳定流程拿到数据马上关掉。在正式构建版本里不要开启 Deep Profile不然玩家设备上跑出来的日志多半跟真实体验毫无参考价值。2.3 真机上的 GC 采样编辑器数据不可全信编辑器里的 Profiler 数据对定位逻辑很有帮助但不能代表最终真机表现。原因有两个编辑器本身用 Mono而很多移动端构建使用 IL2CPP托管堆分配行为和 GC 触发点会略有不同编辑器还有大量 Editor 自身的 GUI 和辅助逻辑在跑GC Alloc 基线会比真机高出不少。有条件的话最好用 Android 或 iOS 真机连上 Profiler 再运行一遍。Android 设备通过 ADB 连接iOS 需要 Mac XcodeUnity 官方文档对这两种连接方式都有详细说明。真机上看到的GC Alloc和GC Collect数据才是你优化后真正要拿去对标的数字。另外提一句搜索“com.android.systemui heaptaskdaemon 影响 gc”会看到不少用户反馈 Android 系统进程占用内存导致 GC 频繁的案例这类现象属于设备系统层面的干扰游戏内再怎么优化也未必能完全消除遇到这种问题要懂得区分责任边界别把所有锅都甩给自己项目。2.4 Memory Profiler 包帮你做“内存体检”除了 CPU ProfilerUnity 还有个官方包叫 Memory Profiler建议做 GC 优化前先装一个。它能对托管堆做快照分析查看所有托管对象的数量和类型还能对比两帧之间堆的增长和回收情况。它的价值在于很多 GC 问题的根源不是某个方法分配了“太多”而是某种对象类型整体数量失控比如场景里挂了几百个带闭包的 lambda、字符串数组在热更流程里不断创建这些在 CPU Profiler 里往往一闪而过但 Memory Profiler 的快照对比能一眼抓出来。3. 日常代码里藏得最深的分配陷阱3.1 字符串拼接最常见的“隐形分配器”写 UI 的时候很多人会图省事在 Update 里直接拼字符串更新文本void Update() { scoreText.text Score: score; }这句看着整洁实际上每帧都在创建两个新的字符串Score: 拼接产生的临时字符串以及最终赋给text的字符串如果score是 int还会带上一次ToString()的分配。GC 堆里全是这种一次性字符串用不了多久收集器就被逼着干一次活。改良第一步是缓存字符串拼接结果只在数值变化时才重新生成private int cachedScore -1; private string cachedText ; void Update() { if (cachedScore ! score) { cachedScore score; cachedText Score: score; scoreText.text cachedText; } }如果确实需要频繁拼接大量片段可以考虑StringBuilder但注意StringBuilder.ToString()本身也会产生字符串分配它更多是减少拼接中间过程的临时对象而不是把最终分配清为零。UI 文本更新的终极手段是降低刷新频率或者干脆把数字拆成位图字体用一组图片来显示分数彻底绕开字符串不过那是另一个级别的优化不在此展开。3.2 LINQ 与匿名类型写得爽堆上哭在做战斗判定、列表筛选的时候LINQ 的诱惑真的很大var aliveEnemies enemies.Where(e e.IsAlive).ToList();这一行代码的分配清单相当感人迭代器状态机、lambda 表达式的闭包、ListEnemy扩容、甚至Where返回的IEnumerable对象全都在托管堆上落位。做数据分析的 PC 程序随便用但在目标帧率 60 FPS 的游戏主循环里这种写法就是让 GC 卡顿不断的光荣使者。替代方案是回归基础写法int aliveCount 0; for (int i 0; i enemies.Count; i) { if (enemies[i].IsAlive) { aliveEnemies[aliveCount] enemies[i]; } }用一个预先分配好的数组或List作为缓冲区避免每帧new。如果数据量不大连for里临时变量产生的分配几乎约等于零。很多团队把“核心循环禁止 LINQ”写进代码规范不是小题大做是真被 GC 卡怕过。3.3 装箱悄无声息的类型橡皮筋箱装在 C# 里是个老话题但 Unity 开发里依旧常见。只要把值类型int、float、struct被当成object、接口或string.Format的参数传递就会发生装箱系统会在堆上额外生成一个对象来包住这个值。最典型的表现string message string.Format(HP: {0}, MP: {1}, hp, mp);string.Format的参数是object[]传值类型必然装箱。用字符串插值$HP: {hp}同样不能避免编译器在底层还是会调用会装箱的重载。正确做法是显式调用ToString()string message HP: hp.ToString() , MP: mp.ToString();这只是少了装箱开销字符串本身还是会分配。要是对性能极其敏感最好直接避免这种组合消息的写法改用分段显示或预格式化的文本。3.4 委托与闭包加载时的“吸血鬼”写 UI 时给按钮挂监听很常见button.onClick.AddListener(() OnBuyItem(itemId));方法本身没什么问题但如果itemId是循环变量编译器为了捕获这个变量会生成一个闭包对象并把整个 lambda 放进这个新对象的方法里。每次执行这段代码都会在堆上多一个闭包消耗不多但要是在循环里注册一百个按钮一百个闭包就等着 GC 回收了。更糟糕的是如果委托会被反复添加和移除GC 压力还会叠加。习惯做法避免在循环里用捕获外部变量的 lambda把需要捕获的数据提前封装成类实例或者写成独立的私有方法。如果只是不想额外写类也可以利用一个“状态对象”同时挂在多个按钮上。3.5 协程与 WaitForSeconds一帧销毁一个对象协程用起来很舒服但很多人没意识到yield return new WaitForSeconds(...)在每次迭代时都会创建一个新的WaitForSeconds对象。一个循环里每帧 yield 一次的新对象最后全成了垃圾private readonly WaitForSeconds interval new WaitForSeconds(1f); IEnumerator Loop() { while (true) { // do something yield return interval; } }把WaitForSeconds、WaitForEndOfFrame这类常用的等待对象缓存成静态字段是协程优化里最简单也最见效的一招。可以建一个静态工具类专门缓存各种常用等待时长对最大堆的压力会小很多。3.6 UI 系统隐藏的分配文字和布局重建分到建模版块细看的话UI 可能是许多游戏 GC 的最大来源之一。uGUI 的Text组件每次改text都会触发网格重建网格数据会存进托管数组频繁改文本等于高频制造托管数组垃圾。ScrollRect 内的动态内容更新、LayoutGroup 的Rebuild、Toggle 组件的切换也都伴随分配。排查这种问题可以在 Profiler 里搜索Canvas相关条目对应到具体是哪个 UI 组件在刷新。4. 实战优化从“减少分配”到“消除分配”4.1 对象池只借不还天下太平游戏中子弹、敌人、飘字、粒子特效这类高频创建销毁的对象不应该用new和Destroy反复折腾而应该用对象池管理。核心逻辑很简单需要时从池里取用完塞回去不真正释放避免反复分配和 GC。对象池实现时真正要处理好的其实是两点一是初始预热游戏加载时就把固定数量的对象提前创建好避免战斗中大量生成造成峰值卡顿二是扩容策略池不够用时要尽量一次多扩几个而不是每次只补一个。我习惯用数组或Stack来存空闲对象同时保留所有活跃对象的引用方便统计和批量回收这套结构在战斗游戏中已经稳定跑过几个项目。4.2 数组与结构体按值传参靠ref返回用SpanC# 里class是引用类型struct是值类型。同样一段数据用class存储时每次实例化都在堆上传个对象用struct存储时它可以内嵌进数组整块分配一次后续访问不再产生额外堆对象。这就是为什么 Unity DOTS 的 ECS 方案极力推荐把一个个实体数据拆成 struct 数组让 CPU 遍历时直接划过一段连续内存分配开销也大幅下降。在传统组件式写法里如果我们定义一个struct GameData把它放在GameData[]数组里那么通过ref传参给处理函数时不会复制整个结构体也不会产生新对象GC 压力明显小于“每帧 new 一个类出来再填数据”。但要注意一点结构体很大或频繁装箱时反而可能因为栈复制产生额外开销所以也不是什么地方都适合结构体化要先实测再做决定。4.3 避免每帧生成临时对象缓存是万能钥匙把高频调用的临时计算结果缓存起来是减少 GC 最直接的方法。除了前面提到的字符串和 WaitForSeconds常见的还有数学计算里new Vector3虽然不产生堆分配但某些 API 需要返回数组时比如Physics.RaycastAll或Camera.GetViewportPoints每次都传入一个新数组就会产生分配。Unity 在近几个版本中为很多物理查询API添加了ListT重载配合预分配好的列表可以做到零分配查询。老项目里如果有RaycastAll(new Ray(...))这种写法建议优先替换掉。Unity 官方也提供了一些零分配的 API 替代方案比如用NonAlloc后缀的物理查询方法用Input.GetTouch而非Input.touches用GetComponentInChildrenT(false)避免额外数组。这些API名字里有“NonAlloc”一看就是给 GC 优化准备的。4.4 增量 GC 与大对象堆的取舍增量 GC 已经在前面讲过我这里想补一个关于大对象堆LOH的关键细节。Unity 的托管堆对特别大的对象通常是 80 KB 以上不同版本阈值略有差异使用独立的大对象堆而 Boehm GC 对 LOH 的回收和整理其实做不到很勤快。这样做的后果是高频创建大型字节数组、大规模 List、超大字符串往往会导致内存碎片化GC 的耗时也会比其他对象更不可控。加载场景的大图集、网络协议里的分包缓冲、音频流数据都是 LOH 的常客。处理思路是“合并大块复用”把分散的字节缓冲池化需要传输大量数据时尽量用可复用的大型缓冲区而不是每次发消息都创建一个新byte[]。很多网络库的底层 BufferPool 就是为了这个而存在的写上层逻辑时不要随手new byte[1024]优先去池里借。4.5 使用 Profiler 验证优化不能靠感觉在做完上面这些改动之后不要满足于“好像没那么卡了”。严谨的流程是先在 Player 设置里关闭增量 GC开启常规回收模式跑一遍稳定流程录下 GC Alloc 基线和 GC Collect 频次优化后重新跑同一遍流程对比数据。前后差异至少要有 50% 左右的分配下降才算是明显改观。如果只降了百分之十几说明大头还没找到。我常用的一份数据清单大致是指标优化前优化目标备注平均帧 GC Alloc20-50 KB/帧 2 KB/帧大世界探索类要求更严GC Collect 频率每 3-5 秒一次30 秒以上一次数值越大越好单次 GC 耗时30-100ms 5ms手机端 18ms 帧预算内托管堆总大小持续增长稳定在固定水位观察 10 分钟游戏GC Alloc 降到几 KB 以下时你才会发现那些曾经的“0.1% 概率卡顿”消失了大半。5. 移动端实战与持续迭代下的 GC 防守策略5.1 真机能跑顺才算真的顺到了移动端GC 问题会被放大得非常明显。电脑上的一次 GC 停顿可能只有几个毫秒玩家几乎无感知但在低端 Android 机上同样的分配量可能把帧率从 30 FPS 直接打到个位数。另外移动端开发还经常要接 SDKSDK 回调里的 JSON 解析、UI 刷新、数据转换都会产生分配这些三方的分配不在你控制的代码里但同样会占用你的堆预算。我见过最典型的一个案例是上线前的立项能跑 90 FPS接完某家广告 SDK 和统计分析 SDK 后帧率在低端机掉到 35 以下排查发现 GC 曲线每隔几秒就爆一次最后查了半天是某个 SDK 回调里不断创建Dictionary去解析服务端配置。遇到这类问题方案不是丢掉 SDK而是把 SDK 回调的线程主动切换或限制频率或者在初始化阶段把 SDK 需要的数据全部预加载好避免运行时频繁解析。5.2 给项目立一条分配预算红线团队协作和独立开发最大的区别是独立开发者可以靠“我记住了”来维持性能意识但团队里只要有一个图省事的人每帧 GC Alloc 就会偷偷涨回去。建议在项目早期就定一条分配预算作为代码评审和提测的硬指标。例如普通战斗循环每帧 GC Alloc 不超过 1 KB打开界面、切换场景这样的低频操作单次不超过 256 KB且不能连续触发所有 Update/LateUpdate/FixedUpdate 里禁止 LINQ、禁止 lambda 捕获、禁止字符串拼接协程里所有WaitFor*必须使用静态缓存。这套预算写进开发文档以后每天跑完回归测试顺手打开 Profiler 看一眼有没有超线成本很低收益却立竿见影。5.3 DOTS 和 Job System新方案不等于全盘推翻这几年 Unity 推 DOTSData-Oriented Technology Stack配合 Job System 和 Burst Compiler把游戏逻辑从托管堆搬到原生内存区域主线程的 GC 压力自然就小了。只要你把数据放进NativeArrayT这类非托管容器在 Job 里用 Burst 编译的代码处理整条链路几乎不会产生 GC Alloc。这个方向很适合大批量实体逻辑比如上千个单位的寻路、战斗判定、粒子物理模拟。但要说 DOTS 能“彻底解决 GC 问题”我是不太信的。UI 层仍然需要 uGUI 或 UI Toolkit网络数据、存档数据、部分第三方库调用都还在跑托管代码。而且 DOTS 的研发成本和上手难度都不低不是所有项目都值得整套切换。更实际的路线是核心密集计算逐渐迁到 Job System传统玩法保持常规优化两条腿走路。5.4 监控和告警别等玩家骂了才知道卡发布后如果完全不监控GC 引发的卡顿就像一个看不见的定时炸弹。新版 Unity 提供了一些运行时性能统计API可以在游戏内通过UnityEngine.Profiling.Profiler.GetTotalAllocatedMemoryLong()、Profiler.GetMonoHeapSizeLong()等接口获取托管内存状态定期把数据上报到你的后端日志系统。设定阈值一旦某台设备堆内存长期高位、卡顿事件次数陡增就能提前告警。在团队内我还会要求每次版本提交前自动跑一个“GC 压力测试”场景专门把游戏里最容易产生分配的操作来回点几百遍用命令行模式下记录 GC 数据自动化判断有没有超标。这套流程跑熟以后新版本的 GC 隐患根本活不到提测阶段。写在最后的几个实践心得做了这么多年性能优化我对 GC 的态度越来越明确GC 不是敌人滥用分配才是。只要每帧的分配量控制在合理范围GC 收集器完全可以在后台默默干活几乎不打扰你的帧率真正该警惕的是那种“堆里到处是临时对象每几秒强制清一次”的状态。如果让我给新手一句话建议我可能不会说什么大道理只会说把你代码里所有写在循环和 Update 里的new都盯一遍该缓存缓存该池化池化GC 卡顿立刻消掉大半。群里再有同学抱怨“Unity 卡顿太难查了”我通常就只回一句——先开 Profiler 看 GC Alloc不用猜。