做Unity卡顿排查这些年我手机里始终存着一句话卡顿不需要理由但一定有源头。帧率下降会直接劝退玩家而GC则是帧率下降里最会伪装的那个。很多团队优化到后期发现卡顿不是来自渲染重、也不是来自加载慢而是每一帧里那些不起眼的临时对象在悄悄填满托管堆。今天这篇《GC看不见的卡顿来源》就是想把这一整个排查链路和大家聊透。先说一个我常挂在嘴边的判断在Unity项目里GC分配本身不一定会让你掉帧真正让你掉帧的是“分配产生的时机和数量”。一次分配几KB对象垃圾回收几次可能都没事但如果你的Update循环里每一帧都在分配托管堆就会在几秒内膨胀GC被逼着频繁工作于是帧率曲线开始出现锯齿状尖刺。这种卡顿不发生在你的业务逻辑代码里你盯着自己的Update函数盯到天黑也找不到问题必须把视线拉到“托管堆”这个维度上来。1. 为什么说GC是帧率表上最典型的“隐形刺客”先讲清楚一个基础概念。GC是Garbage Collection垃圾回收的缩写Unity里的C#脚本跑在Mono或IL2CPP运行时上你用new关键字创建出来的所有引用类型对象类、数组、字符串、委托、List这些都分配在托管堆里。运行时不定期的扫描整个堆找到那些“没有任何引用指向”的对象把它们的空间回收掉这就是一次GC。听起来好像很安全、很智能对吧问题在于GC执行回收的那一刻托管环境对整个游戏来说是“暂停”的英文圈叫Stop the World。它停止所有线程把当前的对象图从头到尾遍历一遍找出哪些对象还能到达、哪些已经是死对象然后进行压缩或清理。这个停顿的时长由托管堆里的存活对象数量决定而不是由你刚刚分配了多少来决定。换句话说即使你这一帧只分配了10KB垃圾但如果前面几秒累计了一大堆未被回收的对象一次GC就可能扫出上百MB的遍历量在移动设备上产生几十毫秒甚至更长的停顿。为什么我说它是“隐形刺客”因为一个标准卡顿尖峰反映在Profiler上你默认会去看哪个函数耗时高。但GC停顿不会在脚本耗时里显示它是一个独立的“回收阶段”往往出现在一帧的末尾或某个特定点你只看Average Time或者Scripts耗时根本看不出端倪。我见过不止一次这样的场景某个性能负责人拿着帧率曲线说“平均47帧”结果打开曲线图一看每隔几帧有个深红色尖峰整个曲线像狼牙棒。排查了三天场景、骨骼、粒子最后在GC Alloc列里发现一个每帧都执行的字符串拼接改完立竿见影。这个经历在我团队里被重复了至少五次所以我决定写这篇专门讲GC的文章。另外移动端和PC端的体感差异很大。PC上有足够的内存带宽和高速CPU几十毫秒的GC停顿可能只是让帧率从120掉到100人眼不一定看得出。但在中端Android机上本身CPU频率低、内存带宽有限一次30ms的GC停顿就是两帧白屏玩家立刻会觉得“卡了”。这也是为什么我觉得做Unity优化的人必须把GC当成一级警备对象——你不主动控制它它就会在你最关键的交战帧里给你一记闷棍。2. 用Unity Profiler找出每一笔GC Alloc的来龙去脉既然GC这么隐蔽那就得把“看见它”变成固定操作。第一步打开Unity自带的Profiler路径是Window Analysis Profiler切换到CPU Usage模块。不要急着点Play先把工具栏里的Deep Profile深度剖析勾上。Deep Profile会把所有脚本函数插入剖析钩子这样你能看到每一层C#函数调用里分配的字节数这是最直接的定位方式。光勾Deep Profile还不够你要知道在哪一列看分配。在CPU Usage面板的表格区域右侧滚动条下方有若干个列其中两个最关键的列分别叫“GC Alloc”和“Time ms”。GC Alloc的单位默认是字节B它表示该函数在这一帧内分配的托管内存总量。点击GC Alloc列头可以排序让它从高到低排列这样一帧里哪个函数分配最多最靠前问题点一眼就能揪出来。不过我要提醒一个新手经常犯的错只看单帧。因为游戏逻辑里很多分配是按条件触发的比如每5秒刷一次敌人、掉血才生成伤害飘字、打开背包才实例化UI如果你恰好录了一帧“平静期”它可能GC Alloc只有几十字节你得出结论“没问题”。正确做法是持续录制5到10秒然后拖动时间轴逐帧观察尤其注意战斗、切场景、操作界面等高频时期。我会把Profiler的录制时长调到10秒以上再用侧边的“录制”按钮保存一份Play Mode数据回到编辑器里慢慢看。Deep Profile有个致命缺点它会让项目运行慢好几倍直接放进真机可能低端机就变成PPT了。所以我的推荐组合是编辑器里用Deep Profile的逻辑真机上用Development Build Autoconnect Profiler的方式先跑正常逻辑再通过Remote框架远程连线录数据。近年来Unity还出了个Hot Reload Deep Profiling2021.2只对Development Build生效可以不用在构建前插桩而是在运行时选择性地对某个脚本模块开启深度剖析对定位线上问题非常友好。如果单纯盯GC Alloc还不够建议再装一个官方Memory Profiler包Package Manager里搜com.unity.memoryprofiler。这个工具的本职工作是分析内存分布但连续抓两张堆快照对比Managed Heap对象的数量变化你就能看到GC都“造”了什么类型的对象。有一次我遇到Profiler显示某帧GC Alloc高达1MB但函数树里全是一些底层调用看不出业务逻辑。后来用Memory Profiler抓快照才发现瞬间多了上千个System.Object数组——罪魁祸首是某个通用工具方法里用了params object[]把一堆结构体参数装箱塞进了数组。这种场景纯靠GC Alloc列反而不容易定位两条路径交叉验证才是正解。还有一个细节很多人不知道Profiler的GC Alloc统计的是“托管分配”并不包含Native内存。比如纹理、Mesh、RenderBuffer等由引擎分配的资源不走托管堆它们的峰值和泄漏要用Memory Profiler的Native部分来查。所以别把GC Alloc当成内存优化的一切它只负责托管那一亩三分地。3. 藏在日常代码里最容易触发GC的五个习惯这一节我总结五类最常见、也最容易被忽略的GC来源。每一类都是我在真实项目里反复遇到的不是教科书上干巴巴的理论。3.1 字符串拼接第一嫌疑犯在Update里写myText.text Score: score;大概是Unity项目里最烂漫的写法规避。看起来轻巧但它不是一个原子操作。C#中string是不可变类型每次拼接都会在堆上创建新的string对象并且把旧字符串的内容复制过去。如果这行代码每帧执行一次意味着每帧都分配一个或几个新字符串旧字符串成为垃圾攒一会儿就触发一次GC。我见过一个实际案例一个排行榜界面每帧刷新10个玩家名字和分数每个都用“名字 “” score “分””拼一段一帧下来GC Alloc 6KB左右。听起来不多但那是持续新增的半分钟后托管堆堆了几百KBGC频率从每10秒一次变成每2秒一次帧率就开始跳。改法很简单能用StringBuilder的就用StringBuilder并且把它做成类字段或静态缓存sb.Clear()后追加新数据。如果是简单的“前缀数值后缀”其实也可以直接把数字score.ToString()转成一个缓存好的string再拼接加法——但ToString本身也可能分配更稳妥的做法是预先算好可能的数值范围用查表法或者干脆用Unity的TextMeshPro里面那个SetText(string, int)重载它避免了临时字符串。风险提示如果只是偶尔触发一次比如打开设置面板时才拼接一次UI字符串这种量级的分配通常无所谓别过度优化。但凡是跑到Update、协程循环、事件频繁触发里的字符串操作一律默认使用StringBuilder或预定义完整格式。3.2 LINQ优雅的代价C#的LINQ写起来是真的爽list.Where(x x.id 1).FirstOrDefault();一行顶十几行循环。但爽的背后是实打实的分配。Where会创建一个迭代器对象FirstOrDefault要用一行enumerator.MoveNext()去遍历整个链式调用还要生成委托、可能创建闭包容器。在老Mono运行时上这几乎每一层都是new。IL2CPP下情况好一些但依然会产生额外的托管对象。我并不是说完全禁用LINQ。在编辑工具、非高频逻辑、启动加载这些对性能不敏感的地方用LINQ可读性的收益远大于分配损失。但如果在战斗逻辑的碰撞检测、每帧的射线命中判空数组、每帧的敌人列表筛选里用LINQ那就是在给帧率埋雷。替换方案也很朴素手写for循环自己维护结果List或数组。看起来代码长了点但0 GC Alloc而且对缓存友好。特别注意一类陷阱OrderBy这类排序操作会把整个集合复制到新数组里做排序GC Alloc和集合大小成正比。一个200个元素的列表做一次排序可能分配几KB如果每帧都做那真是灾难。能优化的思路是改用可复用数组的List.Sort或者维护一个“是否需要重新排序”的脏标记只在数据变化时排序。3.3 装箱与拆箱藏在值类型里的分配每次把一个值类型int、float、Vector3、枚举、struct转换成object或接口类型都会发生装箱Boxing也就是在堆上创建一个新对象把值复制进去。这个过程的GC Alloc是隐式的代码里往往看不到new。典型场景把int塞进ArrayList或Listobject在Debug.Log里用“”拼接数字往string.Format里传数字以及把一个值类型变量赋值给object类型的参数。我见过最夸张的一段代码是把玩家的等级、金币、攻击力等十几个整数值全部放在一个Hashtable里做配置每次读取都要(int)table[level]读取过程拆箱一次。这还不算如果往表里写入临时值装箱又分配一个对象。整个战斗流程每帧读几十次这种HashtableGC Alloc直接把Profiler刷红。拆解的改法有两个层面第一坚决用泛型容器替代非泛型容器比如Dictionarystring, int而不是HashtableListMyStruct而不是ArrayList。第二对频繁拼接的字符串别依赖“”的隐式装箱直接用StringBuilder追加数字的方法或者先调用ToString()注意ToString不一定无分配但至少它不经过中间对象。顺带提一个冷知识枚举转字符串myEnum.ToString()也是装箱反射的分配大户频繁输出调试信息时尽量用预缓存的字符串数组或者改用整数ID。3.4 闭包与匿名函数每次Lambda都在“new”一个对象C#的Lamba表达式是个语法糖它背后会生成一个编译器闭包类。如果一个Lambda捕获了外部变量比如循环里的计数器、局部的敌人对象编译器会为它创建一个新的闭包对象并把捕获的变量保存在对象字段里。于是执行到这一行Lambda时就在堆上多了一个闭包实例。哪怕Lambda本身很短分配也照发生不误。实际操作中最常见的是在Update里写someButton.onClick.AddListener(() { DoSomething(); });虽然这个AddListener写一次之后按钮就不会再触发但如果你是在一个动态创建的UI上每次生成这个UI都AddListener一次闭包就等于UI数量。改进方案优先使用“绑定接收者实例”的委托void OnClick(){ DoSomething(); }把方法名直接传给AddListener这样每次创建的是同一个委托实例不会为闭包单独分配。如果确实需要捕获参数建议在UI初始化时新建一个UI事件数据类通过统一回调方法传递数据避免每次点击重建闭包。协程是另一个隐蔽分配点。StartCoroutine(MyCoroutine())本身会分配一个协程对象而yield return new WaitForSeconds(1f);这行每次执行都会new一个WaitForSeconds实例。一个5秒的连招动画如果每帧yield一次新WaitForSeconds几秒内也能堆不少垃圾。改成在协程开始前把WaitForSeconds实例缓存成字段private readonly WaitForSeconds delay new WaitForSeconds(1f);再yield return delay;就能把每次yield分配降为0。同样的思路也适用于WaitForEndOfFrame、WaitForFixedUpdate这些。3.5 看似无害的API和组件访问有些Unity API本身就会产生GC分配。GetComponentsT()带s的复数版本每次调用都会返回一个新数组哪怕数组里只有一个元素也会重新分配。如果你在Update里频繁调用GetComponentsCollider()那差不多等于每帧new一个数组属于比较隐蔽的罪魁祸首。替代方案是把这个获取动作放到Awake或Start里缓存或者用TryGetComponent较新的Unity版本已提供无分配。另外GetComponentT()在旧版本Unity里其实也会有一些内部查找开销但不一定分配托管对象。为了保险我始终建议高频引用Transform、Renderer、Animator、Text等在初始化时缓存成私有字段不要在Update里反复GetComponent。这条看似基础但我真在项目里抓到过一个写了一年Gameplay的团队他们移动代码里每帧GetComponent ()全项目到处是这种写法。再有一类是渲染和UI的API。比如Camera.main每次调用对主摄像机做查找内部会缓存但第一次还需对象查找Camera.main本身无GC但涉及类似消息系统的方法可能分配。真正高频分配大户之一是UGUI的Text.text赋值它会检查文本改变但如果传入的字符串是每帧新拼的本身就分配了而在某些Unity版本中设置TextMeshPro的文本如果使用简单字符串变量赋值通常也不分配只是内部字符缓冲需要容纳新文本会触发缓冲扩容才分配。因此UI刷新时尽量复用同一个TextMeshPro对象并用SetText的重载或预格式化的BigInteger/字符串避免文本内容长度变化过大导致内部缓冲反复扩容。4. 从对象池到零GC分配一套可以直接抄走的优化方案排查出分配点之后下一步就是动手把高频路径的GC Alloc降到合理范围。我的总原则很简单低频分配不必刻意消灭高频分配必须用一个模式去解决。这里分享几个我已经在多个项目里验证过、可以直接套用的方案。4.1 一个基础对象池模板对象池几乎是所有Unity项目必备的组件适用于子弹、敌人、伤害飘字、技能特效、拾取物等频繁创建和销毁的对象。实现逻辑不复杂准备一个栈或队列存着空闲实例需要时从栈里弹一个没有就new一个用完后再把实例放回栈里而不是Destory掉。直接贴一个精简版模板public class ObjectPoolT where T : Component { private readonly StackT pool new StackT(); private readonly T prefab; private readonly Transform parent; public ObjectPool(T prefab, Transform parent, int preloadCount 0) { this.prefab prefab; this.parent parent; for (int i 0; i preloadCount; i) { var obj CreateNew(); obj.gameObject.SetActive(false); pool.Push(obj); } } public T Get(Vector3 position, Quaternion rotation) { T item pool.Count 0 ? pool.Pop() : CreateNew(); item.transform.SetParent(parent); // 可选重新挂载 item.transform.SetPositionAndRotation(position, rotation); item.gameObject.SetActive(true); return item; } public void Release(T item) { item.gameObject.SetActive(false); pool.Push(item); } private T CreateNew() { var obj Object.Instantiate(prefab, parent); obj.gameObject.SetActive(false); return obj; } }实战中要注意两点第一对象池不是万能的如果你的对象数量峰值很大比如同时存在200个而平均只有20个池子会一直持有200个实例反而挤占内存。所以要给池子设上限超过上限的实例直接销毁这个上限可以通过模拟峰值来设置。第二用完的对象再放回池子时一定要把它的状态重置干净包括位置、旋转、Scale、颜色、Enabled标志等。很多诡异Bug就是对象从池里拿出来时还带着上一次的残影处理这些状态重置的回调可以让对象实现一个IResetable接口在Release里统一调用。4.2 缓存组件引用和预分配集合容量这条可能不需要写太多但我还是要强调所有频繁使用的组件引用都放到Awake或Start里存起来。比如一个Character类在Awake里缓存Transform、Animator、Rigidbody、Collider需要时直接用缓存字段而不是到处GetComponent。这不仅是减少GC也是减少查找开销。集合容量预分配也特别容易被忽略。ListT在Add时如果内部数组容量不够会创建一个更大的数组把旧元素全部拷贝过去这个数组是引用类型分配在堆上。如果你知道这个列表最多存30个元素构造时就写成new ListEnemy(30)就避免了中途扩容的多次分配。同理DictionaryTKey,TValue也可以预估容量构造。如果某个列表每帧都要清空再填充千万别用new List而是维护一个类字段每次list.Clear()后继续用。Clear不会释放内部数组只会把Count归零整体分配就会小很多。4.3 手写循环代替LINQ的实践给个最简单也最常用的漂亮例子。原来的写法// 找出第一个活动的敌人 Enemy target enemies.FirstOrDefault(e e.IsAlive e.Distance 10);改成手写循环Enemy target null; for (int i 0; i enemies.Count; i) { Enemy e enemies[i]; if (e.IsAlive e.Distance 10) { target e; break; } }这段代码的GC Alloc从原来的一次迭代器委托闭包变为0。如果这个敌人查找逻辑每秒执行几十次节省出来的分配量非常可观。类似地Where过滤后取前几个需求用手写循环也能轻松做到。说白了增强型for和普通for本身都不产生分配分配的来源主要是LINQ内部的迭代器与委托。4.4 更隐蔽的“伪优化”小心缓存的坑有些情况下你的“优化”反而会增加GC。我见过一个项目为了减少字符串拼接在启动时新了一个巨大的StringBuilder静态字段然后在每个协程、多个线程里都去调用它完全忘了StringBuilder是线程不安全的同时高频率Clear和Append导致逻辑错乱。这种改法哪怕GC降了也会引入更致命的稳定性问题。还有一次团队把每帧生成的一次性数组改成了ArrayPoolbyte但调用方忘记归还或者归还后还持有引用继续读写导致数据被其他模块覆盖出现了比卡顿更可怕的闪退。使用System.Buffers.ArrayPoolT时一定要遵守“借用期内不能释放释放后不能再碰”的规则。在游戏逻辑里我一般不用这种底层池而是用Unity的NativeArrayT加Allocator.TempJob更可控。4.5 增量GC与时间片必要时才用Unity提供了Incremental GC增量式垃圾回收核心思路是把一次完整的GC分成多个小片分散到不同帧执行降低单帧的“Stop the World”时长。你可以通过Player Settings里的“Incremental GC”选项打开或者运行时设置System.GC.Collect()的频率。它带来的收益是尖峰降低但总CPU开销会增加因为分片需要更多次调度。如果项目代码里本身每帧分配很少开不开增量GC差距不大如果代码里GC Alloc很高且你短时间没法全部消除开增量GC确实能把40ms的尖峰摊平到每帧2~3ms但综合帧率未必提高可能还会更低。我个人的经验是增量GC适合作为“最后一道防线”而不是第一天就开启。先做代码层面的分配消除等把主力业务逻辑的GC Alloc压到每帧几十字节再把增量GC关掉用真正的性能数字做决策。别指望它救一个疯狂分配的项目——那样只是把灾难拉长了摊薄了但总CPU开销依然是瓶颈。5. 优化之后怎么验证别让“看似优雅”的判断骗了你每次优化完GC很多人一看GC Alloc从8KB降到0就觉得万事大吉。但真机上卡不卡还得看帧率曲线和玩家体验。我有一套固定的验证流程这里分享出来。第一步同一台设备、同一场景、同一操作路径用Profiler录优化前后的各10秒数据。对比指标除了GC Alloc总量还要看“GC Invoke Count”GC调用次数和帧时间曲线上的尖峰频率。GC Alloc降了但如果堆内存里残留了大量被池子缓存的实例GC扫描时间未必降下来。所以真正要看的还有每个采样帧的Managed Heap Size看它是否保持稳定。第二步真机操作阶段打开Profiler的Timeline视图把GC事件作为一个独立层显示。有些平台如iOS的IL2CPPIncremental GC在Timeline中会以橙色块显示回收时间你能非常直观地看到卡顿尖峰和GC块的对应关系。如果优化后GC块几乎不出现帧率曲线就平滑了。第三步做一个最接近实际体验的测试让玩家快速连招、频繁打开关闭界面、在UI里持续滚动列表测试10分钟。很多GC问题是偶发的短时间录制看不到长测才能暴露。我这里说的偶发是指某个缓存池扩容、某个对象首次创建时的预分配、或者是别人调用的某段低级库代码。另外我有一个反过来的提醒别被“0 GC Alloc”绑架。零分配是个很好的目标但不应该成为唯一目标。如果为了消灭每帧几KB的分配你把代码写得像天书维护成本暴涨反而得不偿失。优先消灭高频路径上的分配低频路径上偶尔一次几十KB分配只要不是频繁触发对帧率的影响微乎其微。把精力放到真正让玩家感到卡的“每帧尖峰”上才是性价比最高的优化方式。我还分享一个自己踩过的坑。有次为了消除一个武器技能的GC我把一个自定义的BuffData类改成struct试图避免从堆上分配。结构体确实通常不分配堆内存但一旦这个struct被塞进ListBuffData在整个列表扩容或返回时还是可能产生装箱或拷贝。更麻烦的是struct的装箱在多次调用后反而产生了更多GC而且代码里到处是用引用方式传struct才安全简直是从一个坑跳进另一个更深的坑。所以优化时要看具体上下文不能光盯着“引用类型换成值类型”这一个点子。最后聊一个小技巧我排查GC时经常用脚本在关键帧打印当前的GC总内存long before System.GC.GetTotalMemory(false); // 执行一段业务逻辑 long after System.GC.GetTotalMemory(false); Debug.Log($Alloc: {after - before} bytes);这在编辑器里快速验证某段函数是否产生异常分配很管用尤其是Profiler不方便打开的时候。但别把它放到线上代码里GetTotalMemory本身也有开销而且它触发GC的次数不固定对比结果仅供参考。回到开头那句话GC是帧率保卫战里最经典的隐形敌人。只要你能在Profiler的GC Alloc列里看见它在Memory Profiler的堆快照里看清它再用对象池和非分配写法压住它卡顿基本就少了一大半。这个系列还有几篇会讲渲染开销、资源加载和其他隐藏瓶颈但如果你连GC这道坎都迈不过去后面的坑大概率会因为分配问题一个接一个地冒出来。先把这个最看不见的源头治住你会忽然发现很多莫名其妙的掉帧其实早就有答案了。