Boehm GC 架构解析
篇章 :03-架构篇 | 阅读时间 :约 45 分钟 | 前置知识:了解保守式 GC 概念
一、引言
Boehm GC 的正式名称是 Boehm-Demers-Weiser Garbage Collector,出自 Hans Boehm、David Demers 与 Alan Weiser 三人,1980 年代末为 C/C++ 而生。编译器不会告诉运行时"栈上哪个槽位存的是指针",对象头也没有引用位图,Boehm 用一条保守假设绕过全部前提,收益和代价都写进它的架构。
它在 Unity 里的位置要先摆正:按官方文档(2019.1 到 6000.x / 7000.0 系列),Mono 与 IL2CPP 两个后端用的都是 Boehm,Use Incremental GC 自 2019.1 起默认勾选。旧稿"Boehm 是 IL2CPP 专属 GC"不准确,IL2CPP 只是集成最原生的场景:C# 变成纯 C++,Boehm 几乎零改造接入,后文结论默认也适用于 Mono 后端。
还有一组说法要对齐:上游开源 Mono 自 2.8 起默认是精确分代的 SGen(--gc=sgen,libmonosgen),03-01 章解析的正是它;Unity 发行的两个后端却都跑 Boehm,同一句"Mono 的 GC"在不同仓库里指的东西不同。Boehm 从不是 Unity 专属,凡拿不到精确类型信息的宿主它都是默认选项,理解它的架构就解释了 Unity 里的卡顿与堆膨胀。
二、保守式 GC 的工作原理
2.1 核心假设
Boehm 的核心假设一句话就说得完:对齐到字大小且落在堆地址范围内的值,就可能是对象引用。标记阶段对每个机器字只做两件廉价的事:是否指针对齐、是否落在堆区间内;都满足的值成为候选,再去堆块元数据查证是否指向存活对象。
栈内存:0x7FFFFFFF_AABBCC00 → 在堆范围内?→ 是 → "保守地"视为对象引用
0x00000000_0000002A → 在堆范围内?→ 否 → 不是引用(值太小)
0x7FFFFFFF_DEADBEEF → 在堆范围内?→ 是 → "保守地"视为引用
这条假设的价值是完全不需要运行时配合:不要栈图、不要求声明引用字段、不靠对象头位图,这是 Boehm 嵌进任意 C/C++ 程序、也被 IL2CPP 选中的根本原因。判定只有一个方向的错误安全,这就是"保守"的全部含义:真引用绝不会被漏掉,但不是引用的值会被当成引用,净效果就是第七章的虚假根。
2.2 对齐与范围过滤
过滤管线比假设精细。第一层是指针对齐,非 8 字节对齐的值直接丢弃,滤掉大部分整数中间结果;第二层是范围检查,堆外的值(代码段、栈、原生内存、小整数)出局;两层都是常数时间的位运算。
通过前两层的值进入第三层,地址归属查询:候选地址先定位到堆块(4KB 一页,块头记录尺寸类与标记位图),再按块内对象大小反推偏移,验证是否落在已分配对象内。这里有个重要默认行为,指向对象内部的指针也能保持对象存活,它降低集成要求也放大误保留面,3.5 节会介绍怎么收窄。
三层过滤合起来就是"用极少计算换掉全部元数据要求":精确 GC 依赖栈图逐槽查表,Boehm 换成对每个字两次判断。
2.3 为什么保守扫描不需要运行时配合
"零配合"体现在三个层面:不需要栈图或影子栈,任何优化等级的二进制都能直接被扫;不需要对象携带类型信息,扫对象体同样逐字过三层过滤;非增量模式下也不需要写屏障,标记期间对象图不变。
这种性质直接决定了集成成本:接 SGen 必须提供精确根描述、安全点协作与可解读的对象布局,接 Boehm 只需注册线程、排除不该扫的区域。IL2CPP 的 GC 适配层(第八章)本质就是补齐这张短清单:让 Boehm 看到该看的,看不到不该看的。
代价同样清楚:保守扫描分不出"像地址的整数"和真引用,拿不到精确布局就做不了分代快路径,引用不可信则移动后无法可靠更新,压缩式回收被锁死。这不是工程没做好,而是假设本身的边界。
三、Boehm 内部架构
3.1 堆的组织:segment 与 heap block
Boehm 的堆由若干 segment(堆段)拼成,经 mmap 或 VirtualAlloc 按需申请;segment 内部再切成等长的 heap block(堆块),默认一页 4KB,是管理内存的基本单位。
Boehm 堆
├── segment 1(向 OS 申请的连续区间)
│ ├── heap block 0 ← 挂在 16B 尺寸类的空闲链表
│ ├── heap block 1 ← 挂在 32B 尺寸类的空闲链表
│ └── heap block 2 ← 大对象区(整块归属单个对象)
├── segment 2
│ └── ...
└── 堆块头元数据(标记位图、尺寸类、空闲链表指针)
每个堆块头部记录尺寸类(或大对象标记)、粒度标记位与空闲链表指针:给定地址页对齐即可索引到块头,知道"对象多大、哪些还活着",2.2 节第三层过滤就建在这上面;超阈值请求分配连续堆块,不进尺寸类链表。
3.2 尺寸类与空闲链表
小对象按尺寸类(size class)归堆:请求字节数向上取整到该档位(8 或 16 字节步长),同档位共享同一批堆块。每个尺寸类一条空闲链表,节点就是空闲槽位本身,开头几个字存 next 指针,几乎不额外占地。分配取链表头、空了走慢路径切块,回收挂回链表头,与 bump 的差距见第四章。
清扫时相邻全空的堆块合并、极端时整页归还 OS;但合并只发生在"整块空闲"粒度,块内剩一个存活对象,空隙就只能在同尺寸类内复用,碎片缓解但无法根除。
3.3 标记位与标记栈
标记位不在对象头里,而是集中在堆块头的位图中,每粒度一个 bit,对象体零开销,代价是查标记要过块头。标记流程用显式标记栈做队列:新候选未标记则置位压栈,弹出时才扫描对象体找下批候选,溢出则扩容或分批重扫兜底。
// 标记主循环(简化)
while (!mark_stack_empty()) {
obj = mark_stack_pop();
if (already_marked(obj)) continue;
mark(obj); // 置块头位图
for (word w : object_body(obj)) // 逐字保守过滤
if (candidate(w) && !marked(w))
mark_stack_push(w);
}
位图集中存放的副产品:并行标记时多个 marker 竞争块头位图的置位而非对象本身,第六章的多标记线程架在这上面。
3.4 清扫与碎片管理
Boehm 是标记-清除收集器,没有压缩阶段:清除时把未标记槽位挂回尺寸类,全空堆块合并或归还 OS,对象地址终身不变,这是它与 SGen 最大的结构差异。
不移动对象换来引用永远有效、地址稳定,跨 FFI 传递不会被作废;代价是碎片只能部分缓解,内部来自尺寸类取整、外部来自空洞与新请求不匹配,都无法靠整理堆消除,committed 堆因此往往比存活数据大一截。
3.5 分配 API 家族
Boehm 对外是一组语义不同的分配入口,选错 API 是集成里常见问题:
| API | 语义 | 典型用途 |
|---|---|---|
GC_malloc |
普通可回收对象,标记时按字扫描 | 含引用的托管对象 |
GC_malloc_atomic |
对象体不含指针,标记时整块跳过 | 字节数组、字符串内容、原生缓冲 |
GC_malloc_uncollectable |
永不自动回收,需 GC_free 释放 |
生命周期由 C 代码手动管理的对象 |
GC_malloc_ignore_off_page |
只有基址保持存活,内部偏移不算引用 | 大缓冲区,减少内部指针簿记 |
GC_malloc_atomic 收益最直接:标记成本正比于要扫的字数,1MB 字节数组走 GC_malloc 每次 GC 都逐字过滤,换成 atomic 直接跳过,块内随机字节也意外保不住别的对象,顺手掐掉一类虚假根,字符串、像素缓冲都该走它,前提是块内确实无引用。
GC_malloc_ignore_off_page 针对 2.2 节的内部指针宽松策略,只认基址后大缓冲不再被内部偏移钉死;GC_malloc_uncollectable 则给"不想被回收、但内容仍希望被扫"的内存用。
四、分配路径详解
4.1 free-list 查找与 bump 分配
Boehm 的小对象分配是"按尺寸类查空闲链表":换算下标、索引链表头、弹出槽位,空了才走切块或向 OS 要页的慢路径。GC_malloc 保证清零内存,复用槽位每次都要 memset,又是一笔成本。
对照精确 GC 的 TLAB(线程本地分配缓冲)bump 分配,就是比较、移动指针两三条指令,缓冲用尽才整段补充。差异不在单次量级而在成本结构,free-list 每次查表、解引用、可能清零,bump 把开销预付在补充那一次动作里。
// Boehm 小对象分配(简化)
void* GC_malloc(size_t n) {
int sc = size_to_class(n); // 尺寸换算
void* p = free_lists[sc]; // 链表头查找
if (!p) p = refill_size_class(sc); // 慢路径:切块或要页
else free_lists[sc] = p->next; // 弹链表
memset(p, 0, class_size(sc)); // 清零语义
return p;
}
// TLAB bump 分配(简化,对照用)
void* tlab_alloc(size_t n) {
if (tlab_ptr + n > tlab_end) refill_tlab(); // 低频:整段补充
void* p = tlab_ptr;
tlab_ptr += n; // 高频:只动指针
return p;
}
清零的差异值得展开:C 语义要求返回内存能安全当作对象初值,所以 Boehm 每次复用槽位都要 memset,bump 同样清零但通常只在槽位首次启用时做一次。
4.2 一万个小对象的对照
沿用本系列固定例子:一万个装箱 int(16 到 24 字节),TLAB 下通常只发生一次补充,其余全是指针自增;Boehm 下则是换算、查链表、弹链表、清零各一万遍。
| 阶段 | Boehm(free-list) | SGen(TLAB bump) |
|---|---|---|
| 每次分配 | 换算 + 查链表 + 弹链表 + 清零 | 比较 + 指针自增 |
| 慢路径频率 | 链表空时触发,随碎片波动 | 每 TLAB 用尽一次,稳定 |
| 一万个小对象 | 约 10000 次链表操作 | 1 次补充 + 约 10000 次自增 |
| 缓存行为 | 链表节点可能散布 | 连续内存,预取友好 |
| 并发扩展 | 需按链表加锁或分段 | 线程本地,无共享 |
这张表是结构性差距,不是基准毫秒数。量级来源是:bump 把单次开销压到指令级,free-list 把它保留在函数级,分配频率以每秒万计时差异直接体现在 CPU 与缓存未命中上;慢路径还与链表健康度相关,碎片越严重链表越空,形成二次反馈。
4.3 对高频分配场景的含义
游戏运行期对 Boehm 是双重不利:高频小对象(UI 参数、闭包、字符串、装箱值)先在 4.2 节路径上付溢价,又因没有分代,活过一次 GC 的对象与长寿命对象同池、下次还要再扫。降分配因此收益双份,既省开销又减小存活集,单纯加频 GC.Collect 反而扫描更多。实践顺序:先消灭热路径分配(对象池、结构体化、复用缓冲),再用增量摊平停顿(第六章),最后调堆参数。
五、Roots 发现机制
保守 GC 的正确性建立在"根找全了"之上,漏一个根就可能悬垂指针崩溃;Boehm 从四类来源收集根,各有对应的机制。
5.1 数据段与静态根
进程 .data 与 .bss 段的全局、静态变量是天然的根,Boehm 通过链接器符号自动发现数据段边界整段登记,不支持时嵌入方用 GC_add_roots(start, end) 手动登记;静态字段多的运行时往往两种混用。
// 静态根登记(示意)
GC_add_roots(__data_start, __bss_end); // 平台数据段
GC_add_roots(static_ref_table,
static_ref_table + table_len); // 运行时自建的引用表
自动发现省事也埋了坑:数据段混着大量非托管指针(C++ 单例、缓存句柄),保守扫描分不清,会把落在堆范围内的原生指针当成引用,第八章的 GC 排除列表就是为它准备的。
5.2 线程栈扫描
栈是根的大头。Boehm 暂停线程后从栈底扫到当前栈指针,整段逐字执行 2.2 节的过滤,GC_push_all_stack 把区间内所有字推进标记管线。
void GC_push_roots() {
// 1. 扫描所有已登记的根区(静态变量、手动注册区)
for (root_range r : registered_roots) {
mark_possible_ref_range(r.start, r.end);
}
// 2. 扫描每个已注册托管线程的栈:栈底 → 当前栈顶
for (managed_thread* t : registered_threads) {
void* stack_bottom = t->stack_start; // 线程注册时记录
void* stack_top = current_sp(); // 暂停时的栈指针
GC_push_all_stack(stack_bottom, stack_top);
}
}
方向是栈底到栈顶(栈向低地址生长),该区间等于活跃栈帧的并集;暂停时机决定栈顶准确性,线程必须停在能拿到一致 SP 的状态,5.4 节为此存在。
5.3 显式注册与线程注册
显式注册根(GC_add_roots 与配套移除接口)是兜底通道:Boehm 看不见又确实持有引用的内存(句柄表、映射区、原生对象里的引用字段)可登记成区间,粒度越粗越浪费。反向通道同样重要,GC_exclude_static_roots 把不含托管引用的静态区剔出根集合,是 IL2CPP 集成的常规优化。
线程注册是另一条硬性要求:可能持有托管引用的线程必须先注册、提供栈底地址(GC_get_stack_base 加 GC_register_my_thread),它的栈才进入扫描范围,结束时注销。漏注册是嵌入 Boehm 最经典的事故,栈不被扫描、对象被回收,崩溃却发生在另一个线程;Unity 托管线程由运行时统一注册,插件里新建的原生线程碰了引用就得自己负责。
5.4 寄存器的保守扫描
暂停时活跃引用未必在栈上,编译器优化会把它们留在寄存器里。Boehm 把暂停瞬间的寄存器上下文整体保存,当作一段额外的"栈"推进标记管线;扫描保守,不必知道哪些寄存器存指针,POSIX 借信号处理保存上下文,Windows 调 GetThreadContext。
寄存器只有十几个,成本可忽略,但栈、静态区、寄存器三路合起来才是"此刻所有可能的引用"的完备超集,也又一次放大虚假根的暴露面。
六、并行标记与增量模式
6.1 多标记线程
单线程标记的成本正比于根数量加存活对象总量,堆到几十上百 MB 时一次全堆标记就很可观。并行标记把标记栈切成工作块交给多个 marker 消费,线程数由 GC_MARKERS_COUNT 控制,位图置位加原子操作保证不重复。
并行标记缩短标记总时间,没有改变"根扫描加最终清扫仍需停顿"的结构。堆越大收益越明显,堆小时同步开销可能盖过收益,该变量不是越大越好。
6.2 虚拟脏位与增量标记
增量模式的问题更棘手:标记进行中 mutator 可能把尚未标记对象的引用写进已扫过的对象,不处理就漏标、误回收。经典解法是写屏障加记忆集,Boehm 选了更省集成成本的路,虚拟脏位(virtual dirty bits,VDB)。
思路是只重扫被写过的部分:标记期间 mprotect 把堆页临时设为只读,mutator 一写就触发缺页异常,处理器把该页记为脏并恢复可写,下个切片只重扫脏页涉及的对象。机制有多个实现档位(页保护、硬件脏位、显式 GC_dirty),对 IL2CPP 来说页保护路径意味着写屏障近乎缺席。
// 增量标记的时间分片(示意)
while (!marking_finished()) {
run_mutator_until_frame_budget(); // 游戏逻辑跑一帧
rescan_dirty_pages(); // 补扫上个切片期间被写的页
mark_some(work_budget); // 推进一点标记工作
}
增量模式不减少总工作量,甚至因脏页重扫略增总量,它买的是单次停顿上限。切片预算与时机构成引擎侧设计,03-04 章详述;本章只确认 Boehm 的 GC_enable_incremental 一族是其地基。
6.3 Unity Incremental GC 的地基
接到 Unity 上:勾选 Use Incremental GC,引擎开增量标记并把切片调度挂进帧循环,默认单帧预算约 3ms(典型值,可用 incrementalTimeSliceNanoseconds 调整)。多帧摊完一次完整标记,几十毫秒的全堆停顿被拆成若干次几毫秒的小停顿,帧率尖刺随之消失。
两点需要清醒。增量依赖脏位,脏位依赖平台能力,WebGL 不支持增量 GC(第十章);堆特别大时一轮标记横跨多帧,新分配让"已标记"集合不断过期,标记有被拉长的风险。增量 GC 是把尖峰削平的工具,不是把 GC 变没的工具。
七、虚假根的量化影响
7.1 虚假根从哪里来
虚假根就是被保守过滤误判为引用的值,按常见程度排四类:整数与位型巧合(哈希、坐标、句柄 ID 落在堆范围内);死栈槽(旧指针值未被覆写);内部指针(指向对象中段的地址保住整对象);原生内存混扫(数据段里的 C++ 指针被当成引用)。
整数/位型巧合 --+
死栈槽 ---------+--> 保守过滤误判 --> 对象被误保留 --> 堆实际占用 > 真实存活集
内部指针 -------+
原生内存混扫 --+
四类来源指向同一个性质:误判只发生在一个方向,永远不会把真对象判死。这保证了正确性,也意味着虚假根是保守 GC 的结构性税收,无法根除,只能压缩。
7.2 堆膨胀的正反馈回路
单个虚假根的代价是"一个对象多活一轮",麻烦的是它会滚雪球:
虚假根保留死对象
|
v
堆上"存活"集合变大,堆向 OS 要更多页
|
v
堆变大 -> 标记要扫的字数变多 -> 单次 GC 更慢
|
v
标记变慢期间,更多新分配被卷进扫描窗口
|
v
堆里可疑值的绝对数量上升 -> 下一轮误判概率更高
|
+--> (回到顶部,且每圈都更大)
这个回路解释了一个常见现象:某些项目堆"只涨不跌",Profiler 里业务引用已释放占用却稳在高位,此时"还有谁引用着它"的排查一无所获,因为保留者是一个恰好长得像地址的整数。
7.3 经验估算与缓解
额外保留没有统一常数,随堆内容变化。工程上常引用的经验估算是约 10% 到 30% 的对象被误保留(经验估算,非精确测量;实测思路是构造"移除全部业务引用"的对照堆再比占用)。量级足以说明问题,真实存活集 70MB 的项目看到 90MB 到 100MB 占用并不奇怪。
缓解手段按性价比排:用 GC_malloc_atomic 切断"随机字节误保留对象"这条线;把不含托管引用的静态区剔出根集合;压栈上临时引用的生存期;控制尺寸类分布与碎片。四条都是削减暴露面,没有一条能消除误判。
八、IL2CPP 中的 Boehm 集成
8.1 C++ 代码生成流水线
IL2CPP 把 C# 编译成 C++,再交给目标平台的 C++ 编译器产出机器码。Boehm 在这条链路里很自然,它作为静态库链接进最终产物,IL2CPP 生成的代码对它就是普通 C++ 代码:
C# 源文件 -> IL -> IL2CPP 转译 -> C++ 源码 -> 平台 C++ 编译器 -> 可执行文件
^
Boehm GC 以静态库形式链接
(分配、标记、根管理全部就位)
这条流水线说明两件事:IL2CPP 生成的代码不需要为 GC 做"类型信息标注";Boehm 的能力边界就是 IL2CPP 的边界,它没有的分代和压缩 IL2CPP 也没有。第十一章"能不能换"以此为起点。
8.2 Roots 注册
生成代码的静态字段、类元数据、vtable 都可能是引用来源,IL2CPP 把它们登记给 Boehm:
// IL2CPP 生成的根注册(示意)
extern "C" void Il2CppCodeRegistration() {
// 静态字段区:逐段登记为根
GC_add_roots(g_staticFieldsStart, g_staticFieldsEnd);
// 类元数据与 vtable:同样是引用来源
GC_add_roots(g_metadataStart, g_metadataEnd);
// 反向操作:确认不含托管引用的区域直接剔除,
// 减少每轮根扫描量(真实工程中的优化手段)
GC_exclude_static_roots(g_nonGcMetadata,
g_nonGcMetadata + kNonGcMetadataSize);
}
正向登记保证不漏,反向排除保证不滥,两者精度共同决定根扫描成本与虚假根基数。
8.3 线程栈扫描
Boehm 每次 GC 触发时扫描所有已注册托管线程的栈,由 5.2 节的 GC_push_all_stack 从栈底推到暂停时的栈顶;IL2CPP 的托管线程在创建时完成注册,栈底地址随注册提交。
void GC_push_roots() {
// 1. 扫描所有已登记的根(静态变量、元数据区)
for (root_range r : registered_roots) {
mark_possible_ref_range(r.start, r.end);
}
// 2. 扫描每个托管线程的栈:栈底 -> 当前栈顶
for (managed_thread* t : registered_threads) {
void* stack_bottom = thread_get_stack_start(t);
void* stack_top = thread_get_sp(t); // 暂停点取得
GC_push_all_stack(stack_bottom, stack_top);
}
}
8.4 线程挂起与恢复
标记根的那一刻必须世界停止(STW),否则栈顶和寄存器会边扫边变。Boehm 提供挂起与恢复原语,线程在安全点响应挂起,寄存器上下文存进 5.4 节的扫描区;IL2CPP 需保证生成代码在可挂起的位置轮询,这是集成里少数"必须配合"的部分,也是保守 GC 唯一绕不开运行时之处。挂起延迟直接进停顿,线程越多、安全点间隔越长,STW 窗口越宽。
8.5 增量模式下的写屏障
非增量模式下写屏障可以缺席,标记期间对象图冻结。开启增量标记后,"已扫过的对象被写入新引用"就必须处理:IL2CPP 的写屏障要配合脏位机制上报变更,或依赖 Boehm 自身的页保护路径,取决于平台与构建配置。03-04 章详述引擎侧调度,本章只确认分工:Boehm 提供机制,IL2CPP 提供挂点。
8.6 终结器交互
终结器(析构语义)由 GC_register_finalizer 一族承接:对象死亡后先入待终结队列,终结线程执行回调后才真正可回收,IL2CPP 把 C# 析构函数映射到这条路径。两个实务后果:有终结器的对象至少多活一轮,热路径别给小对象挂析构;GC.WaitForPendingFinalizers() 显式排空队列,放进热点等于自造停顿。
// 终结器注册(示意)
GC_register_finalizer(obj, on_finalized, NULL, NULL, NULL);
九、保守 vs 精确的架构对比
把前面八章压成一张表,Boehm 与精确式代表 SGen 的差异集中在根子上:
| 维度 | 保守式(Boehm) | 精确式(SGen) |
|---|---|---|
| Root 识别 | 扫描所有对齐值,逐字过滤 | 仅扫描类型信息标记的引用位 |
| 虚假根 | 有,经验估算额外保留 10% 到 30% | 无 |
| 堆效率 | 低,存活集含误保留 | 高,占用接近真实存活集 |
| 分代 | 无,每次全堆扫描 | 有,Nursery 快速回收短命对象 |
| 压缩 | 无,对象地址不变 | 支持,可整理碎片 |
| 分配路径 | 尺寸类空闲链表 | TLAB bump |
| 集成成本 | 极低,几乎零运行时配合 | 高,需栈图、安全点、类型描述 |
| 典型暂停 | 与堆大小成正比,全堆线性 | Gen0 亚毫秒级,Major 相对可控 |
表里没有"谁更先进"的裁决,只有取舍:保守式用内存效率和分代能力换集成成本,精确式反过来。宿主拿不到类型信息、集成预算极小,保守式是唯一现实选项;堆大、分配高频、有专职团队,精确式才值回集成成本。顺带修正一个误解:Boehm 不是"IL2CPP 专用",SGen 也不等于"精确式"全部。
十、Unity 中的现状与演进
10.1 两个后端都用 Boehm
按 Unity 官方文档(2019.1 到 6000.x / 7000.0 系列),Mono 与 IL2CPP 两个后端用的都是 Boehm,Use Incremental GC 自 2019.1 起默认勾选,关掉才是回到全堆模式。旧资料的"Boehm 是 IL2CPP 专属"以本节为准,两个后端 GC 种类一致,IL2CPP 只是集成链路更原生(8.1 节)。
这对排查有直接意义:Mono 构建下的堆膨胀、全堆扫描、无分代,原因与 IL2CPP 构建完全相同,切换后端换得来别的收益,换不掉 GC 的性格。
10.2 增量模式成为默认
增量 GC 的普及路径是典型的"先做增量 Boehm,后续持续评估更现代的 GC":2018 年前后 Unity 工程师公开过对 Boehm 的增量改造,2019.1 起默认启用并延续到 6000.x 与 7000.0 系列。改造对象始终是 Boehm 本身,调度细节留给 03-04 章。
读这条演进线要避免把"计划"当"已交付":评估不等于替换,任何"某某 GC 已取代 Boehm"的说法都要拿到版本证据才能采信。
10.3 SGen 实验与 Mono 的两种含义
2018 年 Unity 工程师的公开表态有两层:内部试过把 SGen 搬进 IL2CPP,结论是没有普遍优势,把 Boehm 做成增量才是改动最小的路径;这次实验没有变成任何 shipped 版本,"Unity 已用上 SGen"是误读。另一层是那句"Mono 的 GC"指什么,上游 Mono 自 2.8 起默认 SGen(03-01 章的主题),Unity 定制发行的 Mono 后端则用 Boehm(10.1 节),是不同发行版。
10.4 WebGL 与 CoreCLR 的例外
两个例外必须写清楚,否则默认结论会被误用。WebGL 不支持增量 GC,无论 Inspector 里怎么勾选,实际跑的都是非增量的 Boehm,内存优化更要靠降分配本身。Unity 6 的 CoreCLR 后端用 .NET 自带的 GC,不支持 Unity 增量 GC 与 GarbageCollector 系列 API,跨后端写工具代码要先判后端,否则探针会静默失效。
| 平台 / 后端 | GC 实际形态 | 增量 GC 支持 |
|---|---|---|
| Mono 后端 | Boehm | 支持,2019.1 起默认开 |
| IL2CPP 后端 | Boehm | 支持,2019.1 起默认开 |
| WebGL(任一脚本后端) | Boehm(非增量) | 不支持 |
| Unity 6 CoreCLR 后端 | .NET 自带 GC | 不适用,Unity 增量 API 不受支持 |
十一、适用场景与局限
11.1 什么场景适合 Boehm
Boehm 的甜点区由 2.3 节的"零配合"推出来:宿主拿不到精确类型信息、集成预算极小、堆规模可控的场合。C/C++ 库想补自动内存管理又不愿动编译器与对象模型,Boehm 是改动最小的方案;Unity 两个后端本质也属这一类,IL2CPP 已把 C# 变成 C++,再引入需要栈图与安全点的精确 GC,代价远超评估期能接受的范围。另有两个隐性优点:地址稳定(3.4 节)天然适配跨 FFI 传指针,实现单薄则移植面窄,冷门平台上它往往仍是唯一选项。
11.2 什么场景不适合
分配高频且短命对象多的负载(游戏热循环、UI 密集刷新、每帧装箱)撞上 4.3 节的双重不利:路径贵且没有分代挡短命对象。堆持续增长的长驻进程撞上 7.2 节的膨胀回路,误保留与碎片随时间累积,占用曲线只涨不跌。要硬实时内存保证的场景也不适合,保守扫描停顿与堆大小线性相关,没有 Gen0 快路径兜底。
对照第九节还能得出一条:项目若已具备精确 GC 的集成条件(类型信息、安全点、专职维护),继续付保守式的内存税没有正当理由,Unity 自己的路线(10.2 节)也印证了这点。
11.3 落到工程上的旋钮
不换 GC 的前提下,可操作项按优先级排:热路径降分配(对象池、结构体化、复用缓冲),唯一同时改善分配开销与扫描存活集的动作;无引用大块数据改走 GC_malloc_atomic,静态区用 GC_exclude_static_roots 收窄根扫描;增量确认开启且切片预算贴合帧预算;环境变量层面有 GC_INITIAL_HEAP_SIZE 与 GC_MARKERS_COUNT(以发行版实际支持为准)。旋钮再多,改变的是曲线形状,改不掉保守式的天花板。
总结
Boehm 是一条用"保守假设"换"零配合"的技术路线:核心假设让它不依赖栈图、类型描述与写屏障就工作,集成成本极低;同一假设带来虚假根与堆膨胀,无分代、无压缩、全堆扫描让它在高频分配下停顿显著。分配路径是空闲链表而非 bump,根发现靠静态区、栈、寄存器、线程注册四路合力,增量模式靠虚拟脏位把大停顿切成帧内切片,这四件事就是它的全部性格。
Unity 侧按事实对齐:两个后端都用 Boehm,2019.1 起增量默认开启;内部试过 SGen 未见普遍优势,走的是"先做增量 Boehm、持续评估更现代的 GC"这条线;上游 Mono 自 2.8 起默认 SGen,与 Unity 定制构建是两回事;WebGL 不支持增量,Unity 6 的 CoreCLR 换 .NET 自带 GC 且不支持 Unity 增量 API。
面对自己的项目,先确认后端与平台(10.4 节),再看负载是否撞在 11.2 节的三个坑,然后把 11.3 节的旋钮过一遍。多数 Unity 项目做完热路径降分配与增量配置后停顿已可控,确实撞上天花板再谈换 GC 才有意义。
代码示例:读取 Boehm GC 状态与验证增量模式
下面的探针组件在 Unity 里直接可用,做三件事:读取托管堆与 Boehm 侧统计量、把增量切片预算调到 3ms 典型值并推进一次增量收集、对全堆收集计时。Boehm 符号可见性取决于打包配置,release 构建可能被剥离,取不到时按注释里的兜底方式跳过即可。
using System;
using System.Runtime.InteropServices;
using UnityEngine;
using UnityEngine.Scripting;
/// <summary>Boehm GC 状态探针:读统计量、调增量预算、测全堆停顿。</summary>
public sealed class BoehmGcProbe : MonoBehaviour
{
// Boehm 侧的调优旋钮以环境变量形式注入(打包平台允许时):
// GC_INITIAL_HEAP_SIZE=64m 初始堆大小
// GC_MARKERS_COUNT=4 并行标记线程数
#if UNITY_IL2CPP && !UNITY_WEBGL
// Boehm 静态链接进播放器,符号可见性依构建配置而定
[DllImport("__Internal")] private static extern UIntPtr GC_get_heap_size();
[DllImport("__Internal")] private static extern UIntPtr GC_get_free_bytes();
[DllImport("__Internal")] private static extern UIntPtr GC_get_bytes_since_gc();
#endif
private void Start()
{
Debug.Log($"[BoehmProbe] {Snapshot()}");
}
/// <summary>汇总托管堆与 Boehm 堆的当前状态。</summary>
private string Snapshot()
{
long managedMb = GC.GetTotalMemory(false) / (1024 * 1024);
string line =
$"managed={managedMb}MB, " +
$"mode={GarbageCollector.GCMode}, " +
$"incremental={GarbageCollector.isIncremental}";
#if UNITY_IL2CPP && !UNITY_WEBGL
try
{
line += $", heap={(ulong)GC_get_heap_size() / (1024 * 1024)}MB" +
$", free={(ulong)GC_get_free_bytes() / (1024 * 1024)}MB" +
$", sinceGc={(ulong)GC_get_bytes_since_gc() / (1024 * 1024)}MB";
}
catch (EntryPointNotFoundException)
{
// 符号被剥离时跳过 Boehm 侧统计,托管侧读数不受影响
}
#endif
return line;
}
/// <summary>把单帧增量预算调成 3ms(典型值,可按项目实测收窄或放宽)。</summary>
public void TuneIncrementalSlice()
{
if (GarbageCollector.GCMode != GarbageCollector.Mode.Incremental)
{
Debug.LogWarning("[BoehmProbe] 增量模式未启用,切片预算不生效");
return;
}
GarbageCollector.incrementalTimeSliceNanoseconds = 3_000_000;
}
/// <summary>在本帧预算内推进一次增量标记,不阻塞主线程。</summary>
public void NudgeIncremental()
{
GarbageCollector.CollectIncremental();
}
/// <summary>触发全堆收集并计时,用于对比增量与全量的停顿差异。</summary>
public void MeasureFullCollect()
{
float t0 = Time.realtimeSinceStartup;
GC.Collect(); // 落到 GC_gcollect,全堆扫描
GC.WaitForPendingFinalizers();
float cost = Time.realtimeSinceStartup - t0;
Debug.Log($"[BoehmProbe] full collect cost={cost * 1000f:F1}ms | {Snapshot()}");
}
}
用法上,Start 打快照确认后端与增量状态,TuneIncrementalSlice 在设置或调优分支里调用,NudgeIncremental 挂在分配压力测试里观察切片推进,MeasureFullCollect 对比全堆与增量的帧耗时。快照里的 heap 与 managed 对照着看,两者之差里就混着 7.3 节的误保留与碎片。