一句话结论:
垃圾回收器要判断哪些对象"还有人在用",而游戏代码在运行时会不断修改引用关系。为了防止"边数边动"导致数错,GC 会先暂停所有托管线程,数完再恢复。暂停的这段时间就是 Stop-the-World(STW),它在游戏里表现为偶发的卡顿。
下面分几步讲:Unity 用的到底是哪个 GC、为什么必须暂停、一次暂停的具体过程、暂停时长由什么决定、游戏中的典型案例,以及增量 GC 和优化方法。
一、先搞清楚:Unity 里的 “Mono GC” 是谁
很多资料说"Mono 的 GC 是 SGen",这对官方 Mono 运行时成立,但Unity 用的不是 SGen。
| 官方 Mono(Xamarin 等) | Unity(Mono 后端和 IL2CPP 后端) | |
|---|---|---|
| GC 实现 | SGen | Boehm-Demers-Weiser GC(简称 Boehm) |
| 是否分代 | 是(新生代 + 老年代) | 否,每次都检查整个托管堆 |
| 是否移动对象(压缩) | 是 | 否,对象分配后地址不变 |
| 栈扫描方式 | 大体精确 | 保守式:栈和寄存器上任何"看起来像指针"的值都当作指针 |
| 增量模式 | — | Unity 2019.1 起支持,新版本中多数平台默认开启 |
所以在 Unity 里讨论"Mono GC 的 STW",实际讨论的是Boehm 的非分代、非压缩、标记-清除式回收。不管你选 Mono 还是 IL2CPP 脚本后端,GC 行为基本一致。
这几个特性直接决定了后面所有结论:
- 非分代:每次回收都要把所有存活对象标记一遍,存活对象越多,每次越慢;
- 非压缩:不会整理内存碎片,托管堆容易变大,且很难缩回去;
- 保守扫描:偶尔会有本该回收的对象因为栈上某个整数"长得像它的地址"而被保留下来。
二、为什么必须"停下全世界"
2.1 GC 的基本工作:从根出发,找出所有还活着的对象
根(Roots): - 各线程的栈和寄存器(局部变量、参数) - 静态字段 - 运行时内部持有的引用(GCHandle 等) 标记:从根出发,沿着引用一层层走,走到的对象都标记为"活着" 清除:没被标记的对象就是垃圾,内存可以回收2.2 如果不暂停,会出什么事:商场盘点的例子
想象一个商场要盘点"哪些货品还挂在货架上",盘点员从 A 区走到 Z 区,挨个打勾。没打勾的货,晚上统一清走。
如果盘点时顾客还在逛:
1. 盘点员检查完 A 货架,打勾,走向 B 货架 2. 一个顾客把 B 货架上的一件衣服,挂到了已经检查完的 A 货架上 3. 同时把 B 货架上原来那个位置空了出来 4. 盘点员检查 B 货架:这件衣服不在这儿 → 不打勾 5. A 货架已经检查过了,不会再回去看 6. 晚上:这件衣服没被打勾 → 被当成垃圾清走了但它明明还挂在 A 货架上,有人要用!
换成代码:
classPlayer{publicWeaponweapon;}classBag{publicWeaponslot;}// GC 正在标记中……// GC 已经扫描过 player(黑色:自己和字段都检查完了)// GC 还没扫描 bag(白色:尚未检查)player.weapon=bag.slot;// 游戏线程:把武器从背包装到玩家身上bag.slot=null;// 游戏线程:背包里清空// GC 继续扫描 bag:slot 是 null,什么都没找到// player 已经扫过,不会再看// → weapon 对象从未被标记 → 被当作垃圾回收// → 之后访问 player.weapon,读到的是被释放的内存这就是 GC 理论中的"三色标记丢失"问题:已检查完的对象,指向了一个尚未被发现的对象,而原来唯一能找到它的路径被切断了。
解决办法只有两种:
| 办法 | 做法 | Boehm 的选择 |
|---|---|---|
| 让顾客别动(STW) | 盘点期间关门,所有线程暂停 | 默认模式 |
| 装监控摄像头(写屏障) | 允许顾客走动,但每次挪货都登记,盘点员事后复查登记本 | 增量模式(见第六节) |
三、一次 STW 的完整过程
3.1 时间线
游戏主线程: ...执行脚本... new 一个对象 → 托管堆空间不够了 │ ▼ ┌─────── GC 开始 ───────┐ ① 触发判断 │ 是扩大堆,还是先回收? │ ② 暂停所有托管线程 ────────┤ 主线程、Job 中的托管线程、│ ← 从这里开始,所有托管代码都不动了 ③ 扫描根 │ 你自己 new 的 Thread… │ ④ 标记存活对象 │ │ ⑤ 恢复所有线程 ────────────┤ │ ← 暂停结束 └─────────────────────────┘ ⑥ 清除:大多延迟到之后分配内存时逐块进行(懒清除) ...继续执行脚本...3.2 每一步在做什么
① 触发
最常见的触发是:分配内存时,现有托管堆里找不到足够的空闲块。此时 Boehm 会根据已分配量和堆大小的比例,决定是先做一次回收,还是直接向系统申请更多内存扩大堆。另外,调用System.GC.Collect()会强制触发一次完整回收。
所以,GC 是在某次new的时候、在那个线程上同步发生的。你在 Profiler 里会看到它挂在某个普通函数调用的下面,比如某个Update里的字符串拼接。
② 暂停线程
Boehm 需要让所有在托管运行时注册过的线程停下来,并拿到它们的寄存器和栈范围。不同平台的做法不同:
| 平台 | 暂停方式 |
|---|---|
| Linux / Android | 向每个线程发送一个专用信号,线程在信号处理函数里把寄存器保存到栈上,然后挂起等待"恢复"信号 |
| Windows | SuspendThread+GetThreadContext |
| macOS / iOS | Mach 内核接口thread_suspend+thread_get_state |
GC 线程要等所有线程都确认已暂停,才能开始扫描。线程越多,这一步越慢。
③ 扫描根
- 每个被暂停线程的整个栈和寄存器(保守扫描:逐个字检查是否落在托管堆范围内);
- 静态字段等全局数据区;
- 运行时登记的其他根。
④ 标记
从根出发,遍历对象之间的引用。Unity 的实现为托管堆上的对象提供了类型布局信息,GC 知道对象的哪些字段是引用;不含引用的数据(如int[]、byte[]、只含值类型字段的结构体数组)基本不需要扫描其内部。
⑤ 恢复线程
标记结束,所有线程恢复执行。STW 到此结束。
⑥ 清除
Boehm 通常不在暂停期间一次性清理全部垃圾,而是在之后分配内存时按需逐块清理。所以 STW 的主要成本在 ② ~ ④。
四、暂停多久由什么决定
这是最容易误解的地方:
STW 的时长,主要取决于"还活着的东西有多少",而不是"垃圾有多少"。
| 因素 | 影响 | 原因 |
|---|---|---|
| 存活对象的数量 | 最大 | 非分代:每次都要把所有存活对象标记一遍 |
| 存活对象之间的引用数 | 大 | 每个引用都要跟进去检查 |
| 托管线程数和栈深度 | 中 | 暂停要逐个线程确认,栈要逐字扫描 |
| 垃圾的数量 | 小 | 垃圾不会被标记;清除又大多延迟进行 |
| 设备 CPU 性能 | 大 | 同样的堆,低端手机可能比 PC 慢数倍 |
对比分代 GC:SGen 这类分代回收器,大部分时候只回收新生代,存活了很久的大量老对象不需要每次都检查。Boehm 没有这个优化,所以:
- 垃圾产生得多 → GC触发得频繁;
- 存活对象多 → 每次 GC停得久。
这两件事是独立的,需要分别优化。
五、游戏中的典型案例
以下数字为说明原理的示意值,实际数值取决于项目和设备。
案例一:ARPG 的伤害飘字,“打着打着就顿一下”
一款手机 ARPG,玩家反馈"团战时每隔几秒卡一下"。Profiler 中主线程偶尔出现一帧 30~40ms 的尖峰,展开后能看到GC.Collect。
查到的代码:
voidOnHit(intdamage,boolcrit){// 每次命中都会产生新字符串label.text=(crit?"暴击 ":"")+"-"+damage.ToString();// 每次命中都会分配一个新数组varrenderers=target.GetComponentsInChildren<Renderer>();foreach(varrinrenderers)r.material.color=Color.red;// .material 还会复制材质实例// Lambda 捕获了局部变量 → 每次都分配闭包对象StartCoroutine(Delay(0.2f,()=>ResetColor(target)));}团战时,一秒内几十个单位互相打出上百次伤害,每次命中分配几百字节到几 KB。托管堆很快被填满,于是每隔几秒就触发一次 GC。GC 一次的成本又和存活对象数量有关,这款游戏常驻内存中有大量配置和对象池,每次标记都要好几十毫秒。
在 30 帧(每帧约 33ms)下,这一帧直接超时,玩家感受到的就是"顿一下"。
修法:让战斗中的热路径不分配内存。
// 1. 飘字:数字转字符串做缓存,或使用支持直接设置数值的文本接口staticreadonlystring[]s_DamageCache=BuildCache(0,9999);// 2. 组件:初始化时缓存一次Renderer[]_renderers;voidAwake()=>_renderers=GetComponentsInChildren<Renderer>();// 3. 颜色:用 MaterialPropertyBlock,不复制材质staticreadonlyMaterialPropertyBlocks_Mpb=newMaterialPropertyBlock();staticreadonlyints_ColorId=Shader.PropertyToID("_BaseColor");// 4. 延迟回调:避免闭包,或用对象池管理的计时器案例二:卡牌游戏的"配置表炸弹",GC 不频繁但每次都很久
另一款卡牌 RPG,战斗中几乎不分配内存,GC 触发并不频繁。但每次触发都卡 100ms 以上。
原因是:游戏启动时把几十张 Excel 配置表全部解析进内存,变成几百万个托管对象:
classSkillConfig{publicintid;publicstringname;// 每个字符串都是一个独立对象publicList<int>effects;// 每个 List 是一个对象,内部数组又是一个对象publicDictionary<string,float>args;// 又是一串对象}Dictionary<int,SkillConfig>allSkills;// 几万条这些对象永远不会变成垃圾,但由于 Boehm 不分代,每次 GC 都要把它们完整遍历一遍。哪怕这次只回收了 1KB 的垃圾,也要先标记完这几百万个对象。
修法:减少存活对象和引用的数量。
| 做法 | 原理 |
|---|---|
| 用结构体数组代替类对象列表 | 一个SkillConfig[](结构体)是一个对象;一万个类实例是一万个对象 |
| 字符串改为 ID 或索引 | 少一个对象,少一次引用跟踪 |
| 不常用的表按需加载、用完释放 | 存活集更小 |
大块纯数值数据用NativeArray或二进制缓冲按需读取 | 不在托管堆上,或不含引用,不需要逐个标记 |
两个案例对比
| 案例一(飘字) | 案例二(配置表) | |
|---|---|---|
| 症状 | 每隔几秒卡一下 | 偶尔卡一下,但卡很久 |
| 根本原因 | 分配多 →触发频繁 | 存活对象多 →每次很慢 |
| 优化方向 | 减少分配 | 减少存活对象和引用 |
六、增量 GC:把一次大暂停拆成很多小暂停
6.1 原理:盘点时装上监控摄像头
Unity 2019.1 起提供增量 GC(Player Settings 中的Use Incremental GC)。它的思路是:
- 标记工作不再一口气做完,而是拆成很多片,每帧只做一小段(默认目标约 3ms 左右,可调);
- 两片之间游戏照常运行,引用关系会改变;
- 为了不出现第二节那种"漏标",编译器在每次给引用字段赋值时插入写屏障:记录"谁的引用被改了",GC 之后会重新检查这些对象。
回到商场的比喻:盘点分成很多天,每天盘一个区;顾客可以照常走动,但每挪动一件货,摄像头都会记下来,盘点员最后对着记录补查。
6.2 它不是银弹
| 限制 | 说明 |
|---|---|
| 仍然有 STW | 标记的开始和收尾阶段仍需短暂暂停所有线程,只是总时长被打散 |
| 写屏障有开销 | 每次引用赋值都多几条指令,引用操作密集的代码整体会略慢 |
| 可能"追不上" | 如果分配速度太快,或引用改动太频繁,增量标记跟不上,GC 会退回到一次性完整回收,卡顿照样出现 |
| 总工作量不变 | 只是把卡顿摊平到多帧,并没有减少需要标记的对象 |
所以增量 GC 能缓解案例二的"单次长卡顿",但无法弥补案例一那种高频分配。减少分配仍然是根本。
在 Profiler 中,增量 GC 的工作通常表现为分散在多帧中的GC.MarkDependencies片段,而不是一个巨大的GC.Collect尖峰。
七、控制 GC 的时机
除了减少分配和存活对象,还可以主动选择 GC 在什么时候发生:
usingUnityEngine.Scripting;// 进入战斗:关闭自动 GC,战斗中不会因为分配而触发回收GarbageCollector.GCMode=GarbageCollector.Mode.Disabled;// 战斗结束、黑屏过场、打开结算界面时:手动回收GarbageCollector.GCMode=GarbageCollector.Mode.Enabled;System.GC.Collect();// 或者使用手动模式,在空闲帧里做有时间上限的增量回收(需开启增量 GC)GarbageCollector.GCMode=GarbageCollector.Mode.Manual;GarbageCollector.CollectIncremental(2_000_000);// 本次最多花 2ms(单位纳秒)注意:
- 关闭 GC 期间,托管堆只增不减。如果战斗中分配很多,内存会持续上涨,在内存紧张的手机上可能被系统强杀;
- 这种做法适合单局时长可控、局内分配已经很少的游戏(如一局几分钟的竞技对战),配合加载界面、结算界面做回收;
- 由于 Boehm 不压缩、难以归还内存,托管堆的峰值往往就是它之后的常驻大小,峰值本身也要控制。
八、常见误区
| 误区 | 实际情况 |
|---|---|
| “GC 只影响主线程” | STW 暂停所有托管线程,包括你自己创建的托管Thread |
| “垃圾越多,GC 越慢” | 垃圾多主要导致 GC 更频繁;单次时长主要由存活对象决定 |
| “开了增量 GC 就不卡了” | 分配过快时会退回完整回收;且开始与收尾仍有短暂停顿 |
| “IL2CPP 没有 GC 问题” | IL2CPP 同样使用 Boehm,行为基本相同 |
| “Unity 的 GC 和 .NET 桌面版一样是分代的” | 不是。Unity 的 Boehm 不分代、不压缩 |
“GC.Collect()后内存一定下降” | 托管堆通常不会立刻缩小,内存数字可能不变,只是有了更多空闲块供后续复用 |
总结
| 问题 | 答案 |
|---|---|
| Unity 用的什么 GC | Boehm:非分代、非压缩、保守扫描栈,Mono 和 IL2CPP 都用它 |
| 为什么要 Stop-the-World | 标记期间如果引用被修改,可能漏标活对象并错误回收;暂停所有托管线程最简单可靠 |
| 什么时候发生 | 分配内存时托管堆不够用,或调用GC.Collect(),在发起分配的线程上同步执行 |
| 停多久 | 主要取决于存活对象和引用的数量、线程数、设备性能 |
| 游戏中的表现 | 高频分配 → 周期性小卡顿;存活对象多 → 偶发的长卡顿 |
| 怎么优化 | 热路径零分配、减少常驻托管对象、开启增量 GC、在加载或过场时主动回收 |
一句话记住:
GC 盘点时必须让所有人站着别动。盘点的人越多(存活对象越多),每次站得越久;乱扔东西越多(分配越多),盘点越频繁。游戏要流畅,两件事都得管。