☰
Unity 中 Mono GC 的 Stop-the-World 机制
2026/10/21 1:15:17 网站建设 项目流程

一句话结论:

垃圾回收器要判断哪些对象"还有人在用",而游戏代码在运行时会不断修改引用关系。为了防止"边数边动"导致数错,GC 会先暂停所有托管线程,数完再恢复。暂停的这段时间就是 Stop-the-World(STW),它在游戏里表现为偶发的卡顿。

下面分几步讲:Unity 用的到底是哪个 GC、为什么必须暂停、一次暂停的具体过程、暂停时长由什么决定、游戏中的典型案例,以及增量 GC 和优化方法。


一、先搞清楚:Unity 里的 “Mono GC” 是谁

很多资料说"Mono 的 GC 是 SGen",这对官方 Mono 运行时成立,但Unity 用的不是 SGen。

官方 Mono(Xamarin 等)Unity(Mono 后端和 IL2CPP 后端)
GC 实现SGenBoehm-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向每个线程发送一个专用信号,线程在信号处理函数里把寄存器保存到栈上,然后挂起等待"恢复"信号
WindowsSuspendThread+GetThreadContext
macOS / iOSMach 内核接口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 用的什么 GCBoehm:非分代、非压缩、保守扫描栈,Mono 和 IL2CPP 都用它
为什么要 Stop-the-World标记期间如果引用被修改,可能漏标活对象并错误回收;暂停所有托管线程最简单可靠
什么时候发生分配内存时托管堆不够用,或调用GC.Collect(),在发起分配的线程上同步执行
停多久主要取决于存活对象和引用的数量、线程数、设备性能
游戏中的表现高频分配 → 周期性小卡顿;存活对象多 → 偶发的长卡顿
怎么优化热路径零分配、减少常驻托管对象、开启增量 GC、在加载或过场时主动回收

一句话记住:

GC 盘点时必须让所有人站着别动。盘点的人越多(存活对象越多),每次站得越久;乱扔东西越多(分配越多),盘点越频繁。游戏要流畅,两件事都得管。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询