03-03-架构篇-SGen GC架构解析

SGen GC 架构解析

篇章 :03-架构篇 | 阅读时间 :约 45 分钟 | 前置知识:了解分代 GC 概念


一、引言

SGen 的全称是 Simple Generational GC,它是 Mono 运行时自带的垃圾回收器,从 Mono 2.8 起进入发行版并成为默认选择(可用 --gc=sgen 显式指定,动态库名为 libmonosgen)。在 SGen 之前,Mono 长期依赖 Boehm-Demers-Weiser GC,那是一款保守式(conservative)回收器:它把内存里所有"看起来像指针"的字都当成候选引用去扫描。

保守扫描带来两个后果。第一是虚假根(false root):一个恰好落在堆地址范围内的整数会被误判成引用,把它"指向"的对象钉死在堆里,导致本该死亡的对象无法回收。第二是全堆暂停:Boehm 每次回收都要遍历整个堆,堆越大暂停越长,缺乏分代带来的规模优势。SGen 的设计目标就是同时解决这两点:用精确标记(precise marking)消除虚假根,用分代(generational)把常规回收的扫描范围压缩到一块固定大小的新生代里。

SGen 的架构核心是两代设计:Nursery(新生代)与 Major Heap(老年代)。绝大多数对象在 Nursery 中出生后迅速失活,Nursery GC 只需扫描很小的一块区域就能把它们批量回收,暂停时间与 Nursery 的固定大小挂钩,而与整个堆的大小无关。活得足够久的对象会被复制进 Major Heap,在那里接受低频但彻底的全堆回收。

本文按"定位与适用范围、对象表示、两代布局、分配路径、跨代引用、两类回收流程、并行标记、性能特征、与 Boehm 的对比、实战配置"的顺序拆解这套架构,每个环节都给出机制层面的解释,而不是停留在名词层面。


二、定位与适用范围

先说清楚一个最容易混淆的事实:SGen 是开源 Mono 的默认 GC,而 Unity 发行的 Mono 后端与 IL2CPP 后端使用的都是 Boehm,不是 SGen。Unity 官方文档(2019.1 到 6000.x/7000.0 系列)在说明 GC 行为时一直写的是 Boehm;Unity 工程师在 2018 年的论坛回复中也提到,团队评估过 SGen,最终选择在 Boehm 基础上做增量式改造。所以"Unity 现在跑的是 SGen"这个说法不成立,本文也不会做这样的假设。

把常见运行场景按 GC 归位,可以避免后续章节串台:

运行场景 实际使用的 GC 备注
命令行 mono 启动的程序 SGen(默认) 可用 --gc=boehm 切换
服务端 / CI 工具链中的 Mono SGen(默认) 动态库 libmonosgen
嵌入 Mono 的第三方引擎 多为 SGen 跟随所链接的 Mono 版本
Unity 的 Mono 后端 Boehm Unity 未启用 SGen
Unity 的 IL2CPP 后端 Boehm 与 Mono 后端一致

那么这一章的价值在哪里?有三点。第一,SGen 是 Mono 运行时本体的 GC:命令行 mono 启动的程序、基于 Mono 的服务端部署、Godot 这类嵌入 Mono 的引擎、以及大量构建与 CI 工具链,默认都跑在 SGen 上,它是一个活跃使用的生产级回收器,不是博物馆展品。第二,SGen 是本系列分代设计的参照系:第 04 章逐行剖析的正是 SGen 源码,第 07 章在解析 .NET Core GC 时也以它作为分代结构的对照基准。第三,理解 SGen 的精确标记、卡表与晋升机制,是读懂 Unity 增量式 GC 设计取舍的前提。Boehm 章节里"Unity 为什么没能用上分代回收"那个问题,答案要回到 SGen 这里才完整。

读这一章的正确姿势是把它当作"教科书式的现代分代 GC 实现"。它身上的每个机制在 JVM 的收集器、.NET 的分代 GC 中都有对应物:精确描述符对应类型信息、卡表对应 remembered set、晋升对应 copy-on-survive。学会一套,其余的只是名词差异。


三、对象表示与精确性

3.1 vtable 与 GC 描述符

SGen 是精确 GC,它确切地知道每个对象里哪些字是引用、哪些不是。这依赖对象头部的 vtable 指针:托管对象的第一个字指向虚方法表,而 vtable 里挂着该类型的 GC 描述符(GC descriptor)。

复制代码
托管对象内存布局:
┌────────────┬────────────┬────────────┬────────────┐
│ vtable 指针 │  引用字段 A  │  值类型字段  │  引用字段 B  │
└────────────┴────────────┴────────────┴────────────┘
      │
      ▼
vtable ┌─────────────────────────────────┐
       │ 方法表 ...                      │
       │ gc_descr: bitmap = 0b101        │  ← bit0/bit2 为引用
       └─────────────────────────────────┘

字段数较少的类型,描述符就是一个位图:第 n 位为 1 表示第 n 个机器字是引用。数组和可变大小对象使用更复杂的描述符类型,按偏移量或元素类型描述引用位置。回收器遍历对象图时只读取位图标记的槽位,int、float、struct 内的原始数据根本不会被当成候选引用。

3.2 精确性为什么允许移动对象

Boehm 不敢移动对象,根源就在保守性:既然回收器分不清"真引用"和"长得像引用的整数",一旦挪动对象,那些没被登记的字就可能指向失效位置,程序随后读到的就是垃圾数据。精确 GC 没有这个顾虑,所有指向对象的字都在掌握之中,移动之后逐一改写即可。

这也是 SGen 能做 Nursery 复制晋升的前提。Nursery GC 的本质是把存活对象复制到 Major Heap,再把所有引用改写到新地址;如果回收器不能枚举出全部引用位置,这套复制根本无从谈起。所以"精确"与"分代"在 SGen 里不是两个独立卖点,前者是后者的地基:没有精确标记就没有安全移动,没有安全移动就没有低成本的新生代复制回收。


四、两代设计

4.1 弱分代假设

分代 GC 建立在弱分代假设(weak generational hypothesis)之上:绝大多数对象朝生夕灭,而活过一轮的对象往往还会继续活。分配密集型负载(游戏主循环、消息处理、序列化与解析)里,临时对象占分配总量的九成以上,它们的存活周期只有几帧甚至几微秒。

对这类负载,全堆扫描是浪费:回收器把 99% 的时间花在遍历老年代上,真正可回收的对象几乎全在新生代。SGen 的回应是把堆切成两块,让回收频率与区域大小匹配:小区域高频扫,大区域低频扫,两边的成本都可控。

分代假设在实践中有两个可观测的佐证。其一是晋升率:健康的分配模式下,Nursery 里活过一轮的对象只占分配量的很小一部分(典型范围个位数百分比),意味着绝大多数回收只需在新生代里完成。其二是存活集的稳定性:老年代的规模增长缓慢,Major GC 之间的间隔在负载平稳时相当规律。反过来,一旦晋升率异常抬升或 Major GC 间隔缩短,几乎可以断定业务侧引入了非常驻却长生命周期的对象,这比看堆曲线更能定位问题。

4.2 Nursery(新生代)

Nursery 是一段连续的、固定大小的内存,默认 4MB,可通过 MONO_GC_PARAMS=nursery-size=... 调整。所有新分配的小对象都从这里出生,每个线程在其中持有自己的 TLAB。

复制代码
Nursery (默认 4MB,连续且固定):
┌────────────────────────────────────────────┐
│ TLAB1  │  TLAB2  │  已分配  │    空闲      │
└────────────────────────────────────────────┘
         ↑ 各线程的 TLAB 在 Nursery 中分配

固定大小的意义在于限定最坏情况:Nursery GC 需要处理的对象上限就是这几 MB,外加一次卡表扫描,与堆总量无关。堆涨到 200MB 时,Minor GC 的工作量不会跟着涨,这是分代设计最直接的红利。

一轮 Nursery GC 只做三件事:扫描 GC Roots、扫描卡表中的脏卡、把存活对象复制出去。完成后整个 Nursery 重置为可分配状态(被钉住的对象除外),相当于每轮回收都把新生代清空重来,因此 Nursery 内部不需要维护空闲链表,也不存在碎片问题。

4.3 Major Heap(老年代)

Major Heap 存放从 Nursery 晋升的存活对象和直接分配的大对象,它由多个 segment 动态拼接而成,随程序运行不断增长。与 Nursery 的"连续且固定"相反,Major Heap 是分段的、可扩展的,段的尺寸从小到大都有。

复制代码
Major Heap(动态增长的 segment 链):
┌──────────┬──────────┬──────────┬──────────┐
│ Segment1 │ Segment2 │ Segment3 │ Segment4 │
│ (64KB)   │ (64KB)   │ (1MB)    │ (1MB)    │
└──────────┴──────────┴──────────┴──────────┘

Major Heap 以标记-清扫为基础算法(可选标记-紧凑或复制式),对象一旦进入这里,只有在 Major GC 中被判定不可达才会释放。正因为回收代价高,SGen 会尽量推迟对象进入 Major 的时机,让"判死刑"这件事尽量在廉价的 Nursery GC 里完成。老年代真正需要回答的问题只有一个:这个对象配得上长期占用吗。


五、内存分配路径

5.1 TLAB 与快速路径

SGen 给每个托管线程分配一块线程私有的 TLAB(Thread Local Allocation Buffer),线程内部用 bump-pointer 方式分配:移动指针,不清理,不搜索空闲链,不需要任何锁或原子操作。

复制代码
// 快速路径:2-3 条指令完成一次分配
void* ptr = tld->tlab_alloc_ptr;
tld->tlab_alloc_ptr = (uint8_t*)ptr + size;
// 再写入对象头的 vtable,分配完成
return ptr;

这条路径的成本约等于两三次内存写,比调用一个普通函数还便宜。连续分配的对象在内存中也连续,遍历时 CPU 缓存命中率高。经验上九成以上的分配都走这条路,这是 SGen 吞吐量的底座。

5.2 慢速路径与 TLAB 耗尽

当 tlab_alloc_ptr + size 越过 TLAB 上界,进入慢路径:先向 Nursery 切一块新 TLAB(用 CAS 推进全局分配指针,多线程下可能重试几次)。若 Nursery 剩余空间也不足,触发一次 Nursery GC,回收后重试;仍然不足才降级到 Major Heap 分配。

复制代码
static void* alloc_small_slow(size_t size, VTable* vt) {
    void* obj = try_alloc_new_tlab(size);   // 1) CAS 切新 TLAB
    if (obj) return obj;

    sgen_nursery_collect();                 // 2) 触发 Nursery GC
    obj = try_alloc_new_tlab(size);
    if (obj) return obj;

    return sgen_major_alloc(size, vt);      // 3) 降级到 Major Heap
}

三层兜底保证了分配不会失败(除非进程真的没有内存),同时把"触发 GC"这个昂贵动作藏进最低频的分支里。快慢路径的命中比例可以直接从 print-stats 的输出里读出来,这也是评估分配健康度的指标之一。

5.3 大对象直达 Major Heap

超过阈值的大对象(SGen 默认在 8KB 量级)不进 Nursery,直接分配到 Major Heap。理由很直接:大对象若放进 4MB 的 Nursery,下一次 Minor GC 就会把它挤出去晋升,复制开销大不说,Nursery 的空间利用率也会被它拖垮。跳过 Nursery 既省掉一次复制,也避免大对象频繁触发 Minor GC。这与 .NET 的 LOH(大对象堆)思路一致,只是 SGen 的阈值更小。


六、卡表与跨代引用

6.1 写屏障

分代 GC 引入了一个新问题:Minor GC 只扫 Nursery,但 Major Heap 里的老对象可能引用 Nursery 里的新对象,漏掉这条引用就会把活对象误判成垃圾。把整个 Major Heap 都扫一遍又违背分代的初衷,等于退回全堆回收。

SGen 用卡表(card table)解决这个问题:把堆切成固定粒度的卡片(例如每 128 字节一张),另开一个字节数组与堆地址一一对应,记录每张卡是否"脏"。JIT 在每次向引用字段写值时插入写屏障(write barrier):若写入者位于 Major Heap 而被写值位于 Nursery,就把对应卡片标记为脏。

复制代码
// JIT 生成的写屏障(简化)
void write_barrier(void* holder, void** slot, void* value) {
    *slot = value;                                    // 先完成写入
    if (in_major(holder) && in_nursery(value))
        card_table[card_index(holder)] = CARD_DIRTY;  // 再标记卡片
}

于是 Major 到 Nursery 的跨代引用全被浓缩在少量脏卡里,Minor GC 只需扫描脏卡覆盖的那几 KB 到几十 KB,而不是几百 MB 的老年代。这套结构在源码章节里以 Remembered Set(记忆集)的名字出现,卡表就是它在 SGen 中的实现形式。

6.2 卡片状态与扫描成本

卡片只有两种状态:clean 与 dirty。写屏障的代价是每次引用写入多一次地址范围判断,几乎全部落在分支预测命中的快速路径上;扫描成本则与脏卡数量成正比,而脏卡数量由"老对象引用新对象"的活跃程度决定,通常只占堆的一小部分。

Minor GC 扫完脏卡后会把它们清回 clean,下一轮重新积累。如果某段老年代被反复写入(长期存在的缓存对象不断更新引用就是典型情况),那段卡片会长期为脏,扫描开销随之上升。运行时对整卡扫描做了优化:先快速检查卡内是否有指向 Nursery 的指针,没有就整卡跳过,避免逐字段深扫。

写屏障与卡表是分代 GC 的固定入场费,也是后续章节分析 Unity 增量式 GC 写屏障设计时的对照基线:增量式回收还要在此基础上解决"标记进行中如何处理新写入"的问题。


七、Nursery GC 流程

7.1 触发条件

Nursery GC 由分配压力触发:当前线程的 TLAB 耗尽、且从 Nursery 切不出新 TLAB 时,分配路径直接调用 Minor GC。此外 GC.Collect() 等显式调用也会触发。触发点藏在分配的慢路径里,意味着"分配不动了才回收",不存在凭空发起的扫描,回收频率天然与分配速率成正比。

触发之后的暂停机制沿用 03-01 讲过的 STW 模型:发起线程请求暂停,其余托管线程在安全点检查处让出控制权,全部就位后 GC 才开始扫描。这意味着 Minor GC 的实际暂停除了标记与复制本身,还包含等待最慢线程到达安全点的时间。持有锁执行长阻塞调用的线程会拖长这一段,所以排查 GC 卡顿时要同时看线程栈,不能只盯着 GC 日志。

7.2 根扫描与卡表扫描

进入 GC 后,运行时先 STW 挂起所有托管线程并等待它们到达安全点,然后执行两路扫描:一路是 GC Roots(静态字段、栈槽、寄存器),只保留落在 Nursery 内的引用;另一路是卡表中的脏卡,从中提取老对象持有的 Nursery 引用。两路结果共同构成 Nursery 的存活入口集合。

复制代码
void sgen_nursery_collect() {
    stw_suspend_all();                      // 0. 停止世界

    mark_roots_in_nursery();                // 1. 标记 GC Roots
    scan_dirty_cards();                     // 2. 扫描卡表中的跨代引用

    for (obj in marked_nursery_objects)
        promote_to_major(obj);              // 3. 复制晋升

    update_refs_via_forwarding();           // 4. 按转发指针改写引用
    reset_nursery();                        // 5. 重置 Nursery
    stw_resume_all();                       // 6. 恢复线程
}

标记阶段借助对象的 GC 描述符逐字段推进:遇到不在 Nursery 的引用直接跳过,遇到已标记对象不再重复入栈。整个标记量与"存活对象数"成正比,而不是与"Nursery 分配量"成正比,这正是它能压在 1ms 以内的原因。

7.3 复制晋升与转发指针

存活对象被复制到 Major Heap 的新位置,原位置写入转发指针(forwarding pointer)。随后的引用更新阶段遍历根集合与脏卡,把所有指向旧地址的引用改写到新地址;改写不到的引用,指向的一定是本轮未晋升的死亡对象,直接置空即可。

复制代码
static void promote(void* obj, size_t size) {
    void* dst = sgen_major_alloc(size);     // 在 Major 中开空间
    memcpy(dst, obj, size);                 // 复制对象内容
    set_forwarding(obj, dst);               // 旧位置写转发指针
    // 引用更新阶段凭转发指针改写所有引用
}

转发指针复用对象头的低位标志位:被晋升的对象在原地址留下"我已搬家"的标记,更新阶段读到标记就取新地址。这套机制让"复制"与"引用改写"解耦,是复制式 GC 的标准做法,源码章节会给出对象头标志位的具体布局。

7.4 晋升策略与存活统计

SGen 的晋升策略相当直接:Nursery 里活过这一轮的对象全部晋升到 Major Heap,不像某些 GC 那样用年龄计数把对象在新生代里多留几轮。这个选择来自成本核算:Nursery 只有 4MB,把存活对象复制到 Major 的开销极小;而"留在原地"反而需要复杂的免复制管理,包括标记位维护、指针改写、以及整体重置时如何不误伤它们。

代价要靠存活统计来盯:晋升字节数除以 Nursery 容量就是晋升率。健康的分配模式下晋升率应该很低,大部分对象死在 Nursery 里;如果某次 Minor GC 的晋升率突然抬升,说明有短命对象被错误地长期持有,或者有对象本该只活几帧却跨过了多轮回收。print-stats 会输出每次 GC 的晋升数据,这是定位分配问题的第一手材料。

还有一类特殊情况是被钉住(pin)的对象:经 P/Invoke 交给原生代码或被 GCHandle 固定的对象无法移动,不能参与复制晋升,只能以特殊方式留在 Nursery 中并跳过重置。这类对象越多,Nursery 的有效可分配空间越小,Minor GC 会明显变频繁,同样能从统计与 GC 日志里看出来。


八、Major GC 流程

8.1 触发条件

Major GC 在 Major Heap 空间不足、晋升或大对象分配连续失败、或用户显式调用 GC.Collect() 时触发。它的频率远低于 Nursery GC,典型量级是每几分钟一次(典型范围,随负载浮动)。堆越大、存活集越大,单次代价越高,所以它是帧率敏感场景里真正需要盯的那一个。

8.2 全量标记

Major GC 的标记是全堆范围的:从根集合出发遍历整个对象图。SGen 用显式的标记栈管理灰色对象(已发现但子节点尚未扫描完的对象),配合三色抽象推进:

复制代码
void sgen_major_mark() {
    mark_stack_clear();
    push_roots(&mark_stack);                 // 根染灰

    while (!stack_empty(&mark_stack)) {
        obj = pop(&mark_stack);              // 取出灰色对象
        for (ref in reference_fields(obj))   // 按描述符遍历引用
            if (is_white(ref)) {
                mark_black(ref);             // 标记并入栈
                push(&mark_stack, ref);      // 栈内是待扫描对象
            }
        mark_black(obj);                     // 子节点入栈后本体染黑
    }
}

标记栈是堆外分配的独立结构,可以动态增长,避免了递归遍历导致的原生栈溢出。标记完成时所有存活对象为黑、死亡对象保持白,堆被干净地一分为二。多对象共享的引用会被去重(入栈前检查颜色),避免同一子图被重复展开。

8.3 清扫与空闲链表重建

清扫阶段逐段遍历 Major Heap,把白色对象占用的空间并入空闲链表,同时清除黑色对象的标记位为下一轮做准备:

复制代码
void sgen_major_sweep() {
    for each segment in major_heap {
        for each block in segment {
            if (!block.is_marked()) {
                add_to_free_list(block);     // 回收死亡块
            } else {
                block.clear_mark();          // 清除标记,准备下轮 GC
            }
        }
    }
    rebuild_size_class_stats();              // 更新分级占用统计
}

空闲链表按 size-class 分级(16、24、32、48 字节,一路到大块),分配时按请求大小直接定位到对应级别的链表,避免全局搜索。清扫同时维护每个分级的占用率统计,这些数字既是分配路径的索引,也是下一节"要不要压缩"的判据。

8.4 可选压缩与疏散

标记-清扫留下的碎片是长期隐患:堆里空闲总量足够,却找不到连续的大块,结果是堆持续增长而程序照样 OOM。SGen 提供可选的压缩模式(major=marksweep-compact)与复制式 major 收集器(major=copying),在清扫之后把稀疏分布的存活对象疏散到段内的连续区域,腾出整块空间。

压缩需要移动对象并改写全部引用,代价比纯清扫高一个量级,所以它被设计成低频动作:只在碎片率越过阈值时执行,而不是每次 Major GC 都做。疏散完成后,段内重新出现大段连续空闲区,后续的大对象分配与晋升都能直接落位。


九、并行标记

SGen 支持多线程标记,通过 MONO_GC_PARAMS=num_mark_threads=N 配置。并行化的范围有明确边界:只有 Major GC 的标记阶段并行,根扫描入口之后把标记栈分给多个工作线程,各线程独立推进自己的子图;清扫仍然单线程,Nursery GC 因为工作量本来就小,也不做并行拆分。

线程数通常设为 CPU 核数,开得更多只会引入同步开销而摊薄收益。并行标记并不消除 STW:标记期间所有托管线程仍然挂起,变化只在标记的墙钟时间被摊到多个核上,堆越大收益越明显。反过来,几 MB 的 Nursery 回收本身就快,并行化没有意义,这也是 SGen 把并行能力集中在 Major 一侧的原因。

并行标记的难点在于工作量分配,而不在于开线程本身。对象图的形状极不均匀:根附近可能有少数超大容器,拆开之后才谈得上给多个线程分活;若干线程同时进出标记栈又需要原子操作保护,竞争激烈时反而更慢。工程上的通用解法是让线程持有本地缓冲,批量进出标记栈,把同步频率降下来。清扫之所以没被并行化,是因为它本质是逐段遍历加空闲链表重建,内存写入密集,多线程拆分的收益有限而竞态风险高,属于典型的"并行化性价比不高"的阶段。


十、性能特征

下表给出典型范围(经验值,非精确基准,实际数字随堆大小、存活率与硬件浮动):

GC 类型 典型频率 典型暂停 说明
Nursery GC 高(分配密集时每秒数次到数十次) < 1ms 大部分对象在此死亡
Major GC 低(每几分钟一次) 10 - 50ms 全堆标记 + 清扫
Major Compact 极低(碎片越阈值时触发) 50 - 200ms 消除碎片,代价最高

在分配密集型负载(含游戏类主循环)中,Nursery GC 占总回收次数的 95% 以上,Major GC 不到 5%,这个结构正是分代设计想要的结果:贵的回收少做,便宜的回收多做。单次 Nursery GC 覆盖了绝大多数分配场景,因此平均暂停被拉得很低;Major GC 虽然暂停长,但频率低到可以接受;压缩则是碎片失控前的最后防线。

对帧率敏感的场景,真正需要盯的是两条曲线:Major GC 的间隔是否稳定,以及它的暂停是否落在帧预算之外。间隔突然缩短通常意味着存活集在膨胀或碎片在加速,此时该做的是排查业务侧的常驻对象,而不是先去调 GC 参数。

这张表是判断"当前瓶颈属于哪类 GC"的依据。日志里 GC_MINOR 刷屏而单次耗时稳定在 1ms 内,说明压力来自分配速率,优化方向是减少临时分配;GC_MAJOR 出现且耗时逼近区间上限,说明存活集大,优化方向是拆散常驻对象或压缩堆占用。两类问题的解法完全不同,混在一起谈"GC 太慢"没有可操作性。


十一、SGen 与 Boehm 架构对比

维度 SGen Boehm
标记方式 精确(vtable 描述符定位引用) 保守(扫描所有疑似指针的字)
对象可移动 可移动(复制晋升、可选压缩) 不可移动(虚假根会钉住对象)
分代 两代(Nursery + Major) 无分代,每次全堆回收
分配方式 TLAB + bump-pointer 按大小分级的空闲链表
跨代引用维护 卡表 + 写屏障 不需要
虚假根 无 有(整数误判导致内存滞留)
集成成本 高(JIT 需生成精确栈图与写屏障) 低(不依赖类型信息,容忍未登记引用)
暂停可控性 高(暂停与 Nursery 大小挂钩) 低(暂停随堆大小增长)

这张表也间接解释了 Unity 的选型:Boehm 的低集成成本与保守容忍度,恰好匹配 IL2CPP 这种以原生代码形态生成、栈上信息不必完全精确的后端;SGen 的能力上限更高,代价是对运行时配合的要求也更苛刻。两条路线的权衡,本篇章的 03-02 与 03-04、以及第 05 章会分别展开,这里先建立坐标系。


十二、实战配置建议

SGen 通过环境变量 MONO_GC_PARAMS 配置。以下面向使用开源 Mono 的独立程序(命令行工具、服务端进程、嵌入 Mono 的应用)。Unity 的内置后端不使用 SGen,给 Unity 设置这些参数不会生效;Unity 侧对应的抓手是 Incremental GC 开关与 Profiler,属于 03-04 的范围。

复制代码
# 常用 SGen 配置(Linux / macOS)
export MONO_GC_PARAMS="nursery-size=8m,major=marksweep,num_mark_threads=4,print-stats"
mono ./MyApp.exe

# Windows PowerShell
$env:MONO_GC_PARAMS = "nursery-size=8m,major=marksweep,print-stats"
.\MyApp.exe

// 各参数的取舍:
// nursery-size=4m|8m|16m
//   分配速率高、Minor GC 频繁时调大(如 8m),降低回收频率;
//   内存紧张或对象多为短命时保持默认 4m。
// major=marksweep         默认,吞吐量优先,会积累碎片
// major=marksweep-compact 碎片敏感场景,周期性压缩,暂停更长
// major=copying           低暂停优先,堆很大时吞吐量下降
// num_mark_threads=N      并行标记线程数,一般等于 CPU 核数
// print-stats             输出每次 GC 的耗时、扫描对象数、晋升量

调参没有万能解。正确顺序是:先用 print-stats 拿到真实分布(Minor 频率、晋升率、Major 耗时、卡表扫描量),再决定动哪一个参数。盲目把 Nursery 调大只是推迟问题;晋升率异常的根因多半在业务代码的持有方式上,不在 GC 参数里。参数能修的是节奏,修不了的是泄漏。


总结

SGen 是一套教科书级的精确分代回收器。vtable 描述符保证精确标记,为对象移动铺平道路;固定的 Nursery 配合 TLAB 与 bump-pointer,把分配做到接近零成本,把常规回收的暂停压到 1ms 以内;卡表与写屏障以极小的运行时代价,换来了不必扫全堆的 Minor GC;Major Heap 以低频的全量标记-清扫兜底,并保留压缩作为碎片的最终解法;并行标记则把最重的全堆工作摊到多个核上。整套结构呈现出"高频回收便宜、低频回收昂贵"的分代节奏。

同时要牢记边界:SGen 是开源 Mono 的默认 GC,Unity 发行的 Mono 与 IL2CPP 后端用的是 Boehm。本章的价值在于给整个系列立起分代参照系,第 04 章的源码剖析、以及与 .NET Core GC、Unity 增量式 GC 的对比,都以这里的机制为前提。精确、分代、写屏障这三个关键词,会在后续每一章反复出现。


代码示例:读取 GC 统计与配置

复制代码
using System;
using System.Runtime;

class GCStatsDemo
{
    static void Main()
    {
        // 制造一批短命对象,观察分配与回收节奏
        var buf = new byte[64];
        for (int i = 0; i < 200_000; i++)
        {
            buf[0] = (byte)i;
            if (i % 50_000 == 0)
                Console.WriteLine($"alloc #{i}, heap={GC.GetTotalMemory(false) / 1024}KB");
        }

        // 显式触发回收,读取各代回收次数
        GC.Collect();
        Console.WriteLine($"Gen0 collections: {GC.CollectionCount(0)}");
        Console.WriteLine($"Gen1 collections: {GC.CollectionCount(1)}");
        Console.WriteLine($"Gen2 collections: {GC.CollectionCount(2)}");
        Console.WriteLine($"Total memory:     {GC.GetTotalMemory(true) / 1024}KB");
        Console.WriteLine($"Server GC:        {GCSettings.IsServerGC}");
    }
}

# 启用 SGen 统计输出后运行(开源 Mono 环境)
export MONO_GC_PARAMS="print-stats"
mono ./GCStatsDemo.exe
# 输出片段(每次回收一行,典型形态):
# GC_MINOR: (user) time =  0.54ms, objects scanned = 18520, promoted = ...
# GC_MAJOR: (user) time = 15.12ms, objects scanned = 142058

# 确认进程使用的是 SGen:
#   mono --gc=sgen ./GCStatsDemo.exe   # 显式指定 SGen
#   ldd $(which mono) | grep monosgen  # 链接到 libmonosgen 即为 SGen
相关推荐
java_nnnn2 小时前
Redis 初识:从单体架构到分布式架构的演进
redis·分布式·架构
硅基手札3 小时前
【Linux 内核专栏 18】进程间通信 IPC(下)— Socket·Netlink·fd 三件套·futex·线程同步
linux·运维·驱动开发·单片机·嵌入式硬件·mcu·架构
安易算力12 小时前
昇腾CANN算子开发与MindSpore部署调优实践:从Ascend C到模型推理全链路
网络·架构
省钱兄--zs15 小时前
深圳24小时自助健身房软硬件解决方案实战指南与开发流程详解
java·数据仓库·spring boot·小程序·架构
用户03906147071915 小时前
PMSM 矢量控制(FOC)环路公式推导
架构
HaipengYu15 小时前
从 Config Hook 到 Server/TUI:OpenCode 插件 V1 到 V2 的架构演进
人工智能·架构
罗丹丹Luna16 小时前
移动端显示“已提交”以后:SAP 与低代码集成该怎样暴露真实状态
后端·架构
吴凡wudianmei16 小时前
每调一次模型都花钱?如何用工程手段把调用压到最少?
前端·后端·架构
欣欣之王来了16 小时前
Vue3项目脚手架:Vite vs Vue CLI怎么选?
前端·学习·架构·项目·vue3教程