
做Unity性能优化的时间长了你会发现一个特别有意思的现象场景里明明没有激烈地创建物体Draw Call 也压得很低但帧率就是会莫名其妙地掉一下。打开 Profiler 看CPU 耗时主线程并没有飙高可是屏幕左上角的帧率曲线就是会出现一个很规律的锯齿状下跌。这时候十有八九是 GC 在背后偷偷作妖。这是《Unity 卡顿·帧率保卫战》系列的第 4 篇这次我把矛头指向看不见的卡顿来源——GC也就是 C# 的垃圾回收机制。它不像渲染管线优化那样有直观的 Batches 数值可以盯也不像物理系统那样有明确的 Fixed Timestep 参数可以调它藏在每一行代码的变量创建里藏在你顺手写下的字符串拼接里藏在 foreach 和 LINQ 的语法糖后面。等它爆发的时候你已经很难定位到底是哪一行代码引发了一场大规模的托管堆清扫。这篇文章我会从 GC 的工作原理讲起然后列出我这些年实际见过的、出现频率最高的 GC 触发场景再给出一套用 Profiler 定位 GC 问题的排查流程最后用一个小案例把优化的全过程串一遍。适合已经被卡顿逼到头秃、却又对着 Profiler 无从下手的 Unity 开发者。1. 先弄明白GC 到底在卡什么GC 的全称是 Garbage Collection垃圾回收。C# 的开发者对 GC 应该都不陌生它在 .NET 里负责自动管理内存帮我们释放那些不再被引用的对象所占用的空间。听起来很美好但问题在于GC 什么时候执行、执行多久并不是我们能完全控制的。1.1 GC 是托管堆的自动保洁员先理清一个概念。Unity 使用 Mono 或者 IL2CPP 作为脚本后端时C# 的类对象、数组、字符串、委托、装箱后的值类型都分配在托管堆Managed Heap上。和栈上的变量不同堆上的内存不会被自动释放。当托管堆里的对象越来越多、占用的内存超过某个阈值时GC 会被触发它会暂停当前线程通常是主线程从根开始遍历所有被引用的对象标记出哪些还活着然后把活着的数据搬走、把死掉的那块空间清空。这个过程在 PC 上可能只花几毫秒但在移动端、尤其是低端安卓机上一次完整的 GC 可能耗费几十甚至上百毫秒。放到游戏里就是明显的掉帧、卡顿、帧时间尖刺。我想用一个生活化的类比来说明你把家里所有东西都堆在一个客厅里GC 就相当于每隔一段时间请一位保洁阿姨过来把所有还在用的家具一个不落搬到新房间同时把垃圾丢掉。搬的过程中全家人必须站在门口一动不动。搬一次也许只要几秒但如果客厅里堆了成千上万个物件这位保洁阿姨来回跑的次数就会暴涨你站在门口等着的时间自然越来越长。1.2 为什么 Unity 游戏里 GC 卡顿特别刺眼传统 PC 应用里GC 卡顿通常没那么致命因为用户感知不强。游戏不一样游戏对帧率极其敏感一帧只有 16.6ms60帧或者 33.3ms30帧。一次 50ms 的 GC直接就让这一帧废掉了玩家视角里就是一下非常明显的顿挫。更麻烦的是Unity 的 GC 有一个滚雪球效应。如果托管堆前面全是不能回收的对象比如不小心被静态变量缓存了大数组GC 被迫频繁触发触发次数多了分配到新对象的速度变快又进一步抬高 GC 频率。很多项目优化到后期发现代码里已经没有明显的高频分配了但 GC 还是时不时跳出来刷存在感就是因为前期埋下的堆内存碎片和大量残留对象太多。注意IL2CPP 后端和 Mono 后端的 GC 实现细节有差异。IL2CPP 下 GC 行为更接近于标准 Boehm GC 的变体有时对跨程序集引用的处理会带来额外的标记开销Mono 由于 JIT 和某些平台上的后端差异GC 停顿表现也不太一样。两个后端我都实测过结论是别太迷信换后端就能解决 GC 卡顿架构上不做优化换到哪都是白搭。2. GC 卡顿的七个主要来源我按出现频率排了个队很多人一听 GC 就想到不要 new 对象这个方向没错但太笼统。实际项目里 GC 分配往往不是一个大对象引起的而是一堆看起来不起眼的小分配叠加出来的。我在接手过的项目里几乎都见到过下面这七类问题。2.1 字符串拼接最容易被忽略的分配大户在 Update 里写 Debug.Log 带颜色字符串在 UI 上频繁刷新血量数字在聊天框里拼聊天内容和时间戳这些都是极其常见的场景。C# 的 string 是不可变类型也就是说对字符串的任何拼接操作运算符都相当于重新创建一个全新的字符串对象。比如这一行string msg Score: score Time: time;看着就一行实际上编译器会生成多次 string.Concat 调用每次调用都可能产生一个新的字符串对象这些对象用完之后就变成了托管堆上的垃圾等 GC 来收拾。解决方案很简单// 用 StringBuilder StringBuilder sb new StringBuilder(64); sb.Clear(); sb.Append(Score: ).Append(score).Append( Time: ).Append(time); string msg sb.ToString(); // 或者在固定格式下直接用 string.Format string msg string.Format(Score: {0} Time: {1}, score, time);不过要提醒一句string.Format本身也可能引入装箱分配后面会说在极热路径上建议先用 StringBuilder 或自己写一个值类型拼接方法。2.2 LINQ、闭包和委托语法糖背后的隐式分配LINQ 写起来是真的舒服list.Where(x x.count 3).Select(x x.id).ToList()一行解决很多问题。但舒服是要还的。LINQ 涉及 IEnumerable 迭代器、Lambda 表达式生成的闭包每一次调用都会产生额外的引用对象。闭包更隐蔽。你在一个循环里写了int localVar 10; obj.OnClick () Debug.Log(localVar);编译器会默默创建一个捕获类closure class实例来保存 localVar 的值。哪怕这个委托只触发一次这个闭包对象也已经分配到了托管堆上。这条不是让所有人都别用 LINQ而是说高频 Update 路径、物体数量极大的遍历逻辑里绝对不要用 LINQ 和 foreach 捕获高层局部变量的 Lambda。在这些地方老老实实写 for 循环、手动维护缓存列表是在用一点代码量换掉一整条 GC 尖峰。2.3 协程的 yield return看起来方便用起来心疼协程写法简单遇到yield return null就是等下一帧但每次 yield 都可能产生一个 IEnumerator 对象分配。尤其是一个 Update 里挂了几十个协程在生命周期内反复横跳的 MonoBehaviour协程对象的创建和销毁量会直线上升。我在一个寻路模块里见过这种情况每个单位每 0.5 秒发起一个协程来处理路径点高峰期同时有上百个单位在跑协程GC Alloc 曲线直接冲破天际帧率跌到个位数。后来把高频逻辑改成Update里的计时器判断或者用自定义的轻量任务系统来替代协程问题立刻缓解了大半。如果你确实需要协程尽量做到协程实例复用或者用CustomYieldInstruction来减少分配。更激进的方案是引入 UniTask 这类基于状态机的异步库它不产生 IEnumerator 对象分配量小得多。2.4 引擎级的隐藏分配除了业务代码引擎 API 内部也会产生分配。这些分配往往更隐蔽因为你不直接写new也不知道它在背后干了什么。常见的有几个物理查询Physics.RaycastAll、Physics.OverlapSphere这类返回数组的 API每次调用都会申请新的结果数组。不缓存返回值或者频繁调用分配量会非常可观。建议用非分配版本比如RaycastNonAlloc、OverlapSphereNonAlloc配合预先分配好的数组使用。UI 系统UGUI 的Graphic重建、Text更新、布局组件的强制重建都可能触发网格数据和顶点数据的重新分配。聊天框滚动、排行榜刷新是重灾区。粒子系统动态生成粒子时如果 Pool 配置不当内部也会有分配。动画系统某些 Animator 参数查询、AnimationCurve.Evaluate在热路径上的调用也可能产生临时对象。如果你在 Profiler 的 CPU 模块看到引擎模块比如 UGUI、Physics、ParticleSystem的 GC Alloc 很高就要意识到这不是你代码里写了多少new的问题而是你这些模块的调用模式和频率有问题。2.5 装箱与反射调用装箱发生在值类型被转换为引用类型时。一个int被塞进object变量或者被传进一个接受object参数的接口比如string.Format的参数、ArrayList.Add、Debug.Log(object)都会发生装箱。每次装箱都会在托管堆上产生一个临时对象。反射调用就更大头了。某些序列化框架在启动时大量使用反射读字段如果每次都通过PropertyInfo.GetValue()取值返回的是object免不了一堆装箱分配。这在高频场景下非常致命。实践中我记得最清楚的一次一个 UI 背包系统每帧刷新 30 个格子每个格子用string.Format拼接物品数量Debug.Log里又传了一个float和int。表面上没几行代码Profiler 抓出来每帧居然产生了 2.4KB 的 GC Alloc。全部改成 StringBuilder 自定义值转字符串函数后GC Alloc 降到了每帧 48 字节UIPanel 的帧时间从 4.1ms 降到了 1.3ms。2.6 对象频繁创建与销毁瞬时物体的创建和销毁是最显性的一类比如子弹、特效、敌人尸体、飘字。每次 Instantiate 都会分配一大堆对象每次 Destroy 之后这些对象的内存要等 GC 回收。正确做法是对象池这个很多人已经在用了但池化之后还有个坑如果你只是把对象 SetActive(false) 放进池子而没有把它的组件、引用、缓存数据清理干净下个复用周期可能会有意外的状态残留。对象池本身只解决了分配频次的问题没有解决池子里的对象什么时候被真正回收的问题。如果池子的对象太多、长期占着内存不释放GC 的负载不会变高但托管堆会越撑越大在某些平台尤其安卓上会因为内存压力触发更频繁的 GC。所以池子也要有上限策略超过阈值的对象直接 Destroy 而不是回池。2.7 场景切换与大对象瞬时分配切场景时的顿卡除了加载和资源反序列化还有一个经常被忽略的场景中所有 MonoBehaviour 的 OnDestroy、OnDisable 会触发大量托管对象的引用清除如果一个场景里缓存了几个大 List、大数组、材质实例切场景瞬间的 GC 标记工作量会非常大。如果你项目里允许玩家频繁切换场景一定要注意场景卸载时的清理策略把不再需要的资源主动卸载Resources.UnloadUnusedAssets把被静态变量引用的数据清空避免它们变成假活对象卡在托管堆里。3. 用 Profiler 锁定偷帧元凶一套可复现的排查流程GC 问题最麻烦的不是修而是找。下面这套流程是我优化多个项目后用下来的固定动作有需要可以参考。3.1 先看 CPU 总耗时和 GC Alloc 曲线打开 Unity Profiler切到 CPU Usage 模块在右边的图表里勾选GC Alloc曲线同时打开Deep Profile注意Deep Profile 会大幅降低运行速度适合小范围测试不适合整场体验。这一步的目的不是精确定位而是确认你的游戏确实存在 GC 卡顿且卡顿频率与 GC Alloc 尖峰同步。如果你看到 GC Alloc 一直是一条很高很平的直线那说明项目每帧都在大量分配如果你是看到每过几十帧出现一个特别大的尖峰那说明是周期性的批量分配跟某个定时逻辑有关。3.2 Hierarchy 面板按分配排序定位热代码在 Profiler 的 Hierarchy 视图里把排序方式切到GC Alloc时间范围选一个卡顿发生的帧。这时候你看到的不是整个项目的累计分配而是那一帧内具体是哪些方法贡献了最多的 GC 分配。我最常遇到的情况是布娃娃销毁、UI 刷新、频繁的物理检测三者交替出现。你按 GC Alloc 排序后直接点进那些方法就能看到具体的调用栈。注意Profiler 的 GC Alloc 数值是该帧内这个方法产生的托管堆分配量不代表这些对象都活着。它只是告诉你分配发生了但你已经知道分配点在哪优化就有了靶子。3.3 用 Memory Profiler 看托管堆快照上面两步能找到具体方法但有些时候 GC 卡顿的根源在于积累了大量不该被长期持有的对象这时候就需要看托管堆快照。Memory Profiler 包里有一个 Capture 功能可以在游戏运行时截一张托管堆、原生内存、资源类型的快照。用快照对比不同时间点的托管堆你能看到哪些类型的对象数量在持续增长。如果能定位到某个类型比如字符串、粒子系统组件、某种 MonoBehaviour的数量持续走高那就可以顺藤摸瓜找到泄漏或长生命周期缓存。3.4 必要的时候给关键代码打上 ProfileMarker 手动标记如果还是找不到具体哪一段逻辑最耗 GC可以在可疑的代码段前后加上自定义标记Profiler.BeginSample(MyDirtyLogic); // 你的可疑代码 Profiler.EndSample();这个方法只影响编辑器下的 Profiler 数据不会进正式包但能帮你把一段复杂逻辑里的多个分支拆开、分别统计分配量。我经常在 OnGUI、Update 里的某个分支、以及 UI 刷新函数里做这种标记几分钟就能把嫌疑范围缩小到几行代码。4. 实战优化一个受伤的小队 UI 聊天框案例光讲理论没意思我把一个实际的优化案例完整摆出来从现象到数据一步步走一遍。4.1 项目背景与最初的问题现象一个多人同屏战斗项目UI 部分有一个聊天框底部有消息流玩家消息和系统提示会滚动刷新。整个 UI 层不止这一个面板但每次发生大批量消息刷新时帧率都会瞬间掉到 40 左右目标 60。用 Profiler 抓了一下卡顿帧的 GC Alloc 高达 8.9MB其中聊天框相关的 UGUI 网格重建和字符串拼接占比超过 70%。具体表现是聊天框每次滚动刷新一堆文本重构 字符串拼接 列表操作的全套分配全挤在同几帧里完成。4.2 第一次优化字符串处理与缓存第一步把聊天消息的拼接全部改成 StringBuilder并且把消息预览对象做成可复用组件避免每来一条消息就 Instantiate 一个 Text。第二步把消息列表的排序、过滤逻辑从每帧执行改成有新消息才执行并且把结果缓存到复用数组里。改完之后单次刷新时的 GC Alloc 从 8.9MB 降到了 1.3MB帧率回升到 55 左右。但还有一个问题只要消息流连续刷十几条还是会掉到 45 附近。说明 GC 不是唯一瓶颈UGUI 的网格重建才是大头。4.3 第二次优化UI 对象池与分批刷新第三步引入 UI 对象池预创建 30 个消息条目对象滚动刷新时只改变条目上的文字内容不销毁不重建。配合 UGUI 的LayoutGroup使用LayoutRebuilder.MarkLayoutForRebuild来代替自动标记减少不必要的布局重建。第四步对消息到达做合并帧处理同一帧到达的 5 条消息合并到下一帧一次性刷新而不是逐条触发 SetDirty。这样把 UGUI 的网格重建次数从每条一次压到每批一次。这一轮改完连续刷新 20 条消息时GC Alloc 稳定在 60KB 左右帧率保持 58 到 60卡顿彻底消失。4.4 优化效果对比与数据阶段单批刷新 GC Alloc连续 20 条消息后帧率主要优化手段初始版本8.9MB跌到 40无第一次优化后1.3MB55字符串拼接改 StringBuilder消息预览缓存复用第二次优化后60KB58~60UI 对象池网格批量重建合并帧刷新这个案例说明一个道理GC 优化经常不是一步到位而是要把字符串、对象生命周期、引擎 API 调用模式一层层剥开。你每消掉一个分配点帧率就往上爬一点直到所有隐藏的分配都被处理干净那个看不见的卡顿才会真正消失。5. 日常开发中的 GC 防线从架构层面减少分配等线上出问题再优化永远是最花成本的路。如果项目还在开发期或者正在规划新功能我建议把下面这些原则直接写进团队的编码规范里。5.1 缓存与复用把用后即弃改成循环利用凡是在 Update、LateUpdate、FixedUpdate、协程、UI 回调、动画事件里高频执行的逻辑任何临时对象都要考虑复用。最典型的是这三个数组/List 缓存RaycastAll、OverlapSphereAll的返回值准备一个私有数组初始化到足够大的容量用NonAlloc版本反复填。字符串缓存UI 上的大量文本刷新准备一个 StringBuilder 成员变量Clear()后复用不要每帧new一个。事件参数缓存自定义事件系统的参数如果包含值类型或者需要频繁创建对象用静态的事件参数池。5.2 针对数据结构的选型Array、List、Dictionary 的取舍List 和 Dictionary 在内部都是数组实现的扩容时会产生新的数组旧数组在 GC 标记时也是垃圾。如果在一个循环里反复向 List 添加元素而且初始容量没设置好扩容就会触发布料级别的分配。建议所有你预计容量会超过 10 的集合初始化时直接指定容量Listint ids new Listint(128); Dictionaryint, string map new Dictionaryint, string(64);对于高频遍历的场景优先用普通数组而不是 List数组没有迭代器分配遍历速度更快。对需要频繁增删但数量有限的场景用LinkedList要谨慎它的节点是独立对象可能比 List 扩容更费。5.3 针对 Update 与 MonoBehaviour 生命周期的规范一个常见的架构问题把本应每帧轮询的逻辑写进了 Update导致每帧都在做重复查询和分配。更合理的做法是用事件驱动、数据驱动的方式尽量让代码被调用而不是轮询。没必要的 MonoBehaviour 也要控制数量。我自己维护过的一个系统里300 个子弹对象每个都挂了一个 Update里面除了位置移动什么都没有。改成对象池DOTween 或手动管理的模拟循环后不仅 GC 分配少了帧时间也降了不少。5.4 针对引擎 API 的替代方案物理、UI、动画、粒子引擎 API 的分配往往比业务代码更隐蔽但替代方案也很明确物理检测优先使用NonAlloc系列接口配合预分配数组。UI 更新避免高频修改 Text 的 rectTransform 尺寸避免频繁增删子节点。UGUI 的Canvas.SendWillRenderCanvases触发频率很高UI 上任何显示变化都可能触发整棵 Canvas 的网格重建。如果 UI 层特别重考虑拆分 Canvas 或者用Additional CanvasShaderChannels减少重建范围。动画避免在 Update 里频繁读取 Animator 参数Animator 的参数查询可能触发内部缓存更新。粒子粒子数量超过千级时注意ParticleSystem的Emit调用频率和池化配置。5.5 用自定义分配器/Job System 做更激进的优化如果你已经把手写的业务代码优化到了极限还想再压一部分 GC可以考虑把高频逻辑挪到 NativeArray Job System 上。Job System 使用非托管内存不参与 C# 托管堆的 GC可以将原本每帧数 KB 的数组分配减少到 0。代价是代码复杂度上升跨线程数据同步要小心。我见过一个塔防项目把怪物的行为逻辑全部改成 IJobParallelFor 处理每帧的 GC Alloc 从 120KB 降到了 4KB同时由于 Job 的并行性帧率还提升了 15%。但这不是银弹小型项目或者逻辑频繁变化的模块不值得为了 GC 优化上 Job System容易把自己锁死在性能优化里。6. 常见问题速查表与避坑经验下面是我在实际项目里反复踩过、或者见过别人踩过的坑整理成速查表方便直接对照。6.1 常见问题速查表问题现象可能原因快速验证方法解决方案帧率周期性掉到个位数定时器逻辑里有批量创建对象Profiler 抓 GC Alloc 尖峰对应的帧批量创建改对象池定时逻辑用事件替代轮询UI 列表滑动卡顿UGUI 文本重建 字符串拼接Hierarchy 里看 Text/Graphic 的 Mesh 更新StringBuilder 复用对象池合并帧刷新持续掉帧GC Alloc 一直很高热路径里高频字符串拼接/LINQ/闭包CPU Hierarchy 按 GC Alloc 排序改成 for 循环、缓存、值类型拼接GC 偶发尖峰无法稳定复现事件系统触发的临时委托/消息对象手动打 ProfileMarker拆分定位委托池化缓存事件参数切场景时长时间卡顿场景卸载时大量对象引用清除Memory Profiler 看切场景前后托管堆快照清理静态引用主动卸载残留资源低端安卓机表现尤其差引擎内置 GC 触发频率较高在真机上开启 Profiler 低开销模式减少分配总量考虑增量 GC必要时降级部分特效6.2 我踩过的坑第一个坑过度依赖Resources.UnloadUnusedAssets()。这个方法在切场景时确实能释放资源但它不是同步完成的调用后要等好几帧才能真正执行而且它会扫描所有对象引用本身就有不小的 CPU 开销。正确用法是只在资源切换的关键节点调用不要在 Update 里反复调用。第二个坑把Debug.Log留在了正式包。我在做性能优化时习惯打很多日志有一次提交版本前忘了关掉结果正式包在真机上频繁 GC。Debug.Log 除了字符串拼接的分配还会导致日志系统内部的对象创建。发布前一定要用宏控制比如#if UNITY_EDITOR或者自定义日志系统。第三个坑粒子系统的 Emission 和 Pool 配错。一次特效优化中我把粒子系统的 Pool 容量配小了一个数量级结果粒子系统每帧都在实例化新的粒子对象GC Alloc 反而比不优化之前还高。记住粒子系统的prewarm属性和maxParticles要匹配池子容量要够大否则它不会回收只会边分配边销毁。第四个坑把 C# 的yield return new WaitForSeconds()写成了yield return new WaitForSecondsRealtime()这两者在编辑器下表现差异不大但 WaitsUntilCanceled、YieldInstruction 这类类对象不同在部分平台上有不同的分配行为。类似的小差异很多统一认清你项目里用的那些 YieldInstruction 的底层实现很重要。6.3 一些不容易注意到的 GC 来源有些 GC 分配藏得非常深甚至连源码都不太容易看到。我补充几个不太被关注的值类型数组的隐式装箱在某些泛型方法里值类型数组int[]被当object传递有可能引发装箱。注意泛型约束写成where T : struct可以减少一部分。Canvas 本身的 buffer 分配UI 上的 Canvas 如果设置了additionalShaderChannels在某些 GPU 上会分配额外的顶点数据 buffer这种内存不归 C# GC 管但会推高原生内存。音频、视频解码器产生的临时 buffer有些编解码器在解码时会持续分配原生内存虽然不直接计入 GC Alloc但会触发整体内存回收间接影响 GC 频率。Android 系统层面的 GC 干扰真实设备上系统其他应用比如后台服务的内存活动也会影响你 App 的整体性能。你优化得再干净也阻止不了系统在你前台跑 GC。这就是为什么真机测试必须做、且必须用多款不同档位的设备做。7. 收尾前的一个建议我个人在实际操作中的体会是GC 优化不是某个阶段的专项工作而是贯穿整个开发周期的纪律。你可以在需求评审时顺便过一下数据结构写代码时顺手检查一下热路径有没有不必要的分配做功能自测时顺便看一眼 Profiler 的 GC Alloc 曲线。等这些成为习惯你基本就不太会在上线前被 GC 卡顿杀个措手不及。最后再分享一个小技巧如果你的项目允许把 Profiler 的GC Alloc曲线放在你日常开发时比较容易看到的位置比如编辑器 Game 视图旁边的小窗口。每次跑完一段功能瞄一眼那条曲线有没有异常波动比等到玩家反馈卡顿之后再回头查省力得多。GC 这个家伙本质上是在帮你的程序管理内存但它在不合适的时机反复出现就成了玩家嘴里这破游戏怎么这么卡的元凶。把它管好你的帧率保卫战就已经赢了一大半。