Unity 垃圾回收 GC
Unity 垃圾回收 (GC) 详解 - 卡顿元凶与优化策略
引言:GC 为什么是游戏的痛点
垃圾回收(Garbage Collection, GC) 自动回收不再使用的托管内存,省去手动 free 的心智负担。但对帧率敏感的游戏来说,它是一个隐患:
一次 GC 可能暂停整个游戏(Stop-The-World) 若干毫秒 —— 表现为周期性的卡顿尖峰(Hitching),即使平均帧率很好,帧时间的毛刺也会毁掉流畅感。
游戏优化里,GC 的目标往往不是"回收得快",而是尽量不产生垃圾,从根上避免回收发生。
预备:托管内存 vs 原生内存(GC 只管前者)
理解 GC 前,先分清 Unity 的两个内存世界 —— GC 只管其中一个。
| 托管内存(Managed) | 原生/非托管内存(Native) | |
|---|---|---|
| 由谁管 | 脚本运行时的 GC(Mono / IL2CPP 的 Boehm GC) | Unity 的 C++ 引擎 / 手动 |
| 装什么 | C# 托管对象(托管堆) | 网格/纹理/音频等资源数据、NativeArray、引擎内部分配 |
| 怎么释放 | GC 自动回收 | Destroy() / Dispose() / 引擎管理 |
| 是否产生 GC 压力 | 是(分配即 GC Alloc) | 否 |
托管内存 = GC 管理的 C# 托管堆,是 GC 和 GC Alloc 的作用范围。
什么在托管内存里
引用类型都在托管堆上(通过 new 分配):class 实例、数组、string、委托/闭包、装箱的值类型。
不在托管堆上的:值类型(int/float/struct/Vector3)作为局部变量在栈上、或内嵌在所属对象里;只有被装箱时才进托管堆。
关键陷阱:Unity 对象的"托管壳 + 原生数据"
很多 Unity 对象(GameObject、Texture2D、Mesh…)跨两个世界:
C# 侧:一个很小的"托管包装对象"(在托管内存)
│ 引用
▼
C++ 侧:真正的重数据(网格顶点/纹理像素…,在原生内存,很大)- 你手里的
Texture2DC# 对象很小(托管),但像素数据在原生内存、很大。 - 所以 GC 只能回收那个托管壳,回收不了原生数据 —— 这就是为什么必须
Destroy()/Resources.UnloadUnusedAssets()来释放资源的原生内存,光靠 GC 不够。 - DOTS /
NativeArray/ Burst 的数据在非托管内存,不受 GC 管,因而能绕开 GC 压力。
分清"什么会引发 GC、什么要手动 Destroy"是优化的基础。
一、Unity 的 GC 背景
- Unity 的托管代码(C#)运行在 Mono 或 IL2CPP 后端上,两者都使用 Boehm–Demers–Weiser 垃圾回收器。
- 它是一个 Stop-The-World(暂停式)、非分代(non-generational)、非压缩(non-compacting)、标记-清除(Mark-Sweep) 的保守式(conservative) GC。
| 特性 | 含义与后果 |
|---|---|
| Stop-The-World | GC 运行时暂停托管代码执行 → 帧时间尖峰 |
| 保守式(conservative) | 扫描内存、把"看起来像指针的值"都当引用 → 无法移动对象 |
| 非压缩 | 对象不能移动 → 可能堆碎片化,且难以把内存还给系统 |
| 非分代 | 每次回收扫描整个托管堆,无"只扫新生代"的优化 |
| 托管堆只涨不缩(历史) | 堆按需扩张后,通常不主动缩回系统(新版可释放部分) |
二、GC 的工作原理(标记-清除)
托管堆上的对象,GC 通过"从根出发标记可达对象、清除不可达对象"来回收:
- GC Roots:栈上的引用、静态字段、寄存器等。
- 标记:从 Roots 出发,把所有可达对象标记为"存活"。
- 清除:未被标记的即垃圾,回收其内存(但因非压缩,不整理,留下空洞)。
- 触发时机:主要是堆分配(
new)时发现需要扩张,或手动GC.Collect()。不分配就几乎不触发。
三、增量式 GC(Incremental GC)
为缓解 Stop-The-World 的单帧尖峰,Unity(2019+)提供增量式 GC:把标记阶段拆分到多帧执行,每帧只做一小片,从而把"一次大停顿"摊成"多次小停顿"。
下图对比了两者的帧时间(示意):

- 代价:需要写屏障(write barrier) 跟踪增量期间被修改的引用,带来轻微的常态开销,且 GC 总时间可能略增。
- 收益:单帧尖峰显著降低,更容易守住帧预算(如 60 FPS 的 16.6ms)。
- 开关:Player Settings 里的 Use Incremental GC。
- 注意:增量 GC 减轻尖峰,但治标不治本 —— 根本手段仍是少产生垃圾。
四、垃圾从哪来:常见堆分配来源
在 Unity 里,以下操作会在托管堆上产生垃圾(尤其每帧/热路径里要警惕):
| 来源 | 说明 |
|---|---|
| 装箱(Boxing) | 值类型(int/struct)被当作 object 使用(如 string.Format 参数、非泛型集合) |
| 字符串操作 | string 不可变,拼接/格式化都分配;用 StringBuilder,热路径避免字符串 |
| LINQ | 分配枚举器、闭包,热路径避免 |
| 闭包 / lambda 捕获 | 捕获外部变量的匿名方法会分配闭包对象 |
| 返回数组的 API | Mesh.vertices、GetComponents()、Physics.RaycastAll、Input.touches 等每次返回新数组 |
Camera.main | 内部做查找,慢且分配 → 应缓存引用 |
| 协程 yield | yield return new WaitForSeconds(t) 每次分配 → 缓存复用 |
Debug.Log | 字符串格式化 + 分配;发布版应剔除热路径日志 |
| 集合扩容 | List/Dictionary 超容量时重新分配底层数组 |
| Instantiate / Destroy | 频繁创建销毁对象 → 用对象池 |
五、优化手段
核心思路:复用而非新建、缓存而非重取、预分配而非运行时分配。
1. 对象池(Object Pooling)
频繁 Instantiate/Destroy(子弹、特效、敌人)改为预先创建一批、循环复用,避免分配与回收。Unity 内置 ObjectPool<T>。
2. 缓存引用
// 差:每帧查找 + 分配
void Update() { transform.LookAt(Camera.main.transform); }
// 好:缓存
Camera cam;
void Start() { cam = Camera.main; }
void Update() { transform.LookAt(cam.transform); }同理缓存 GetComponent 结果、WaitForSeconds 实例:
// 差:每次 yield 都分配
yield return new WaitForSeconds(1f);
// 好:复用
WaitForSeconds wait = new WaitForSeconds(1f);
while (true) { yield return wait; }3. 复用容器,用非分配 API
// 差:每帧新建 List / 返回新数组
List<Collider> results = new List<Collider>();
Collider[] hits = Physics.OverlapSphere(p, r);
// 好:复用 + NonAlloc 版本
readonly List<T> _buffer = new List<T>();
GetComponents(_buffer); // 传入缓冲,不分配
Physics.OverlapSphereNonAlloc(p, r, _preallocatedArray);4. 避免装箱与字符串垃圾
- 用泛型集合(
List<int>而非ArrayList),避免值类型装箱。 - 热路径用
StringBuilder;UI 数字更新用缓存/避免频繁格式化。
5. 预分配 / 值类型 / 非托管容器
- 启动时预分配数组与集合。
- 用
struct减少堆分配(但注意别再被装箱)。 NativeArray/ Unity.Collections(非托管、不受 GC 管理)+ DOTS / Burst:从根本上绕开托管堆,几乎零 GC 压力。
六、测量:让垃圾可见
| 工具 | 用途 |
|---|---|
| Unity Profiler 的 GC Alloc 列 | 看每帧的托管分配量;热路径稳态目标是 0 B/帧 |
| Deep Profile | 定位具体是哪个调用在分配 |
| Memory Profiler 包 | 堆快照,分析对象常驻与泄漏 |
| Profiler 的 GC.Collect 标记 | 观察 GC 何时触发、耗时多少 |
优化 GC 的实操闭环:Profiler 找到有 GC Alloc 的帧 → Deep Profile 定位分配点 → 用上面的手段消除 → 复测确认稳态 0 分配。
七、Mono / IL2CPP / DOTS 的差异
| 后端 / 方案 | GC 情况 |
|---|---|
| Mono | Boehm GC,支持增量 GC |
| IL2CPP | C++ AOT 编译,仍用 Boehm GC,GC 行为类似;整体运行时性能通常更好 |
| DOTS + Burst + NativeContainers | 数据在非托管内存,Burst 代码不产生托管分配 → 基本无 GC 压力 |
八、最佳实践速查
| 原则 | 做法 |
|---|---|
| 不产生垃圾 > 快速回收 | 消除每帧分配,让 GC 无事可做 |
| 复用 | 对象池、复用集合、缓存 WaitForSeconds |
| 缓存 | 缓存 GetComponent / Camera.main 等结果 |
| 非分配 API | ...NonAlloc、GetComponents(list) 传缓冲 |
| 避开装箱/字符串/LINQ | 热路径尤其注意 |
| 预分配 | 启动时分配,运行时不分配 |
| 考虑增量 GC | 摊平尖峰(但仍要少产生垃圾) |
| 极致场景上 DOTS/Burst | 非托管容器绕开 GC |
| 持续测量 | 以 Profiler 的 GC Alloc = 0 为稳态目标 |
总结
Unity 的 GC 基于 Boehm 保守式 Mark-Sweep 收集器,具有 Stop-The-World、非压缩、非分代的特性,会在游戏运行时产生帧时间尖峰。GC 只在托管堆上工作,而 Unity 对象的原生数据(纹理、网格等)需手动释放。
优化的核心思路是消除堆分配而非加速回收:通过对象池复用对象、缓存高频引用、使用 NonAlloc API、复用容器、避免装箱和字符串操作、预分配运行时结构,并在极致场景下使用 DOTS/Burst + NativeContainers 绕开托管堆。增量 GC 可将单帧停顿分摊到多帧,缓解卡顿尖峰。持续使用 Profiler 监测 GC Alloc,以每帧 0 B 分配为优化目标。
附:核心概念速查
| 概念 | 说明 |
|---|---|
| Boehm GC | Unity 使用的保守式、非压缩、Mark-Sweep 收集器 |
| Stop-The-World | GC 运行时暂停托管代码 → 帧时间尖峰 |
| Incremental GC | 把标记分摊到多帧,靠写屏障跟踪引用变化 |
| 托管堆(Managed Heap) | C# 对象所在、受 GC 管理的内存 |
| GC Alloc | Profiler 中每帧的托管分配量,优化目标是 0 |
| 对象池 | 预建复用对象,避免分配/回收 |
| NativeArray | 非托管容器,不受 GC 管理(DOTS/Burst) |
