资讯动态

Unity 中 Mono GC 的 Stop-the-World 机制

发布时间:2026/10/21 1:15:24 来源:尧图企业网站定制
一句话结论垃圾回收器要判断哪些对象还有人在用而游戏代码在运行时会不断修改引用关系。为了防止边数边动导致数错GC 会先暂停所有托管线程数完再恢复。暂停的这段时间就是 Stop-the-WorldSTW它在游戏里表现为偶发的卡顿。下面分几步讲Unity 用的到底是哪个 GC、为什么必须暂停、一次暂停的具体过程、暂停时长由什么决定、游戏中的典型案例以及增量 GC 和优化方法。一、先搞清楚Unity 里的 “Mono GC” 是谁很多资料说Mono 的 GC 是 SGen这对官方 Mono 运行时成立但Unity 用的不是 SGen。官方 MonoXamarin 等UnityMono 后端和 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.weaponbag.slot;// 游戏线程把武器从背包装到玩家身上bag.slotnull;// 游戏线程背包里清空// GC 继续扫描 bagslot 是 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向每个线程发送一个专用信号线程在信号处理函数里把寄存器保存到栈上然后挂起等待恢复信号WindowsSuspendThreadGetThreadContextmacOS / iOSMach 内核接口thread_suspendthread_get_stateGC 线程要等所有线程都确认已暂停才能开始扫描。线程越多这一步越慢。③ 扫描根每个被暂停线程的整个栈和寄存器保守扫描逐个字检查是否落在托管堆范围内静态字段等全局数据区运行时登记的其他根。④ 标记从根出发遍历对象之间的引用。Unity 的实现为托管堆上的对象提供了类型布局信息GC 知道对象的哪些字段是引用不含引用的数据如int[]、byte[]、只含值类型字段的结构体数组基本不需要扫描其内部。⑤ 恢复线程标记结束所有线程恢复执行。STW 到此结束。⑥ 清除Boehm 通常不在暂停期间一次性清理全部垃圾而是在之后分配内存时按需逐块清理。所以 STW 的主要成本在 ② ~ ④。四、暂停多久由什么决定这是最容易误解的地方STW 的时长主要取决于还活着的东西有多少而不是垃圾有多少。因素影响原因存活对象的数量最大非分代每次都要把所有存活对象标记一遍存活对象之间的引用数大每个引用都要跟进去检查托管线程数和栈深度中暂停要逐个线程确认栈要逐字扫描垃圾的数量小垃圾不会被标记清除又大多延迟进行设备 CPU 性能大同样的堆低端手机可能比 PC 慢数倍对比分代 GCSGen 这类分代回收器大部分时候只回收新生代存活了很久的大量老对象不需要每次都检查。Boehm 没有这个优化所以垃圾产生得多 → GC触发得频繁存活对象多 → 每次 GC停得久。这两件事是独立的需要分别优化。五、游戏中的典型案例以下数字为说明原理的示意值实际数值取决于项目和设备。案例一ARPG 的伤害飘字“打着打着就顿一下”一款手机 ARPG玩家反馈团战时每隔几秒卡一下。Profiler 中主线程偶尔出现一帧 3040ms 的尖峰展开后能看到GC.Collect。查到的代码voidOnHit(intdamage,boolcrit){// 每次命中都会产生新字符串label.text(crit?暴击 :)-damage.ToString();// 每次命中都会分配一个新数组varrendererstarget.GetComponentsInChildrenRenderer();foreach(varrinrenderers)r.material.colorColor.red;// .material 还会复制材质实例// Lambda 捕获了局部变量 → 每次都分配闭包对象StartCoroutine(Delay(0.2f,()ResetColor(target)));}团战时一秒内几十个单位互相打出上百次伤害每次命中分配几百字节到几 KB。托管堆很快被填满于是每隔几秒就触发一次 GC。GC 一次的成本又和存活对象数量有关这款游戏常驻内存中有大量配置和对象池每次标记都要好几十毫秒。在 30 帧每帧约 33ms下这一帧直接超时玩家感受到的就是顿一下。修法让战斗中的热路径不分配内存。// 1. 飘字数字转字符串做缓存或使用支持直接设置数值的文本接口staticreadonlystring[]s_DamageCacheBuildCache(0,9999);// 2. 组件初始化时缓存一次Renderer[]_renderers;voidAwake()_renderersGetComponentsInChildrenRenderer();// 3. 颜色用 MaterialPropertyBlock不复制材质staticreadonlyMaterialPropertyBlocks_MpbnewMaterialPropertyBlock();staticreadonlyints_ColorIdShader.PropertyToID(_BaseColor);// 4. 延迟回调避免闭包或用对象池管理的计时器案例二卡牌游戏的配置表炸弹GC 不频繁但每次都很久另一款卡牌 RPG战斗中几乎不分配内存GC 触发并不频繁。但每次触发都卡 100ms 以上。原因是游戏启动时把几十张 Excel 配置表全部解析进内存变成几百万个托管对象classSkillConfig{publicintid;publicstringname;// 每个字符串都是一个独立对象publicListinteffects;// 每个 List 是一个对象内部数组又是一个对象publicDictionarystring,floatargs;// 又是一串对象}Dictionaryint,SkillConfigallSkills;// 几万条这些对象永远不会变成垃圾但由于 Boehm 不分代每次 GC 都要把它们完整遍历一遍。哪怕这次只回收了 1KB 的垃圾也要先标记完这几百万个对象。修法减少存活对象和引用的数量。做法原理用结构体数组代替类对象列表一个SkillConfig[]结构体是一个对象一万个类实例是一万个对象字符串改为 ID 或索引少一个对象少一次引用跟踪不常用的表按需加载、用完释放存活集更小大块纯数值数据用NativeArray或二进制缓冲按需读取不在托管堆上或不含引用不需要逐个标记两个案例对比案例一飘字案例二配置表症状每隔几秒卡一下偶尔卡一下但卡很久根本原因分配多 →触发频繁存活对象多 →每次很慢优化方向减少分配减少存活对象和引用六、增量 GC把一次大暂停拆成很多小暂停6.1 原理盘点时装上监控摄像头Unity 2019.1 起提供增量 GCPlayer Settings 中的Use Incremental GC。它的思路是标记工作不再一口气做完而是拆成很多片每帧只做一小段默认目标约 3ms 左右可调两片之间游戏照常运行引用关系会改变为了不出现第二节那种漏标编译器在每次给引用字段赋值时插入写屏障记录谁的引用被改了GC 之后会重新检查这些对象。回到商场的比喻盘点分成很多天每天盘一个区顾客可以照常走动但每挪动一件货摄像头都会记下来盘点员最后对着记录补查。6.2 它不是银弹限制说明仍然有 STW标记的开始和收尾阶段仍需短暂暂停所有线程只是总时长被打散写屏障有开销每次引用赋值都多几条指令引用操作密集的代码整体会略慢可能追不上如果分配速度太快或引用改动太频繁增量标记跟不上GC 会退回到一次性完整回收卡顿照样出现总工作量不变只是把卡顿摊平到多帧并没有减少需要标记的对象所以增量 GC 能缓解案例二的单次长卡顿但无法弥补案例一那种高频分配。减少分配仍然是根本。在 Profiler 中增量 GC 的工作通常表现为分散在多帧中的GC.MarkDependencies片段而不是一个巨大的GC.Collect尖峰。七、控制 GC 的时机除了减少分配和存活对象还可以主动选择 GC 在什么时候发生usingUnityEngine.Scripting;// 进入战斗关闭自动 GC战斗中不会因为分配而触发回收GarbageCollector.GCModeGarbageCollector.Mode.Disabled;// 战斗结束、黑屏过场、打开结算界面时手动回收GarbageCollector.GCModeGarbageCollector.Mode.Enabled;System.GC.Collect();// 或者使用手动模式在空闲帧里做有时间上限的增量回收需开启增量 GCGarbageCollector.GCModeGarbageCollector.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 盘点时必须让所有人站着别动。盘点的人越多存活对象越多每次站得越久乱扔东西越多分配越多盘点越频繁。游戏要流畅两件事都得管。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑