首页/新闻资讯/正文详情

Unity GC 卡顿全解析:从分配机制到性能优化实战

发布时间:2026/9/26 14:18:25 来源:云帆数科 栏目:资讯中心
Unity GC 卡顿全解析:从分配机制到性能优化实战
1. 这个系列要解决什么问题做 Unity 性能优化的朋友多半都有过这种经历游戏跑起来帧率看着还行帧时间曲线也不算离谱但就是在某些时刻——切技能、开背包、刷怪、甚至只是播了个动画——画面突然肉眼可见地钝了一下。你盯着 Profiler 看半天发现 CPU 占用不高Draw Call 也正常GPU 更是闲得发慌那这个卡顿是从哪来的大概率是 GC。GCGarbage Collection垃圾回收是 Unity 里最典型的看不见的卡顿来源。它不像 Draw Call 和 Overdraw 那样可以在 Frame Debugger 里直观看到也不像复杂的 Shader 那样有明显的 GPU 开销标识。它藏在托管堆Managed Heap的分配与回收里平时你感知不到但当它触发时一卡就是几十毫秒直接把你的帧时间打穿。这个系列前几篇聊了渲染管线的优化、资源的加载与卸载、CPU 侧的代码热点排查今天这篇专门把 GC 单独拎出来讲清楚三件事GC 为什么会卡、GC 到底是怎么工作的、以及在 Unity 项目里我们真正应该怎么去治理它。如果你是个刚接触优化不久的新手这篇文章可以帮你建立一套分配意识——从写代码的第一天就知道哪些写法会埋下 GC 隐患。如果你已经做了几年优化那这篇文章里的排查方法和避坑清单应该也能让你在项目里少走不少弯路。提示本文讨论的 GC 机制基于 Mono 运行时下的 Unity 项目IL2CPP 与 Burst 相关内容会单独提及但不会展开得太深。2. GC 到底是怎么把帧率搞崩的2.1 托管堆分配与回收的基本逻辑先补一个基础概念。Unity 里的 C# 脚本跑在 Mono 或 IL2CPP 运行时上它们在底层维护着一块托管堆Managed Heap。你在代码里 new 一个对象、拼接一个字符串、用一次List.Add导致内部数组扩容——这些操作都会在托管堆上分配内存。托管堆的内存不是用完就立刻还给操作系统的而是留在堆里等下一次分配复用。当堆里的空闲空间不足、无法满足新的分配请求时运行时就会触发一次 GC。GC 会遍历所有托管对象找出那些已经没有任何引用的垃圾对象把它们占用的内存回收回来必要时还会对堆进行压缩整理。这个过程本身没什么问题问题在于它发生得太突然。我在之前的项目里遇到过一种非常典型的场景一个刷怪玩法每波怪死亡时都会掉落大量掉落物每个掉落物对应一个包含多个字段的类实例。玩家在波次结束时连续拾取每拾取一个都会创建拾取提示、飘字、特效预制体引用等一堆临时对象。平时一切正常但当拾取数量累积到某个临界点、托管堆被填满时GC 一次触发就是 30 到 50 毫秒的停顿。在 60 FPS 的项目里一帧的预算只有 16.7 毫秒这一下直接掉到 20 帧以下。这个临界点就是你堆的高水位线。GC 的触发频率和单次耗时高度相关堆越小、分配越频繁GC 就越频繁堆越大、存活对象越多单次 GC 扫描和整理的时间就越长。而最糟糕的是在堆快满的时候才触发——那时可回收的对象很多但存活对象也不少GC 既要回收又要压缩耗时自然高。2.2 为什么 GC 会在游戏运行时间歇性发作游戏逻辑不像服务器程序那样持续稳定地分配内存它的分配模式是脉冲式的。玩家点击按钮、切换场景、释放技能、加载资源——这些时刻的分配量会瞬间激增。问题在于GC 的触发并不是均匀分布的。它只在分配请求无法被满足时才发生所以表现就是一段时间内堆内存一直够用你完全感知不到 GC 存在突然某个操作加大了分配量堆不够了GC 立刻触发你的帧时间曲线就会出现一个孤零零的尖峰。这个堆够用的阶段里托管堆其实已经被垃圾对象占了大半只是还没到触发线而已。换句话说GC 卡顿不是你某一行代码写错了导致的而是你整个项目的分配节奏和 GC 的触发机制天然不匹配。我做过一个简单的测试示例来理解这个现象在一个 Update 循环里每帧创建一个 1 KB 左右的临时字节数组然后用完就丢。看起来每一帧的开销都是毫秒级以下的但帧率曲线在 60 FPS 附近稳定几秒后就会规律性地出现一次 20 毫秒以上的尖峰。原因很简单每帧 1 KB 的分配量很小但连续 60 帧就是 60 KB几秒钟后堆空间耗尽GC 启动所有未引用的数组被回收——然后下一个循环周期又开始了。这就是 GC 卡顿最迷惑人的地方它不像 GPU 瓶颈那样持续稳定地拉低你的帧率而是间歇性发疯。你抓帧的时候很可能恰好错过了那一帧等你想复现的时候又怎么也复现不出来。2.3 分配量与 GC 耗时的量化关系在 Unity Profiler 里GC 的耗时通常标在 CPU 的GC Alloc和GarbageCollectAssets这类条目上。但这里有个比较坑的点Profiler 里显示的GC Alloc表示的是分配了多少字节而不是GC 停顿了多少毫秒。我之前见过不少团队看到某帧 GC Alloc 只有 2 KB就觉得这帧没问题。但真相是2 KB 的分配本身确实没什么成本但它可能刚好是压垮骆驼的最后一根稻草——这 2 KB 分配出去之后堆满了GC 被触发然后一卡就是几十毫秒。分配给 GC 的负债GC 可能晚几十帧才让你还。所以量化和排查 GC 问题时一定要关注两个维度一是分配量本身GC Alloc二是 GC 实际触发的次数和单次耗时。前者可以用 Profiler 的 CPU 模块直接看后者需要你结合帧时间曲线和日志里的GC.Collect来推算。实操建议在开发期打开 Unity 的Log Assert或者手动在关键帧节点记录GC.GetTotalMemory(false)和GC.CollectionCount(0)配合帧时间一起输出能很快定位哪些玩法操作会触发 GC 尖峰。3. Unity 项目里最常见的 GC 分配来源3.1 字符串拼接看似无害的隐形大户在所有 GC 分配的来源里字符串拼接几乎可以排到前三但大多数开发者对它完全没有警惕。一个很典型的例子void Update() { uiText.text Score: score.ToString(); }这行代码看起来人畜无害但实际上它执行了至少两次托管堆分配一次是score.ToString()创建了一个新的字符串对象另一次是Score: 拼接又创建了一个更大的字符串对象。如果这行代码放在 Update 里每帧都会产生两个字符串对象60 秒就是 7200 个垃圾字符串这些最终都要靠 GC 来回收。我在一个 UI 项目里做过一次彻底排查仅仅是把一个 HUD 上的飘字更新从字符串拼接改成预先提取数字前缀、再用StringBuilder复用GC Alloc 就从每帧约 8 KB 直接降到了近乎为 0。修改量不大但帧时间的毛刺肉眼可见地变少了。字符串问题在日志、调试信息和热更新系统里更严重。很多团队为了方便排查问题在战斗中频繁Debug.Log日志内容里又带大量拼接。开发期没问题但如果你不小心在发布版本里忘了关掉这些日志它们每一行都在制造垃圾对象GC 负担直接拉满。实操建议发布版本务必关闭不必要的Debug.Log或者用日志开关统一控制。一个简单的#if UNITY_EDITOR || DEVELOPMENT_BUILD宏就能把正式包的日志分配降到接近于零。字符串问题的完整治理方案包括UI 上频繁更新的文本用StringBuilder或预格式化的字符串模板替换拼接。数字转字符串时尽量避免每帧ToString()缓存常量或使用自定义的快速转换方法。日志系统里用string.Format也尽量少用它内部会产生装箱和临时数组。可以改用占位符拼接或使用专门的日志格式化器。热更框架里的配置表读取如果频繁取字符串字段考虑做好缓存。3.2 装箱Boxing每次调用都在交隐形税装箱是另一类高频分配。当一个值类型int、float、struct被赋给 object 类型的变量、作为 object 参数传给方法、或者被插值进字符串时CLR 会在堆上创建一个包装对象把值类型的数据拷贝进去。这个包装对象就是垃圾等你下次 GC 时被回收。最简单的例子object boxed 42; // 装箱 Debug.Log(Value: someInt); // 装箱 字符串拼接Debug.Log的重载里没有直接接收 int 的版本所以你会发现传int进去时它实际上走了object参数触发装箱。这种开销在每帧都执行的地方特别致命。装箱在 Unity 项目里几乎无法完全避免因为某些框架层 API 内部就会装箱但我们可以做到在热点代码里避免主动触发不要用object类型接收值类型变量。使用泛型方法T替代object参数避免隐式装箱。字符串拼接时避免拼接值类型先转成字符串再拼当然最好直接用 StringBuilder。事件系统和委托中避免传递值类型裸数据实在要传就包一层 struct 并复用。字典的 key 如果是 int、float 等值类型要注意部分 API 实现内部会装箱比较。尽量用自定义的Dictionaryint, T而非Dictionaryobject, T。注意装箱分配极其微小单次装箱可能只有几十字节但它单个 GC 周期内可能发生数万次积少成多GC 压力就是这么来的。3.3 LINQ 与闭包写法酷炫但对 GC 不友好LINQ 和 Lambda 闭包是现代 C# 里非常常见的写法但它们在 Unity 的 Mono 运行时下往往会产生大量隐形分配。拿Where举例var validTargets allTargets.Where(t t.IsAlive).ToArray();这段代码在运行时会发生什么Where返回的是迭代器迭代器本身是一个状态机对象需要分配t t.IsAlive这个 Lambda 如果捕获了外部变量还会生成一个闭包对象ToArray()又分配了一个数组。三处分配三个垃圾对象。在列表只有十几个元素的逻辑里这没什么但在每帧都要做的事里这种写法直接让你的 GC Alloc 飙升。我见过一个单位的 AI 系统每帧对单位列表做一次OrderBy排序取最近的敌人结果每帧分配了 4 到 5 个数组和迭代器对象直接导致 GC 频繁触发。需要注意的是并非所有 LINQ 都有相同的分配行为。比如ListT.ForEach在某些运行时下可以通过内部优化避免分配迭代器但Where.Select.OrderBy的链式调用几乎必然产生迭代器链。IL2CPP 下部分 LINQ 会做内联优化但也别指望它完全消除分配。实操建议如果你不想彻底放弃 LINQ至少确保它在低频调用如按钮点击中使用而不是放在 Update 或 OnGUI 等高频路径里。热点循环里建议自己写 for 循环 手动筛选。Lambda 闭包的问题类似。局部变量被捕获后编译器会生成一个闭包对象闭包对象本身在堆上分配。如果把 Lambda 传给一个不会立即执行、而是存下来之后调用的方法比如事件、协程、协程回调闭包对象的生命周期会拉长垃圾和内存占用都会上升。避免方法用普通的私有方法替代 Lambda 回调。如果必须用 Lambda确保它不捕获局部变量无捕获的 Lambda 可以被静态方法替代。热点循环内不要用带捕获的 Lambda。协程和事件注册的 Lambda 要注意注销时机避免闭包对象长期存活。3.4 协程与数组藏在异步逻辑里的持续分配协程Coroutine在 Unity 里的实现本身就会分配额外对象。每一个StartCoroutine调用都会创建一个协程对象如果协程体内有yield return每个yield也会触发一次状态机的对象分配。更麻烦的是许多常见的yield写法本身就会分配对象yield return new WaitForSeconds(1f);new WaitForSeconds每执行一次就创建一个新对象。一个每 5 秒触发一次的协程运行一分钟就产生了 12 个临时对象。如果项目里有几十个协程在跑这个分配量相当可观。我以前在项目里做过一个优化把全局常用的WaitForSeconds缓存成静态字段复用GC Alloc 立刻降了不少。另一种高频分配来源是数组和ListT。很多人习惯用new Listint()然后在循环里Add但ListT在容量不足时会自动扩容扩容操作会分配一个新数组并把旧数据拷贝过去。如果在 Update 里反复创建和扩容 List垃圾对象会越来越多。数组分配本身也不可避免但可以优化热点路径里用对象池Object Pool复用数组和 List。预先指定容量new ListT(capacity)减少扩容次数。避免在多帧循环中反复创建临时数组。用ArrayPoolT.NET Standard 2.1 或 Unity 的NativeArray应对高频临时数组需求。3.5 UI 与动画Text 和 IMGUI 的隐性分配Unity 的 UIUGUI在更新大量文本和布局时会产生比我们想象中更多的 GC 分配。很多新手以为 UI 卡顿是渲染问题但实际上Text.text的赋值、LayoutRebuilder的刷新、以及IMGUI的 OnGUI 都会持续分配。Text.text赋值会触发一次文本布局计算这个过程会分配临时的TextGenerationSettings、ListUIVertex等对象。如果你的 HUD 上有很多每帧都在变动的文本每秒分配的临时对象数量会非常可观。OnGUI的问题更严重。IMGUI 系统本身是即时模式GUILayout、GUIStyle的调用会在每帧都创建大量的临时对象尤其是在编辑器里。如果在正式包里还保留着 OnGUI 的调试面板那基本等于给 GC 加了持续压力。实操建议正式包中彻底清理 OnGUI 调用UI 文本更新频率尽可能降到仅在数值变化时更新而不是每帧都赋一次相同的字符串。优化 UI 分配的几个方向预生成文本对象只在内容变化时更新。使用TextMeshPro替代旧版 UGUI Text它的文本生成开销更低且支持SetText避免不必要的字符串分配。频繁刷新的列表考虑使用虚拟化列表如VirtualizingScrollRect或LoopListView。UI 动画如透明度、位移使用 DOTween 的回调尽量传入预缓存参数避免每帧 Lambda 闭包分配。4. 从 Profiler 到代码找到 GC 卡顿的真凶4.1 Profiler 的正确打开方式过滤掉干扰项GC 问题的排查第一步就是看 Profiler但新手很容易被海量信息带偏。具体操作打开 Window Analysis Profiler切到 CPU Usage Profiler 模块在左上角的Target Selection里选玩家设备或编辑器。然后在Hierarchy视图下可以看到每一帧的各种系统调用耗时和 GC Alloc 字节数。关键的一步在Hierarchy视图的右上角有一个排序方式下拉菜单切换到GC Alloc它会按每帧每个函数分配的字节数降序排列。这样你就能一眼看到谁在真正制造垃圾。我通常在优化前一帧画好一个基线快照记录帧时间、GC Alloc、GC 触发次数然后针对最大分配源逐项优化每优化一项就再看一次基线对比是否下降。不要让 Profiler 只当体检报告要让它当跟踪定位器。4.2 用 Deep Profile 精准定位分配点普通的 Profiler 在 Release 模式下抓到的分配信息是按函数聚合的它只能告诉你这个函数一共分配了 100 KB但具体是哪一行代码产生的得靠Deep Profile才能看到——但这个功能有坑开启后会让所有脚本方法都被插桩性能开销翻倍帧率会明显下降。所以我的习惯是先用普通 Profiler 定位到分配量大的顶层函数。然后只针对那个函数开启 Deep Profile 抓一次帧或者直接在该函数入口和关键分支处手动加计时和GC.GetTotalMemory打点。找到具体的分配代码行后关闭 Deep Profile改完代码后再用普通 Profiler 确认。注意Deep Profile抓到的 GC Alloc 数字会被插桩本身放大不要拿它当精确值只看相对趋势。编辑器下还有一个小技巧在 Console 窗口把Logging里的Exception打开跑一遍目标玩法如果看到大量Allocation报警某些版本会输出Allocation相关错误日志那基本可以确定这些分配源头都在日志里被你一网打尽了。4.3 帧时间曲线的毛刺怎么看GC 卡顿最典型的 Profiler 表现是帧时间曲线上出现孤立尖峰周围帧的耗时都正常。这时候不要只盯着尖峰那一帧看要看它前后几十帧的分配趋势。我之前处理过一个每隔几秒掉一次帧的诡异问题帧时间曲线每 6 秒左右就出现一个 35 毫秒左右的尖峰但 Profiler 的 CPU 耗时里并没有任何单帧函数超过 10 毫秒。排查了很久才发现是某个异步加载回调里每 6 秒执行一次每次会创建一张 2 MB 左右的临时 RenderTexture然后立刻丢弃。虽然没有在那一帧触发 GC但堆空间被持续挤占GC 的触发时机被推后到了另一帧于是尖峰就随机出现在了一个看起来完全无辜的帧上。所以排 GC 问题时记住一句话GC 尖峰出现的那一帧往往不是分配量最大的那一帧而是堆满的那一帧。真正的大户要去尖峰之前的那些帧里找。4.4 用 Unity Memory Profiler 看堆内部分布这里推荐一下 Unity 自带的 Memory Profiler 包com.unity.memoryprofiler它可以抓取托管堆的整体快照区分出每个对象类型占用的内存大小。虽然它不像 CPU Profiler 那样能定位到代码行但能帮你确认哪些类型的对象占用了大量托管堆空间。举个例子排查时发现托管堆总占用 200 MB但代码里找来找去只看到几 KB 的临时分配。这时候用 Memory Profiler 抓快照可能会发现某个缓存容器里存了大量永不清理的Texture2D或Material引用它们不是 GC 垃圾而是真正的内存占用大头。GC 卡顿优化的目标不只是减少分配量还要降低托管堆的峰值占用。堆越大GC 单次扫描的时间越长。Memory Profiler 能帮你找出那些看起来合理但长期占用堆内存的对象比如没有及时清理的列表、缓存了过多永久对象的容器、以及过度预加载的资源。5. 我能跑多快就多快Unity 项目里的 GC 优化实操5.1 对象池让高频临时对象起死回生对象池可以说是 Unity GC 优化的第一板斧。它解决的核心问题是让某些频繁创建销毁的对象不再被反复 new 出来。对象池的思路很简单创建一个容器预先生产一批对象用的时候从池里取用完了还回去而不是直接丢弃让 GC 回收。public class ObjectPoolT where T : class, new() { private readonly StackT _pool new StackT(); public T Get() { return _pool.Count 0 ? _pool.Pop() : new T(); } public void Release(T instance) { _pool.Push(instance); } }这只是个最朴素的实现。实际项目里对象池还需要考虑初始化、重置状态、容量上限、线程安全等问题。用对象池能直观减少分配量的经典场景子弹、敌人、掉落物等 GameEntity 的实例化与销毁。飘字、特效、伤害数字等生命周期极短的 UI 元素。频繁创建和释放的 List、数组等容器。频繁调用的协程对象把协程实例池化复用。我见过一个极端案例某射击游戏里子弹对象是纯 C# 类每次开火都 new 一个实例玩家火力全开时每秒创建几百个对象。加了对象池之后GC Alloc 直接降了一个数量级GC 触发频率肉眼可见地下降了。关于对象池的注意事项池里的对象必须彻底清理状态否则可能复用到脏数据。池容量要动态调整避免空闲时池占内存过大。如果对象持有 Unity 原生资源引用如GameObject、Texture池化时要特别注意引用泄漏。建议配合GameObject.Instantiate的预制体池使用而不是纯 C# 对象池。5.2 缓存与复用能拿到就不重造缓存和对象池的差别在于对象池管的是生命周期短、频繁创建销毁的对象缓存管的是内容昂贵、读取频繁的数据。两者经常配合使用。一个最典型的缓存场景是WaitForSecondspublic static class Yielders { private static readonly Dictionaryfloat, WaitForSeconds Cache new Dictionaryfloat, WaitForSeconds(); public static WaitForSeconds Wait(float seconds) { WaitForSeconds wfs; if (!Cache.TryGetValue(seconds, out wfs)) { wfs new WaitForSeconds(seconds); Cache.Add(seconds, wfs); } return wfs; } }这样协程里yield return Yielders.Wait(1f)就只会为每个不同的时长分配一次对象而不是每次协程执行时都 new。类似的缓存思路还能用在这些地方数字转字符串高频使用的整数0-100用静态数组预先生成字符串避免ToString()。UI 文本常用的格式化模板提前生成不要在每帧重新拼。反射结果如果项目用了大量反射比如配置表映射缓存 FieldInfo、MethodInfo、Type避免每次调用都查一遍。常用列表和数组容量固定的临时列表可以缓存复用比如每帧收集敌人目标时用同一个 List 清空再填。5.3 结构体替代类用值类型把分配抄近道在 C# 里struct值类型和class引用类型的关键区别在于结构体实例通常分配在栈上或内嵌到其他对象里不会在托管堆上单独分配类实例则一定在堆上分配是 GC 的直接管理对象。如果一段代码里创建了很多小对象但又不修改它们的内容可以考虑用struct替换class一个典型的例子是坐标数据结构// 堆分配版本 public class GridCell { public int X; public int Y; public int Type; } // 栈/值类型版本 public struct GridCellStruct { public int X; public int Y; public int Type; }new ListGridCell(1000)时用GridCell类会产生 1000 个堆对象而用GridCellStruct只产生一个数组里面内嵌 1000 个连续的结构体。GC 压力天差地别。不过结构体不是银弹。如果结构体被装箱比如放进object类型容器它同样会在堆上分配包装对象。结构体过大超过几个字段拷贝成本会超过引用传递的成本反而不划算。实操建议小于 16 字节的小数据、不需要继承多态的、生命周期短的数据结构优先考虑用 struct频繁修改内容且体积大的保留 class 更合适。5.4 慎用协程必要时改用异步/状态机协程虽然写起来方便但正如前面所说它本身就是 GC 分配大户。一个项目里如果协程满天飞GC 压力会非常明显。拿一个战斗系统的技能流程举例技能释放本身是个非常高频的操作如果每个技能都用协程去控制播放、等待、回调那么每放一个技能就产生至少 3 到 5 个协程对象和yield对象。如果一个玩家一秒钟放三个技能那就是每秒钟 10 到 15 个垃圾对象。协程的优化方向有几个缓存 WaitForSeconds / WaitForEndOfFrame / WaitForFixedUpdate 等 yield 对象实例这个效果立竿见影。用非协程方案替代简单延时逻辑比如用Invoke或自己维护的计时器系统。复杂异步流程用状态机或异步方法async/await重写如果 Unity 版本支持的话UniTask是一个不错的替代方案它把协程的分配降到了最低。举个简单的状态机替代协程的示例class SkillSequence { private float _timer; private int _state; public void Update(float dt) { switch (_state) { case 0: PlayAnimation(); _timer 0.5f; _state 1; break; case 1: _timer - dt; if (_timer 0) { ApplyDamage(); _state 2; } break; case 2: // 结束 break; } } }这个写法没有任何堆分配逻辑清晰度不亚于协程性能却好得多。5.5 字符串和日志的零分配改造字符串是 GC 大户但也不是完全没法做到几乎零分配。核心思路是把字符串拼接从每次生成新对象改成复用同一个可变字符缓冲区。StringBuilder是可以复用的工具对象。可以把它静态缓存起来每次使用前Clear()private static readonly StringBuilder _builder new StringBuilder(256); public static string BuildHudString(int score) { _builder.Clear(); _builder.Append(Score: ); _builder.Append(score); return _builder.ToString(); }注意ToString()仍然会产生一个字符串对象但至少拼接过程零分配。如果 UI 文本赋值支持直接给StringBuilder如 TextMeshPro 的SetText那连最后的ToString都能省掉。日志改造同理。写一个自己的Log类内部用 StringBuilder 和日志等级控制发布版本直接跳过所有调用public static class Log { [System.Diagnostics.Conditional(ENABLE_LOG)] public static void Info(string format, params object[] args) { // 内部用 StringBuilder 格式化避免 string.Format 装箱 } }用Conditional特性比#if宏更优雅Conditional会让编译器在未定义预处理符号时直接移除调用代码但要求方法的参数类型不能被省略。实际上如果你追求严格零分配最好的方式还是发布版直接不调用任何日志方法。5.6 用 ArrayPool 和 NativeArray 降低临时数组开销临时数组在 Unity 里也很常见。每帧计算一些临时的Vector3数组、RaycastHit数组用完就丢。这种需求可以用System.Buffers.ArrayPoolT来做它把一个池化的内存块借给你用完还回去不产生堆分配。using System.Buffers; var hits ArrayPoolRaycastHit.Shared.Rent(64); try { int count Physics.RaycastNonAlloc(ray, hits, 100f); // 使用 hits[0..count] } finally { ArrayPoolRaycastHit.Shared.Return(hits); }这段代码比RaycastAll返回新数组高效得多因为RaycastNonAlloc ArrayPool的组合不会分配新数组而是复用池里的内存。在 Unity 的 DOTS / Job System 里NativeArrayT也有类似作用。它分配的是非托管内存不受 GC 管理但必须手动Dispose()。用得好能彻底把临时数据搬出托管堆用得不好会造成内存泄漏。注意ArrayPool返回的数组长度可能大于请求的长度使用时必须基于count而不是array.Length遍历。6. GC 优化的常见坑与排查技巧6.1 别迷信 GC Alloc 0有些开发者把 GC Alloc 当成 fps 的替代指标看到某帧 GC Alloc 是 0 就放心了。但 GC 卡顿的根本原因是堆满后触发 GC而不是分配量本身。所以GC Alloc 是 0 不代表没有 GC可能只是分配发生在上一帧这帧恰好没新的分配。GC Alloc 是几十 KB 也不代表一定会卡关键要看它是否导致了 GC 触发。如果你把一帧的 GC Alloc 降到 0但上一帧分配了大量对象且堆已满那么下一帧仍然会触发一次大 GC。我在项目里见过最典型的例子团队花了一周把所有 Update 里的临时分配都清理干净了但帧时间还是每隔一段时间掉一次。最后发现是资源加载系统在后台持续加载 AssetBundle每加载一个 Bundle 都会创建一堆临时对象。那些对象不在 Update 路径里所以普通 Profiler 的逐帧分配列表里根本没有它们。排查经验GC 卡顿不止要看 Update 路径还要关注异步回调、协程、资源加载回调、物理回调OnCollisionEnter、OnTriggerStay等所有可能在帧间突发执行的代码路径。6.2 小心 过度优化牺牲可读性换性能GC 优化最大的陷阱之一就是过度追求零分配把代码写得像天书一样难维护。举个例子有些团队为了不产生字符串分配把每个 UI 文本的显示逻辑都改成手动拼接字符数组代码可读性瞬间下降改一处要动七八个地方。优化确实有效但对团队协作和后续维护是灾难。我的建议是建立分配预算高频路径Update、FixedUpdate、LateUpdate、OnGUI严格要求 GC Alloc 接近于零。中频路径按钮点击、技能释放、每 1 秒一次的逻辑允许少量分配但要有意识控制。低频路径场景加载、配置初始化分配量大一点问题不大因为 GC 可以被你主动安排在加载时的空闲帧。这种分级处理后你就不用在每一行代码上都战战兢兢把宝贵的优化精力集中在真正影响帧率的地方。6.3 你改了代码为什么卡顿还在我排查过很多改了但没效果的案例总结下来大多数原因是你优化的不是真正触发 GC 的那个分配点。GC 的触发在时间上是滞后的你清掉了一个 Update 里的分配源但 GC 尖峰可能在半秒后由其他分配源触发。所以优化时必须同时看分配总量和GC 触发次数改完之后再观察几分钟的帧时间曲线确认尖峰是否消失而不是只盯着一帧数据。隐藏的第三方库代码在分配合并。比如 DOTween、TextMeshPro、Unity UI 内部实现都有自己的缓存池和临时对象它们的分配行为在你自己的代码之外。Profiler 里看着像某个 UI 系统函数分配了很多但实际触发点可能在它的内部。这种时候不要贸然改库源码优先调整自己的调用方式比如减少 DOTween 的创建频率、避免 TMP 的 SetText 传入新字符串。编辑器自身的分配干扰。在 Editor 中跑游戏编辑器自身比如程序化导入、Asset Database 刷新、Inspector 刷新会产生大量托管分配和游戏本身的分配混合在一起。如果你在 Editor 里看到一个莫名其妙的 GC 尖峰先确认它是否是编辑器进程自身的开销。用真机测试能避免这个干扰。6.4 为什么真机表现和编辑器差很多这是 Unity 优化里非常经典的问题编辑器下的 GC 行为和真机差距极大。原因是编辑器的执行环境和 Mono/IL2CPP 运行时不完全相同Editor 有自己的编辑器代码和资源管理进程分配模式与独立 App 不一样。Editor 下的 GC 策略通常比真机宽松堆大小和触发阈值不同。IL2CPP 后托管堆的布局和 GC 策略增量式 GC、并发 GC也会发生变化。我遇到过的实际情况是同样一段代码在编辑器里每帧 GC Alloc 显示 5 KB帧率稳定 60但打包到 Android 真机上帧率只有 40 出头GC 尖峰非常明显。这是因为真机上的 Mono 运行时堆更小、GC 触发更频繁同样 5 KB 的分配在编辑器里根本不够触发一次 GC在真机上可能每两三秒就压垮一次堆。所以优化效果最好以真机 Profiler 为准编辑器数据只做趋势参考。在做发布包优化时务必确认 Build Target 是 IL2CPP 还是 Mono二者 GC 行为差异会直接影响你的优化结论。有条件的话在真机上用 Unity Profiler 的Autoconnect Profiler抓取数据别只看编辑器内的模拟结果。7. 给不同阶段开发者的 GC 优化清单7.1 还在学习期的开发者先养成分配意识如果你刚接触 Unity我的建议不是先背一堆优化技巧而是先养成写代码时思考分配的习惯。具体来说每写一个可能会被频繁调用的函数先想一下它是否会产生临时对象。打开 Profiler 时先看 GC Alloc 而不是只看帧率。多读优秀开源项目的源码留意他们是怎么处理字符串、列表、协程和回调的。不要等性能出现问题才去优化而是在写第一行代码时就有意识地选择低分配方案。我见过很多新手在项目初期写了大量 LINQ 和 Lambda 友好的代码等开发后期 Profiler 一上整个团队都在为这些代码买单。如果从一开始就控制得比较好后面几乎不需要大改。7.2 中期项目建立 GC Alloc 基线针对性优化如果你的项目已经进入中期功能很多代码已经成型那么别想着一次把所有 GC 问题解决那不可能也不现实。正确做法是用 Profiler 抓一次典型一局游戏的完整 GC Alloc 和 GC 触发记录建立基线。找出 TOP 5 的 GC 分配源逐项优化。每优化完一项跑一次同样的场景记录新的基线确认是否下降。反复迭代直到 GC Alloc 控制在每帧 1 KB 以内GC 触发频率降到几十秒一次甚至更低。这个阶段的优化清单我按优先级排序优先级优化项预期效果P0清理 Update 里所有字符串拼接、LINQ、装箱GC Alloc 直接下降 30%-50%P0缓存 WaitForSeconds 等 yield 对象减少协程产生的垃圾对象P1对象池化子弹、特效、飘字大幅减少高频临时对象分配P1减少 UI Text 的每帧刷新减少 UI 系统内部临时对象P2用 struct 替换部分小 class降低托管堆压力P2关闭发布版日志消除日志产生的隐性分配P3引入 ArrayPool / NativeArray消除临时数组分配7.3 大型项目从救火转向体系化治理项目大到一定规模单独的优化点已经解决不了问题了你需要一套可持续的治理机制。我在几个大型项目里采用的方案大致如下建立性能回归流水线在 CI 里跑一个固定的性能测试场景自动采集 GC Alloc、GC 触发次数、帧时间超过阈值就报警。制定代码规范与 Review 检查项要求热点路径禁止使用 LINQ、禁止无脑字符串拼接、禁止用new WaitForSeconds等。Code Review 时把 GC 分配作为必查项。建设共享的缓存与对象池库提供统一的ObjectPoolT、Yielders、日志封装、StringBuilder管理模块让团队不会各自造轮子。定期做 Profiler 专项巡检每个大版本上线前专门跑一遍各玩法的 GC 专项测试把 GC 尖峰消灭在发布之前。这样做了之后GC 问题就不会再是隔三差五冒出来一个每次都要花好几周去查的救火任务而是进入了一个可量化、可追踪、可持续的轨道。8. 最后分享一点实际体会GC 优化这件事做了几年之后最大的体会倒不是某个具体技巧而是看待卡顿的方式。以前我遇到卡顿第一反应是哪个系统太耗了去查它的 CPU 占用。后来才慢慢明白很多卡顿不是某个系统太耗而是某个系统的分配节奏刚好撞上了 GC 的触发机制。你把它改成零分配之后帧率不一定立刻变好但 GC 尖峰一定会减少——那种时不时卡一下的体验比单纯的帧率低要可怕得多。另外想说的一点是GC 优化永远不是一次性的工作。项目每加一个玩法、每引入一个新的第三方库都可能带回新的分配问题。保持分配意识建立定期巡检的习惯比憋一个大招一次性清干净更重要。如果你正在被看不见的卡顿折磨我的建议是先开 Profiler按 GC Alloc 排序找到最大的几个分配源一个一个改掉。目标不是追求绝对的 GC Alloc 0而是让 GC 的触发频率和单次耗时降到玩家感知不到的水平。这篇聊的是 GC 的机制、分配来源和优化手段。下一部分如果大家有兴趣我可以接着聊聊 IL2CPP 下的内存行为差异以及 Burst Job System 里如何彻底绕开托管堆。那部分玩起来才叫真正的性能自由。

相关推荐

SpringBoot+JSON表单引擎:5分钟配置一套动态审批流
SpringBoot+JSON表单引擎:5分钟配置一套动态审批流

重复的 CRUD 写多了真的会让人怀疑人生。前几年我在一家做企业信息化的小公司待过一阵子,几乎每个月都要接到一个新需求:做个报销单、做个领料单、做个请假审批、做个项目立项……表单长得都差不多,无非是字段多几个少几个,流程无… · 2026/9/26 14:18:25

MySQL binlog反序列化报错排查:Error while deserializing event at offset
MySQL binlog反序列化报错排查:Error while deserializing event at offset

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/26 14:18:19

UI专用小模型实战:用低成本打造可落地的AI生成界面工具
UI专用小模型实战:用低成本打造可落地的AI生成界面工具

最近两三年,AI 生成 UI 一直是个很热闹的方向,隔三差五就有人丢出一个“一句话生成登录页”“设计稿一键转前端代码”的 Demo。但如果你真的在团队里试过把这些方案落地,通常会被两个问题劝退:一是那些动不动几十 B 甚至上百 B 的… · 2026/9/26 14:18:12

Windows 11 25H2 离线安装 .NET 3.5 实战:DISM 命令与镜像源配置指南
Windows 11 25H2 离线安装 .NET 3.5 实战:DISM 命令与镜像源配置指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/26 14:54:06

STM32CubeMX 6.14保姆级教程:下载安装、时钟配置与固件包离线导入
STM32CubeMX 6.14保姆级教程:下载安装、时钟配置与固件包离线导入

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/26 14:54:06

微信手机切换账号电脑不退出?原理与四步解决方案
微信手机切换账号电脑不退出?原理与四步解决方案

1. 这个问题到底在说什么?为什么它让很多人抓狂“在电脑端登录微信后,手机切换微信账号,电脑端不退出”——这句话乍看像一句技术故障描述,但背后其实戳中了大量用户日常使用微信时最真实、最频繁的痛点。我做微信生态相关项目落地… · 2026/9/26 14:53:59

Atlas 300V 24G推理加速卡部署YOLO实战:从ATC转换到性能调优
Atlas 300V 24G推理加速卡部署YOLO实战:从ATC转换到性能调优

去年底我们做视觉检测项目选型,手里正好有一块Atlas 300V 24G,折腾YOLO部署踩了不少坑,也把整条链路摸清楚了。很多人听到“Atlas”第一反应是训练卡,其实300V 24G定位很明确,它就是一张推理运算加速卡,拿来… · 2026/9/26 14:53:59

DeepSeek-Coder生成可执行Python脚本与单元测试实战
DeepSeek-Coder生成可执行Python脚本与单元测试实战

简介:本资源是一份面向中高级开发者与AI工程实践者的深度技术指南,聚焦DeepSeek在自动化代码生成与单元测试领域的落地应用,解决传统开发中脚本编写重复、测试覆盖率低、交付周期长等核心痛点。文档为单文件PDF(1.75MB&#xff09… · 2026/9/26 14:53:59

轻量级数据采集网关脚手架:快速构建设备联网原型系统
轻量级数据采集网关脚手架:快速构建设备联网原型系统

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/26 14:53:59

数据库课后习题答案别硬背:当测试用例集刷,效率翻倍
数据库课后习题答案别硬背:当测试用例集刷,效率翻倍

简介:万常选版《数据库原理与设计》课后习题答案资源,覆盖第2至6章及第9章,适合正在学习关系模型、数据库建模、关系数据理论与模式求精的本科生、自学者作为复习与自测材料。压缩包共7个文件,含3个doc参考答案、2个sql示例脚本、… · 2026/9/26 0:00:21

OpenClaw 替代品?Hermes Agent 踩坑实录:macOS 飞书接入 TaoToken 配置
OpenClaw 替代品?Hermes Agent 踩坑实录:macOS 飞书接入 TaoToken 配置

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/26 0:00:40

向下兼容与向上兼容:接口设计中的兼容性策略与工程实践
向下兼容与向上兼容:接口设计中的兼容性策略与工程实践

一次版本升级事故,是很多团队绕不过去的坎。线上环境里,服务端明明已经上线了新版接口,老的移动端还在照着旧文档传参数。请求一到网关,校验直接拒绝,用户操作失败,客服群炸了锅,开发群里开始互… · 2026/9/26 0:00:46

了解更多?预约专属演示

我们的顾问将为您一对一讲解产品与方案

企业微信二维码