06-02-源码-增量标记算法实现

源码深度:增量标记算法实现

篇章 :06-源码深度-Unity增量式GC | 阅读时间 :约 50 分钟 | 前置知识:增量标记原理、对象图遍历


一、引言与前提纠正

本章要回答的问题只有一个:Unity 增量 GC 的标记阶段,在源码层面到底是怎么实现的。它是怎么把"标记整个堆"这件几十毫秒的大事切成小步的,切完之后状态存在哪里,标记过程中世界还在变又靠什么兜住正确性。

SGen 从来没有随 Unity 产品发布过,Unity 的增量 GC 就是 Boehm-Demers-Weiser 收集器的 incremental mode,Mono 与 IL2CPP 两个后端皆是如此(03-04 章已经划清了这条界线)。本文将那版大纲全部作废,如果你在别处看到把 Unity 增量 GC 和 SATB、三色屏障绑在一起当"Unity 源码"讲的文章,默认那是对其他收集器的移植想象。

错误模型的危害不止名字不对,它会让你找错调优对象。按"三色 + SATB"的模型,你会去关心屏障队列长度、灰栈水位这类在 Unity 里根本不存在的东西。真实的 Boehm 增量标记,正确性基石不是写时快照,而是页粒度的脏页重扫;推进引擎不是"每帧弹 N 个灰对象",而是按工作亏空记账的 GC_mark_some。本章接下来八节就沿着这两个真实机制展开:先回顾 Boehm 的标记算法本身(第二节),再看它的增量推进器(第三节)和大数组断点续扫(第四节),然后是正确性的核心脏页复查(第五节)、增量窗口的 STW 边界(第六节)、残余风险(第七节),最后与其他收集器做算法层对比(第八节)。调度层面"每帧给多少时间、切不完怎么办"的细节属于 06-03 章,写屏障的生成细节属于 06-04 章,本章只讲标记算法内部。


二、Boehm 标记算法回顾

2.1 标记栈与灰色对象推进

Boehm 是标准的标记-清扫收集器,标记阶段的核心数据结构是一条标记栈(mark stack)。所有从根或已标对象里发现的"已标记但子引用未扫描"的对象压入栈中,标记器不断弹栈、扫描其子引用、把新发现的未标记对象再压回去,直到栈空。

复制代码
(示意,非源码逐行对应)
根集合(栈槽、静态区、寄存器、注册根)
   │ 逐个读出,可疑值先做保守识别
   ▼
标记栈:[ objA, objB, ... ]        ← 栈里的对象是"待扫描"状态
   │ pop
   ▼
扫描 objA 的每个引用槽
   ├─ 槽值指向未标记对象 → 打标记、压栈
   ├─ 槽值指向已标记对象 → 跳过
   └─ 槽值不像堆内指针 → 忽略(保守扫描的日常)
   ▼
回到循环开头,直到栈空 → 标记完成

这里没有经典教科书里的"灰→黑"显式染色切换。Boehm 的标记信息压在对象的标记位上,"灰"这个状态由标记栈本身承载:在栈里就是待扫描的,弹出来扫完就是"黑"了,只是没有一个单独的黑色字段记录这件事。理解这一点很重要,因为后面讲增量时你会发现,增量要保存的"断点"就是这条栈的位置和内容,而不是一套颜色位图的状态机。

标记栈本身也有工程细节。栈空间是有限的,Boehm 的处理方式是栈满时把一部分对象"降级"记到溢出结构里,甚至临时退回保守策略处理局部块。对增量模式而言,这意味着标记状态不止一条栈,还包括与之配套的溢出记录与当前扫描游标,这些全部位于 GC 管理的持久内存区域,不属于任何一帧调用的栈帧,这是后面第三节"跨帧续扫"成立的物理基础。

2.2 GCJ 风格的描述符标记

Boehm 支持一个在同类收集器里相当特殊的模式:GCJ 风格的元数据标记。它最初为 Java 运行时设计,做法是把"某个对象内部哪些位置是指针"的描述信息以函数指针(描述符)的形式挂在类型元数据上,标记器扫到这类对象时调用对应的描述函数,由它告诉标记器哪些字节范围内是指针、偏移如何跳转。

对 Unity 这条链路来说,IL2CPP 生成的代码并不走 GCJ 描述符这条路径,它用的是更常规的按布局扫描。但这个机制对理解第四节是必要的铺垫,因为 Boehm 处理"值类型大数组"的增量扫描入口恰恰挂在 GCJ 向量扫描的机制上。你只需要记住它的形状:对象的扫描方式不是一刀切地把整块内存当指针数组读,而是按类型走不同的扫描函数,每种扫描函数都可能有自己的"扫描到一半"的进度状态。

2.3 精确扫描与保守扫描两条路径

Boehm 在引用识别上是保守的:任何字里存的值,只要落在某个已分配堆块的地址范围内,就被当成引用处理,不管它在托管语义里是不是真的指针。这条策略贯穿始终,包括增量模式。

但在扫描执行层面,它其实有两条并存的路径:

  • 按布局扫描:编译器或绑定层知道对象的指针布局(比如 IL2CPP 侧知道一个托管对象哪些字段是引用),扫描函数只读指针槽,跳过纯值字段。这条路更接近精确 GC 的执行效率,但识别策略仍是"槽里的值落在堆内就当引用",所以仍是保守判定加精确布局的混合体;
  • 整块保守扫描 :对布局未知的内存(比如 GC_MALLOC_UNCOLLECTABLE 分配的裸缓冲、部分内部结构),逐字读取,每个字都做指针识别。

值类型数组是一个特例,它内部很多槽根本没有指针(比如 int[]、float[]),但 Boehm 无法从类型系统层面确定这一点时(GCJ 模式下描述符标记为向量类型、或者绑定层没有提供布局信息时),会走"向量扫描"路径:把整个数组内存当连续区域快速过一遍做保守识别。一个几万元素的值类型数组,整块扫完可能就吃掉几毫秒,这直接和"每帧只给 GC 一个小时间片"的增量目标冲突。第四节会看到 Boehm 怎么解决它。


三、增量化的本质:GC_mark_some 是可分步的标记推进器

3.1 一次调用只推进有限工作

把第二节的标记器想象成一个函数 GC_mark_some:它的任务是推进标记直到完成,但被改造成"每次调用只干一段有限的活,然后返回"。返回后,标记栈、溢出结构、扫描游标原封不动地留在 GC 的持久内存里,等下一次调用从原地续起。这就是增量标记的全部魔法所在,没有别的秘密。

推进器的上游是 alloc.c 里的 GC_collect_a_little_inner(int n),它是引擎侧每次"泵一小步"最终落到的函数。它的核心是一个按工作亏空(work deficit)推进的循环,示意如下:

复制代码
// 示意:GC_collect_a_little_inner 的亏空循环结构
// (对照 Boehm alloc.c,省略错误处理与统计细节)
void GC_collect_a_little_inner(int n) {
    int i = 0;
    int deficit = GC_rate * n;      // 本步预算:工作量单位,不是毫秒
    while (i < deficit && GC_mark_some((ptr_t)0) == 0) {
        // GC_mark_some 返回 0 = 标记还没完,继续推进
        i++;
    }
    // 若标记已完成且时间还有余:GC_stopped_mark(GC_timeout_stop_func) 收尾让路
    // 若连续多次切片仍完不成(GC_n_attempts 耗尽):
    //     GC_stopped_mark(GC_never_stop_func) + GC_finish_collection()
    //     回退为一次做完,调度细节见 06-03
}

注意预算单位。GC_rate * n 记的是"工作量亏空",不是毫秒数。每完成一小步标记就扣减一份亏空,扣够了就返回,时间上限只是外层防止干过头的保险丝。这回答了一个常见疑问:增量标记为什么能精确地"断点续扫"?因为它压根不是按时间切完再存状态,而是每一步都建立在一个本来就持久的状态机上,时间片到点中断时状态天然合法。

3.2 任意一次返回后,堆都处于合法中间态

"合法中间态"值得展开说。GC_mark_some 返回的那一刻,系统满足三个不变式:

  1. 标记栈里的每个对象都已打上标记位,只是子引用还没扫完;
  2. 已扫完的对象,其所有子引用要么已标记,要么被判定为非引用;
  3. 还没有任何白色对象被回收(清扫尚未开始)。

这三条合起来意味着:此刻停止标记、直接进入清扫,也不会把"根本可达但还没发现"的对象之外的东西误伤,但会把"本该存活却还没来得及发现"的对象当垃圾清掉,也就是漏标。所以 Boehm 不能随便在中间态转入清扫,必须把标记做完、或把中间态的欠账(脏页)在收尾时补齐。反过来,中间态允许"被打断":无论在哪一步返回,下一次调用都从当前栈顶和游标继续,不需要重头再来,也不需要重建任何上下文。这就是可重入、可断点续扫的准确含义。

复制代码
(增量标记的一轮时间线)
帧 k      帧 k+1    帧 k+2    ...    帧 k+m
[推进一步] [推进一步] [推进一步]       [推进一步]
   │         │         │               │
   └─────────┴─────────┴─── 状态常驻于 GC 持久内存 ──┘
                                    │
                                    ▼
                        标记完成 → 收尾 STW → 清扫

增量窗口期间应用线程照常跑、照常改引用,第五节处理由此产生的问题。这里先把推进器本身的行为说完整。

3.3 推进器视角下的两种返回

从调用者视角,一次推进的结果只有两种:这一步干完了但整轮标记还没完(GC_mark_some 返回"未完成",循环继续或本次时间片到点返回);或者标记已经收尾。收尾路径有两种形态:如果时间预算还有剩余,用可超时的停止函数做一次 GC_stopped_mark 把剩余标记一口气做完并让出;如果连续多个切片都完不成(改引用的速度追上了标记速度,GC_n_attempts 一族计数器耗尽),就用永不停止的停止函数强行做完整轮,外加 GC_finish_collection() 收摊。后者就是 03-04 章讲的回退,06-03 章会展开它的调度账本。本章只强调边界:无论走哪条收尾,最终都汇入同一个 GC_stopped_mark,标记算法本身不需要为"增量版"和"回退版"准备两套逻辑,它们用的是同一个推进器,区别只在什么时候停手。


四、值类型数组的断点续扫

4.1 为什么单个大对象能劫持时间片

第三节的推进器建立在一个隐含假设上:标记工作可以被切成大致均匀的小份,每份对应弹出一个对象、扫完它的引用。对普通托管对象,这个假设成立,单个对象通常几字节到几百字节,扫描耗时可以忽略。但值类型大数组打破了它。

想象一个 byte[4 * 1024 * 1024] 的缓冲(动画缓存、地形高度图、网络收包缓冲都可能是这个量级)。按 2.3 节的向量扫描路径,整个数组要当连续区域过一遍做保守识别。如果 GC_mark_some 必须一次扫完这个数组才能返回,那么"每帧 3ms 时间片"的承诺就被这一个对象击穿了:时间片再小,这个数组的扫描也必须整段落在某一帧里。堆里再有若干个这种量级的数组,单帧暂停会重新膨胀到几十毫秒,增量等于白做。

问题的根源不是数组大,而是"扫描进度"这个状态粒度太粗:普通对象扫完与否是二值的(要么没弹出来,要么整个扫完),中间没有可保存的进度点。

4.2 GC_gcj_vector_proc:按 256 元素分块

Boehm 的解法是给向量扫描加进度游标。GC_gcj_vector_proc 一族的向量扫描函数把大数组按固定元素数分块推进,Unity 这条链路上每步处理 256 个元素,扫完一块就返回控制权,游标留在数组内部,下次从游标处续扫。示意:

复制代码
// 示意:向量扫描的分块推进(对照 Boehm 向量标记函数的结构)
// p_current 保存在 GC 持久状态里,跨调用保留
void GC_gcj_vector_proc(void *p_current, ...) {
    word *base = <数组起始>;
    word *end  = <数组结束>;
    word *p    = p_current;          // 上次扫到哪里

    while (p < end && 本次预算未用完) {
        word *chunk_end = p + 256;   // 一块 256 元素
        if (chunk_end > end) chunk_end = end;
        while (p < chunk_end) {
            // 逐元素保守识别,命中的引用照常打标记、压标记栈
            p++;
        }
        扣减工作亏空;
    }
    p_current = p;                   // 游标回写,断点在此
}

有了这个游标,一个 4MB 的数组不再是一次不可分割的 3ms 停顿,而是摊在若干个时间片里的连续小块。推进器的亏空记账也能正确反映真实工作量:扫了 256 个元素就扣 256 个元素的钱,不会出现"账本上一小步、实际一大坨"的失真。

4.3 分块扫描的连带设计

分块带来一个必须回答的问题:数组内部扫描到一半时中断,另一半里的引用岂不是漏了?答案在第二节的不变式里:游标之前的元素已经识别过的引用都已打标记压栈,游标之后的元素尚未扫描,但它们所属的数组对象本身已标记,数组整体在标记栈的追踪之下,续扫完成前标记不算收尾,也就不会进入清扫。换句话说,"对象已标记但内容未扫完"本来就是标记栈的常态,向量扫描只是把这个状态从"对象级"细化到了"数组内部偏移级",对正确性框架没有增加新约束。

顺带一提,256 是块大小的经验取值,不是可配置项。块太大则断点粒度粗,块太小则循环开销与游标维护成本上升。对使用者而言,它透明存在,你能观察到的只是"一个大数组不再单独撑爆一帧的 GC 时间"这个结果。


五、正确性核心:脏页重扫而非写时快照

5.1 问题:已扫过的对象又被写入了新引用

增量窗口里应用线程在跑,最危险的时序是这样的:

复制代码
时刻 T1:GC 扫描对象 X,读完 X 的全部引用槽,X 的扫描完成
时刻 T2:应用执行 X.field = Y,其中 Y 尚未被标记
时刻 T3:GC 继续标记,但 X 已弹出标记栈,不会再被读取
时刻 T4:标记宣告完成,Y 仍是白色
时刻 T5:清扫把 Y 当垃圾回收
时刻 T6:程序访问 X.field,拿到已释放的 Y → 崩溃或内存损坏

这就是漏标。所有增量或并发标记器都必须阻断这条时序链,差别只在用什么手段。经典理论给出两条路线,先讲清楚它们,再看 Boehm 的第三种选择。

5.2 经典两条路线:插入屏障与删除屏障

Dijkstra 插入屏障:在写入发生时检查新值。若黑对象(已扫完)写入了一个白对象,就把白对象染灰入队,保证它不被漏掉。它的口号是"黑到白的边不允许存在",只关心新建立的引用,实现上只需要在写入指令后插入一小段检查。

Yuasa 删除屏障(SATB,写时快照):在写入发生前记录旧值。它维护的是"标记开始时刻的对象图快照",任何被覆盖掉的旧引用如果还没标记,就补标记入队,从而保证快照里所有可达对象都被保留。它的口号是"标记开始后消失的引用也要算数",代价是会保留一批标记期间已经实际死亡的对象,也就是浮动垃圾。

两条路线的共同点:都在每次引用写入时做细粒度判断(对象级甚至字段级),用精确的队列操作换取精确的保留范围。这也是为什么很多人默认"增量 GC 一定有 SATB",本章开头纠正的正是这个默认。

5.3 Boehm 的选择:MANUAL_VDB 页粒度重扫

Boehm 走了第三条路,它不修改写入语义,而是记录"哪些内存页被写过",标记完成前反复复查这些页。

Unity 这条链路上启用的是 MANUAL_VDB(虚拟脏位的一种实现,配置见 gcconfig.h,Unity 所用 fork 的相关改动可追溯到 2018 年 Jonas Echterhoff 的提交)。它的工作方式:

  • 写屏障不改引用,只做一件事:计算被写指针所在的页,把该页在 GC_dirty_pages 里标脏。增量模式下 Unity 会为托管代码生成这些屏障(生成细节见 06-04 章);

  • 增量标记推进过程中,GC 通过 GC_read_dirty 消费脏页集合,把脏页上的对象重新纳入扫描范围;

  • 收尾时所有脏页被重扫一遍,5.1 节那条时序链在 T3 与 T4 之间被打断:X 所在的页在 T2 被标脏,收尾重扫会重新读 X 的引用槽,Y 在这一刻被发现并标记,T5 的误回收不会发生。

    (脏页复查示意)
    应用写入 X.field=Y ──写屏障──▶ GC_dirty_pages[X所在页] 置脏
    │
    GC 标记推进中,周期性 GC_read_dirty ◀──┘
    │ 取出脏页集合
    ▼
    重扫脏页上的对象,新引用补标记、重新入标记栈
    │
    ▼
    收尾 STW:清点剩余脏页,全部重扫完毕才允许转入清扫

与上游 Boehm 对比一下:Boehm 原生在多数平台上用的是 MPROTECT_VDB,靠 mprotect 把堆页设为只读、缺页异常处理函数标脏,不依赖每次写入插入屏障代码。Unity 选择 MANUAL_VDB,换来的是跨平台行为一致、不依赖信号机制,代价是每次引用写入都要付一条屏障指令。两种 VDB 都是页粒度,脏页复查的语义相同。

5.4 得失分析:为什么页粒度重扫对 Boehm 是对的

这个选择对 Boehm 是自洽的,得失都很具体。

得:实现简单。不需要对象级队列,不需要区分插入/删除语义,不需要维护 SATB 快照,脏页集合就是全部增量正确性状态。与保守扫描天然契合:保守扫描本来就"宁可多标",重扫一个页大不了多标几个碰巧落在页内的值,不会引入新的漏标风险。不移动对象也简化了问题,页地址在整轮标记中恒定,脏位不需要跟着对象搬家。

失:粒度粗。典型估算,一页 4KB 在 64 位下容纳 512 个指针槽,页内牵一发动全身:哪怕只写了一个字段,整页 512 个槽都要重扫。若一页上密布大对象或页内多数对象都已扫过,重扫就是明显的重复劳动,这就是重扫放大。极端场景下(高频改写集中在少数热页),同一页可能被反复标脏、反复重扫,06-03 章讲的"进度原地踏步直至回退",相当一部分压力正来自这里的重复工作量。

放在 Unity 的场景里权衡,这笔交易仍然划算。游戏的托管堆里大量对象短命且分散,页粒度重扫的重复成本被保守扫描的容错性和实现的简单性抵掉了大半;而一个复杂的 SATB 屏障体系意味着每次引用写入更贵、可移植性更差,对"引擎级、双后端、全平台"的约束来说不是好买卖。没有免费的午餐,Boehm 只是选了最适合它自己性格的那份账单。


六、初始与收尾的 STW 边界

6.1 enable 时强制从干净状态开始

增量窗口不能从一个混乱的堆开始。misc.c 的 GC_enable_incremental 里有一个容易被忽略但至关重要的分支:启用时若发现堆里已有分配(GC_bytes_allocd > 0),先强制执行一次完整收集 GC_gcollect_inner(),把堆清干净,然后再调用 GC_read_dirty(FALSE) 初始化脏位跟踪。

为什么必须这样?增量标记的正确性建立在"标记从一个确定的起点出发"上。如果启用瞬间堆里还残留着上一阶段积累的对象和引用关系,而脏位跟踪尚未开始记录这些区域的写入,那么增量窗口里这些"历史遗留页"上的任何写入都不会被标脏,第五节的重扫机制对它们直接失效,漏标风险从启用的第一帧就埋下。先做一次全量收集,等于把所有对象重新归类、让所有页进入已知状态,增量窗口从这张干净的底图上开始工作。这是增量模式对自身前提的一次自检,也是为什么"运行中途打开增量开关"的那一刻会看到一次出乎意料的完整 GC 停顿。enable 路径的引擎侧全貌属于 06-01 章,本节只讲它对算法起点的意义。

6.2 收尾:GC_stopped_mark 一次做完剩余标记

增量窗口关闭时(标记推进完毕,或者触发回退),剩余的标记工作由一次 GC_stopped_mark 一次性完成,参数决定这次停止函数是否会因时间预算让路:正常收尾用可超时的 GC_timeout_stop_func,回退路径用永不停止的 GC_never_stop_func。

收尾 STW 的职责边界要分清楚。它要做的事:扫清标记栈里最后一批对象、重扫所有仍挂着的脏页(这是第五节时序链的最终封口)、确认标记完成。它不做的事:清扫本身(那是标记完成后的一次性工作,不分片,见 06-03 章)、以及任何调度决策(什么时候进入收尾、收尾失败了怎么办,都是调度层的事,细节同样在 06-03 章)。

从算法视角看,收尾 STW 是增量标记给自己留的"最后一致性窗口"。增量窗口里所有被推迟、被合并、被粗粒度记录的欠账,都在这一刻被全额结清,结清之后堆上不再有任何未决的标记状态,清扫才能安全开始。这一帧的长度取决于脏页数量和剩余标记栈深度,典型估算仍在零点几毫秒到低毫秒量级,具体由堆规模与增量期间的改写密度共同决定。


七、漏标与浮动垃圾的残余风险

7.1 保守扫描:宁可多标,不可漏标

Boehm 的保守识别在增量语境下反而成了一层保险。任何落在堆地址范围内的值都会被当引用处理,包括整数、浮点数、指针运算的中间值,只要它们碰巧长得像指针。这意味着:

  • 少量真正的漏标窗口被"碰巧有值落在目标地址"补上的概率不为零,尤其是堆布局密集、地址连续的场景;
  • 更重要的是框架性的作用:脏页重扫本来就是"宁滥勿缺"的设计(多扫一页不会致错,漏扫一页才会),保守扫描在识别层面延续了同样的哲学,两层"宁可多标"叠加,把增量模式下的漏标崩溃概率压到了实践中相当罕见的水平。

这不是说漏标不可能,第五节那条时序链依然需要脏页机制严格阻断,保守扫描只是在脏页机制万一失效(比如某条未走屏障的写入路径,或平台差异导致的脏位遗漏)时,提供了一层概率性的冗余,而不是确定性的保证。把冗余当保证用,迟早出事。

7.2 代价:误保留、堆膨胀与碎片

"宁可多标"的账单有三张。

误保留。看起来像指针的值把本该死掉的对象多标成存活,它会活过这一轮,直到下一轮那个可疑值恰好不再出现。这直接推高了每轮的存活集合,GC 看到的"有效堆"比真实需要的大。

堆膨胀。误保留叠加上增量窗口里"出生即标记"的新对象(标记期间分配的对象一律按存活处理,避免被本轮误伤),增量周期里的存活集合通常比同场景非增量模式更大。堆只能涨不能缩,膨胀会延续到后续轮次。

碎片。Boehm 不压缩,对象永远不搬家,回收后的空洞只能靠空闲链表复用。误保留制造的"半死不活"对象散布堆中,把连续的大空洞切碎,后续大分配找不到合适块时只能向系统再要内存,进一步加剧膨胀。这一点与 03-02、03-04 章讲的 Boehm 碎片问题是同一件事,增量模式没有缓解它,只是没有让它变得更糟。

7.3 一个反直觉的结论

把三、四、五、六节串起来会得到一个有点反直觉的结论:Unity 增量 GC 的正确性,与其说靠"精巧的屏障算法",不如说靠"粗糙但容错的整体设计"。页粒度重扫牺牲了精度换来实现简单和平台一致,保守扫描牺牲了识别精度换来对漏标的天然抗性,新对象直接标记牺牲了回收及时性换来状态机的自洽。每一处都在用"多做无用功"换"少犯致命错"。对一个嵌在游戏引擎里、要同时跑在 Mono 和 IL2CPP 上、要覆盖从手机到主机的收集器来说,这是务实到近乎必然的选择。


八、算法层横向对比

把 Boehm 增量与另外三代主流方案放在同一张表里。维度刻意选在算法层面,而不是产品特性层面:

维度 Boehm 增量(Unity) .NET 后台标记 ZGC / Shenandoah
中断粒度 时间片分帧推进,首尾短暂 STW 分代分阶段,标记期并发,重新标记 STW 几乎全程并发,仅在少数安全点短暂暂停
屏障类型 写屏障只标脏页(MANUAL_VDB),不改语义 卡表 + SATB 风格屏障,服务于分代与增量正确性 加载屏障(读屏障),配合颜色指针
对象是否移动 不移动,非压缩 移动,分代压缩 移动,配合染色指针转发
正确性机制 脏页复查 + 保守识别双重兜底 屏障保证引用关系不丢,精确识别 颜色指针 + 加载屏障在读侧拦截失效引用
适用取向 嵌入式集成、跨平台一致性优先 通用运行时,吞吐与内存利用率优先 大堆低延迟优先,实现复杂度最高

表里三列的差异本质上是"在哪一侧付出代价"。Boehm 把代价放在 GC 侧(重扫放大、误保留),换来托管代码侧的屏障极简和实现的可移植。.NET 把代价放在屏障侧(每次引用写入更贵、卡表要维护)和压缩侧(移动对象要修补引用),换来精确识别和更好的内存利用率。ZGC 与 Shenandoah 把代价放在读侧(每次引用读取都要过加载屏障)和实现复杂度上,换来近乎无感知的暂停,这是概念层面的对照,Unity 并未采用这类机制。

需要强调的是,这张表比较的是算法取向,不是优劣排名。Boehm 的"脏页 + 保守"组合在游戏这个特定场景里是低风险选择,让它在不引入现代收集器复杂度的前提下把峰值暂停压进帧预算;反过来,一旦项目迁到 Unity 6 的 CoreCLR 后端,评估对象就整列右移到 .NET 那套卡表与压缩的体系,本文的机制分析随之整体失效。选型的正确姿势永远是先确认自己站在哪一列上。


九、本章小结

把全章收拢成五句话。Unity 增量 GC 的标记算法就是 Boehm 的 GC_mark_some,一个把标记栈和扫描游标驻留在持久内存里的可分步推进器,GC_collect_a_little_inner 按工作亏空驱动它,任意一次返回后堆都处于可断点续扫的合法中间态。值类型大数组通过 GC_gcj_vector_proc 按 256 元素分块推进,防止单个大对象劫持整个时间片。正确性不靠 SATB 快照,靠 MANUAL_VDB 的页粒度脏记录与 GC_read_dirty 的持续复查,用重扫兜住"已扫对象又被写入新引用"的漏标窗口,与保守扫描的"宁可多标"哲学一脉相承。增量的首尾各有一个 STW 边界:enable 时若堆非空则强制全量收集保证干净起点,收尾时 GC_stopped_mark 一次结清剩余标记与脏页欠账。误保留、堆膨胀与非压缩下的碎片依然在,只是被整体设计控制在可接受范围。


本章总结

本章从错误前提纠正出发,还原了 Unity 增量标记算法的真实实现:它不是 SGen 的三色改造,而是 Boehm 增量模式下的页脏位复查体系。四个核心机制撑起了整个设计:可分步的标记推进器(GC_mark_some 与亏空记账)、大数组的游标化扫描(256 元素分块)、页粒度的脏记录与重扫(MANUAL_VDB)、以及首尾两处 STW 边界的状态自检。它们共同保证了"每帧几毫秒"的承诺在对象图持续变化的现实中依然成立。

带着三个问题离开本章。为什么增量标记能断点续扫而不重头再来?因为状态常驻 GC 持久内存,不是每帧重建。为什么开了增量还要担心漏标崩溃?因为漏标窗口由脏页重扫阻断,保守扫描只是概率性冗余,真正的保障在 GC_read_dirty 的复查纪律里。为什么 Unity 不用"更先进"的 SATB 或并发标记?因为页粒度重扫与保守扫描这对组合,在"双后端、全平台、引擎级集成"的约束下,用最少的复杂度买到了足够的正确性,这笔账在 03-04 章的架构视角和本章的算法视角下是同一个结论。

相关推荐
碧海银沙音频科技研究院1 小时前
实现炬芯ATS308X的usb cdc 串口IAP 固件升级功能实现
人工智能·嵌入式硬件·深度学习·算法
云淡风轻~窗明几净1 小时前
角谷猜想与百度AI的对话dialog(原始稿)
人工智能·算法·机器学习·图论
淡海水2 小时前
06-01-源码-Incremental GC总览与设计
unity·c#·gc
小O的算法实验室2 小时前
ASOC,分层分数阶粒子群算法+无人机电场路径规划
算法
重生之小比特2 小时前
【算法拾光】二分查找|搜索插入位置 + x 的平方根
算法
智购科技自动售卖机厂家3 小时前
双摄像头方案F1-score只有0.85,排查发现是上下视角的融合逻辑写反了~YH
python·单片机·嵌入式硬件·算法·eclipse
江沉晚呤时4 小时前
零依赖的代价与自由:我用 C# 自研了一套 Office 读写库
开发语言·c#
?abc!4 小时前
(动态规划+DFS)494. 目标和
算法·深度优先·动态规划
信奥卷王4 小时前
[GESP202406 六级] 二叉树(含视频)
数据结构·算法