03-02-架构篇-Boehm GC架构解析

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 节的误保留与碎片。

相关推荐
海姐软件测试2 小时前
IT疑难杂症诊疗室:从故障现象到根因定位的实战指南
架构
mldong2 小时前
工作流引擎的委托代批:一张单,两个办理人
后端·架构
Joy T8 小时前
Agent 确定性上下文设计:从 Prompt 参数注入到能力分层
java·开发语言·微服务·架构·prompt·agent context
微订校园外卖平台10 小时前
同城外卖小程序开发:校园外卖系统四端架构与订单链路拆解
架构·外卖系统·本地生活·微订
m0_5873830011 小时前
广州 24 小时自助健身房解决方案技术实现与实战指南
java·spring·小程序·架构·需求分析
牛战士coding11 小时前
AI Agent 如何接入真实业务系统?订单助手三层架构完整拆解
人工智能·架构
收放扳机11 小时前
PCB收放板设备机械手架构选型参考——三轴/四轴/六轴/蜘蛛手对比
人工智能·科技·架构·自动化·制造·pcb工艺
九卷12 小时前
企业级Agent系统整体技术架构介绍
ai·架构·大模型·llm·agent·智能体
木子n113 小时前
软件架构设计实战-架构18.从Classic到Adaptive:SOA架构在MATLAB里的模样
架构·autosar·性能工程·服务化架构