.NET Core GC 标记与扫描
篇章 :07-源码深度-.NET Core GC
阅读时间:约 45 分钟
一、引言
标记阶段是 GC 的核心------它从 GC Roots 出发遍历整个对象图,标记所有存活对象。.NET Core GC 的标记系统融合了并行标记 、后台标记 、卡片标记三种技术,是工业级 GC 标记算法的代表作。
这一阶段同时决定两件事:正确性和暂停时间。标错的代价不是多留几个垃圾,而是把活对象交给清扫阶段销毁,程序随后在完全无关的位置解引用一块已经回收的内存------崩溃现场和 GC 没有任何表面关联,这类问题排查起来成本极高。所以标记的全部设计都把"不漏标"放在第一位,把"快"放在第二位;并行、后台、卡片三项技术,全都是在不破坏正确性的前提下抠出来的时间。
标记也是暂停时间的主要来源之一。根扫描与收尾必须停顿世界,中间遍历对象图的部分可以并行化、也可以搬到后台与应用线程并发执行,但每一笔搬运都要付记账成本:并行要付任务切分与线程同步,后台要付写屏障与卡片补扫。读懂这些记账方式,才能读懂本专栏后面关于 Server GC 与 Background GC 的全部取舍。
本文按"根 → 状态 → 并发 → 优化 → 度量"的顺序展开:第二节拆根扫描的来源,第三节讲并行标记与标记栈,第四节给出三色状态与标记不变式,第五、六节讲卡片表及其跨代联动,第七节讲后台标记线程与用户线程如何协作,第八节是对象图遍历与标记队列的优化,最后两节交代量级口径、误区与实践清单。清扫与压缩不在本文范围内,见本篇 07-04;后台 GC 的整体编排见 07-05;堆结构与分代布局的背景见 03-05。
二、根扫描:标记的起点
2.1 为什么根必须先停再扫
任何标记算法都要先回答一个前置问题:从哪里开始走。这些确定存活的起点就是 GC Roots,而根扫描是整轮标记里唯一没有回旋余地的部分------漏掉一根根,它指向的整棵子树都会被清扫阶段当成垃圾销毁,程序之后摸到这棵子树的任何节点都会崩。
根扫描必须在应用线程静止的状态下进行。原因是数据一致性:线程如果还在跑,栈上的引用可能正被改写,扫描器读到的是一个自相矛盾的中间状态------前半帧刚读完,后半帧引用已经换人。误标(把垃圾当活的)只是浪费内存,漏标(把活的当垃圾)是崩溃,所以任何不确定的瞬间都不允许出现在根扫描里。运行时用协作式挂起解决这件事:托管线程在安全点响应挂起请求,挂起瞬间的寄存器与栈内容构成一份自洽快照(挂起与安全点的实现细节随运行时版本有差异,此处描述的是通用模型,属合理推断)。
这顺带解释了根扫描的耗时特征:它正比于活跃线程数、栈深度和句柄数量,与堆大小基本无关。堆涨到几百 MB 时根扫描依然是毫秒以下的量级(典型估算,非实测常数),因为堆里的对象根本不参与这一步。随堆膨胀的是后续的对象图遍历,那才是并行化和后台化的收益所在。
2.2 四类根的来源
根不是一团模糊的"程序里所有引用"。在收集器视角下它分类清晰,每类的载体和扫描时机都不同:
| 根类别 | 载体 | 扫描时机 | 说明 |
|---|---|---|---|
| 栈与求值栈 | 各托管线程的栈帧 | STW 根扫描(挂起瞬间) | 局部变量、方法参数、求值栈临时值 |
| 寄存器 | 挂起时保存的寄存器快照 | 与栈同一次扫描 | 返回地址被劫持时寄存器里的对象指针 |
| 静态字段引用 | 类静态变量,经 GC 静态句柄暴露 | STW 根扫描 | 单例、缓存、全局配置对象 |
| 句柄表 | GCHandle:强、弱、固定、引用计数 | STW 根扫描 | 托管与非托管之间的引用锚点 |
| 终结器队列 | 等待执行 Finalize 的对象 | 标记期间按可达性判定 | 不阻止回收,阻止"终结前被释放" |
四类根汇入标记器的路径可以画成一张漏斗图:
┌─────────────── 根集合(GC Roots)───────────────┐
┌─────────────┼──────────────┬─────────────────┬───────────────┤
│ │ │ │ │
托管线程栈 寄存器快照 静态字段 句柄表 终结器队列
(每线程一份) (挂起瞬间) (经静态句柄) (GCHandle 全表) (按队列枚举)
│ │ │ │ │
└─────────────┴──────┬───────┴─────────────────┴───────────────┘
▼
STW 根扫描:逐个登记 → 压入标记栈(灰)
│
▼
对象图遍历(并行 / 后台)→ 清扫与压缩(07-04)
栈与寄存器是"动态的根",它们随执行流动,只能在挂起的那一刻取快照;静态字段与句柄表是"静态的根",挂在堆外的元数据结构上,任何时候都能枚举,但为了与对象图状态一致,仍放进同一次 STW 一起扫。这个区分在后台标记里会再次出现:动态根要重新扫(应用线程又跑了很久),静态根的变化则由句柄操作与写屏障记下来。
2.3 句柄表:收集器看不见的引用必须登记
精确式收集器有一条铁律------收集器必须能说出"这个位置存的是不是引用"。栈上的局部变量编译器知道,静态字段元数据里标着,但有一批引用天然处在收集器视野之外:托管代码传给非托管代码的引用、非托管代码回调时持有的引用、运行时自己缓存的引用。句柄表就是它们的登记处。
GCHandle 的几个取值对应不同的根语义,值得分开看:
- 强句柄:等价于一根普通根,被它指着的对象在本轮必定存活;
- 弱句柄:不参与"是否存活"的判定,只在标记结束后回访一次------目标没被标记,句柄自动清空。缓存、观察者模式依赖的正是"目标可以死、句柄不会悬"的语义;
- 固定句柄:既当根又下达"不许动"的指令,压缩阶段会跳过它,这是 07-04 第七节的伏笔;
- 引用计数句柄:供跨运行时场景按计数维持生命周期。
终结器队列是另一处容易被误解的根。待终结的对象不是"因为排队所以存活",而是先由句柄表保持可达参与标记判定;只有标记证明它从程序其余部分已不可达,它才会被移入终结队列执行 Finalize,下一轮才真正释放。这是"有终结器的对象至少多活一轮"的源码层解释,也是大量实现了终结器的对象释放滞后、内存迟迟不回落的根因。
2.4 精确扫描与保守扫描的分界
对照本专栏反复出现的 Boehm,可以把 .NET 的选择看得很清楚。Boehm 是保守式:栈上任何一个长得像指针的整数都会被当成引用,宁可错留不可错杀,代价是垃圾可能因为"恰好有个同值整数"而活下来。.NET 是精确式:引用位置由编译器与元数据精确给出,不会误留垃圾,代价是收集器看不见的引用必须走句柄登记------这就是句柄表存在的全部理由,也是为什么在非托管代码里缓存托管指针必须用 GCHandle.Alloc,直接把地址拿走就是给自己埋一个"对象被回收、地址突然失效"的雷。
精确式的另一项成本落在对象图遍历上:每个引用字段都要按元数据逐一识别,值类型字段要逐字段下钻,大数组要逐元素过。第八节的遍历优化正是冲着这个成本去的。
三、并行标记与标记栈
3.1 多线程并行
在 Server GC 模式下,每个 CPU 核有一个独立的堆,每个堆有自己的 GC 线程并行执行标记:
// gc.cpp - 并行标记入口(简化)
void GCHeap::MarkPhase()
{
if (IsServerGC())
{
// 每个堆并行标记
ParallelFor(0, heap_count, [&](int heap_idx) {
gc_heap* heap = heaps[heap_idx];
heap->MarkHeap();
});
}
else
{
// Workstation GC:单线程标记
single_heap->MarkHeap();
}
}
这段简化代码把并行的形态说清楚了:Server GC 下的并行是"每堆一线程",粒度天然合适------Server GC 本来就按核把堆切开了,标记任务的边界与堆的边界重合,线程之间几乎不必共享工作结构,各自把自己堆里的灰对象清空即可。堆的物理分割与每核一线程的布局见 03-05 第二、三节,本文不重复。
Workstation 分支按默认形态写成单线程,这是最常见的配置:一台 GC 线程,标记全过程串行执行,好处是零同步开销、缓存行为稳定,代价是标记时长无法靠多核压缩。需要说明的是,运行时允许通过 GCHeapCount 一类配置给工作站模式也开多个 GC 线程做并行标记(配置项与生效细节以所用运行时版本为准),并非只有 Server GC 才能并行;原稿的单线程分支应读作"默认一台 GC 线程时的执行方式"。
并行真正难的地方不在"同时开跑",而在两件事:工作怎么切,跑完怎么合。每个 GC 线程先从属于自己的那部分根起步(栈、静态、句柄各自分片),把扫到的灰对象压进自己的 标记栈------每个线程一份私有栈,避免每次压弹都去碰共享结构,这是并行标记把同步成本压到最低的关键设计。私有栈先清空的线程不会闲着,源码里存在线程之间借用与转移标记工作的路径(具体符号与判定条件以 src/coreclr/gc/ 源码为准,本文按通用模型描述,属合理推断);Server GC 下遇到归属其他堆的对象,则按对象所属堆路由回对应的工作结构。
3.2 标记栈
每个 GC 线程维护一个标记栈,用于三色标记的灰色节点遍历:
struct MarkStack {
Object** stack;
int top;
int capacity;
void Push(Object* obj) {
if (top >= capacity) {
// 扩容(罕见情况)
capacity *= 2;
stack = (Object**)realloc(stack, capacity * sizeof(Object*));
}
stack[top++] = obj;
}
Object* Pop() {
return top > 0 ? stack[--top] : NULL;
}
};
这是一份把三色算法落到内存上的最小实现。Push 对应"把对象染灰" :一个对象刚被发现、字段还没逐个看过,就先压进栈占位,它此刻的状态正是灰色。Pop 对应"取出灰对象开始扫":弹出后扫描器逐字段检查,把新发现的白色对象压回去,自己在扫描结束时转黑。栈的 LIFO 顺序天然形成深度优先遍历------先沿一条引用链走到底再回头,同一条链上的对象在内存里往往也相邻,缓存局部性优于广度优先,这是标记吞吐量的隐性来源。
capacity 的扩容分支注释为"罕见情况",这个判断有前提:栈的稳态占用等于"已发现但未扫完"的对象数,正常对象图里这个数字不大,因为灰对象被弹出后立刻转黑并压入子节点,栈不会持续堆积。真正的风险来自单个超大对象------几百万元素的数组被扫描时会瞬间涌入几十万个引用,把栈撑爆。所以标记栈之外还有可增长的标记列表与按块挂载的溢出结构作为回退(mark_stack / mark_list 一族符号,具体名称以源码为准,此处为简化示意):栈是有上界的快路径,涨不下的工作落到可增长结构,再涨不下就整块批量挂载。三级回退保证任何形状的对象图都装得下,而内存占用不会被一个数组绑架,这套分级在第八节展开。
四、三色状态与标记不变式
4.1 三种颜色的含义
三色不是实现细节,它是并发标记的正确性框架。每个对象在标记期间处于三种状态之一:
| 颜色 | 含义 | 所在数据结构 | 何时转色 |
|---|---|---|---|
| 白 | 尚未访问,标记结束仍是白色即为垃圾 | 不在任何工作结构里 | 被根或灰对象引用 → 灰 |
| 灰 | 已标记,但字段尚未扫完 | 标记栈 / 标记列表 | 字段全部扫完 → 黑 |
| 黑 | 已标记且子节点全部扫描完成 | 不在任何工作结构里 | 不再转色 |
状态转换画出来是一条单向路径,加上并发期的约束:
被根或灰对象引用 字段全部扫描完成
┌──────┐ ───────────────▶ ┌──────┐ ───────────────▶ ┌──────┐
│ 白 │ │ 灰 │ │ 黑 │
│ 未访问 │ ◀── 本轮不回退 ─── │ 待扫描 │ │ 已完成 │
└──────┘ └──────┘ └──────┘
│ (在标记栈/列表里) │
│ │
└──────────── 标记结束仍是白色 ────────────────────────┘
= 垃圾,交给清扫阶段回收(见 07-04)
并发期约束(标记不变式):
黑 ──引用──▶ 白 不允许"无记录"地出现
一旦出现,必须由卡片表登记,或在收尾 STW 中补扫回来
.NET 的实际实现不需要真的维护三块颜色位:对象头中有一个标记位(简化示意,具体位布局以源码为准),置位即"本轮已标记",等价于非白;灰与黑的区别由"是否还在工作结构里"天然表达------在栈上就是灰,被弹出扫完就是黑。这套编码把状态压缩成一个问题:"这个对象本轮被访问过吗?"访问过就不可能被回收,剩下的只是扫描进度管理。
4.2 新对象出生即已标记
标记期间应用线程还在分配,新对象怎么办?答案是出生即染黑:分配器把新对象的标记位直接置上。这一条同时解决三个问题。其一,本轮清扫不会误杀"标记开始后才诞生"的对象------它们没机会被遍历到,按存活处理是唯一安全的默认值。其二,不必回头补扫新分配区域,省掉一遍遍历。其三,它与分代回收天然兼容:新对象落在年轻代,本来就是本轮的幸存者。
代价藏在时间维度上:后台标记窗口拉得越长,边扫边生的对象就越多,堆在标记期间持续膨胀;分配速率高的程序会把标记窗口越撑越长,形成正反馈。这是后台标记追不上分配时必须切换到前台处理的根源,第七节的前台插队代码就是这个机制的出口。
4.3 漏标为什么比慢更可怕
把不变式说成一句话:黑色对象不允许直接引用白色对象而不留记录 。串行标记天然满足它------扫黑的过程中世界静止,黑指向的白一定已被压栈;并发标记会破坏它------GC 刚把 A 染黑,应用线程紧接着执行 A.ref = B,而 B 还是白色,遍历已经错过 B,B 就此蒸发。
所有并发标记算法的分歧点都在"怎么把这条违规边记下来":有的在写入时拦截(写屏障把目标染灰),有的在写入后登记区域(脏卡记录)。.NET 选的是后者------写屏障置脏卡片,收尾 STW 统一补扫脏区域。第五、六节讲这套记账,第七节讲它与后台线程的配合。
漏标的后果模式值得单独强调,它决定了工程上的优先级排序。漏标不会立刻报警:GC 认定 B 是垃圾,清扫把它回收,B 的内存随后被新分配改写;程序继续运行,直到某次读取 A.ref 拿到一块面目全非的数据,轻则字段乱码,重则类型错乱直接崩溃。崩溃点距离真正的错误(GC 结束那一刻)可能隔了几分钟、几百个逻辑步骤。所以评估一个 GC 优化该不该上,永远先问"漏不漏标",再问"快多少"。
五、卡片表:并发期间的变脏记忆
卡片标记用于追踪跨代引用。在 Gen 0 GC 时,只需要扫描卡片表中标记为"脏"的区域,而非全量扫描整个老年代:
// 卡表结构
// 每张卡覆盖 8 字节(card size = 8 bytes in .NET Core)
uint8_t card_table[HEAP_SIZE / CARD_SIZE];
void SetCard(void* addr) {
size_t card_index = ((uint8_t*)addr - heap_start) / CARD_SIZE;
card_table[card_index] = 0xFF; // 标记为脏
}
bool IsCardDirty(void* addr) {
size_t card_index = ((uint8_t*)addr - heap_start) / CARD_SIZE;
return card_table[card_index] == 0xFF;
}
// 扫描脏卡
void ScanDirtyCards()
{
for (size_t i = 0; i < card_table_size; i++)
{
if (card_table[i] == 0xFF)
{
// 扫描该卡覆盖的内存区域,寻找跨代引用
void* start = heap_start + i * CARD_SIZE;
ScanRegionForCrossGenRefs(start, start + CARD_SIZE);
card_table[i] = 0x00; // 清除脏标记
}
}
}
逐段读这段示意代码。卡表本体是一张按地址索引的字节数组 :堆被等分成固定粒度的"卡",给定任意堆内地址,除以卡粒度就得到卡号,直接下标访问------这一步没有任何查找结构,是 O(1) 的位次换算,所以写屏障可以把它压到几条指令里。SetCard 是写屏障的落点 :引用字段被改写时,运行时算出该字段所在卡号并置脏,原值 0xFF 与清零后的 0x00 构成一位标志(真实实现中脏位编码可能不止一档,本文按单档简化)。ScanDirtyCards 是收尾补扫的主体 :顺序遍历整张卡表,只对脏卡调用 ScanRegionForCrossGenRefs 展开细扫,扫完立刻把卡清回 0x00。
清卡时机是这段代码里最值得琢磨的一行。清早了会漏------补扫还没覆盖到的区域被抹掉记录,引用就此丢失;清晚了会白扫------已经补扫过的卡继续留脏,下一轮把同一块内存再扫一遍。所以它必须发生在"全部脏卡已扫描完毕"这个状态点上,也就是收尾 STW 内部,扫描与清零在同一轮循环里完成。这个顺序一旦被改动,并发标记的正确性就没了。
卡粒度本身是一组权衡:粒度越细,脏卡扫描浪费的字节越少,但卡表体积越大、写屏障的字节覆盖比例越高;粒度越粗,一张脏卡拖进的无关内存越多,跨代扫描越接近全扫。原稿代码给出的 8 字节口径保留在此,实际粒度以你所读版本的 src/coreclr/gc/ 源码常量为准,不同版本做过调整;本文后续讨论只依赖"固定粒度、地址直算"这两条结构性事实,不依赖具体数值。
5.1 卡片表的双重身份
卡片表在同一套数据结构上承担两个职责,混淆它们是理解 .NET GC 的常见障碍:
- 跨代记忆集:分代回收里,只收年轻代时必须知道"老年代里哪些对象指向了年轻代"。被写过的卡就是候选,扫脏卡等价于枚举可能的 old → young 引用,这是它在每一次 Gen 0/Gen 1 回收中的用途;
- 并发标记的变脏记录:后台标记期间,应用线程改写已染黑对象的引用字段,写屏障把所在卡置脏,收尾 STW 时这些卡就是补扫清单------这是它在一轮并发标记内部的用途。
前者常年存在,后者只在并发标记窗口内才有意义。两者共用一张表,因为它们要回答的问题本质相同:"这段时间里,哪块内存的引用被改过了。"
5.2 谁来置脏:JIT 插入的写屏障
置脏动作不在 C# 代码里,也不在业务程序的任何一行------它是 JIT 在编译引用赋值时插入的写屏障指令序列做的(典型如 .NET 的引用写屏障,具体指令序列以 JIT 生成结果为准)。这意味着两件事:其一,开发者无法绕过它,任何 obj.field = ref、任何数组元素写入都会自动记账;其二,它的成本被计入每一次引用写入,频率乘以几条指令,在引用写入密集的热循环里能被 Profiler 看到。
并发标记窗口内,写屏障还要额外判断"当前是否处于需要记账的状态"并置脏卡片,非并发期则主要维护跨代记忆集。这个判断让屏障在两种职责之间复用同一套置脏路径,也是"后台 GC 期间引用写入略慢于平时"的源码层原因。屏障的微观成本在 07-06 讨论极致优化时还会再出现。
六、跨代引用与卡片扫描的联动
6.1 老对象指向新对象的那条边
分代 GC 的基本假设是"大部分引用指向同代或更年轻对象",但反例一定存在:一个存活了很久的缓存对象,字段里指着刚 new 出来的值。做 Gen 0 回收时,这根边必须被当成根的延伸处理------只扫根找不到它,但它指向的对象是活的。
卡片扫描就是为这条边准备的枚举机制。老年代的对象被写过 → 所在卡变脏 → 回收年轻代时只扫这些脏卡,从卡覆盖的内存里逐字段挑出指向被回收代的引用,把目标标灰。老年代的其余部分一个字节都不用碰:
老年代内存(Gen 2) 年轻代(Gen 0/1)
┌────────────────────────────────────┐
│ 干净对象 │ 脏卡区 │ 干净对象 │ 脏卡区 │ ┌─────────────┐
│ │▓▓▓▓▓▓▓│ │▓▓▓▓▓▓▓│ ──ref──▶ 存活对象 A │
└────────────────────────────────────┘ └─────────────┘
▲ ▲
│ │
┌───────┴────────────────────┴───────┐
│ card_table:只有这两张卡是 0xFF │ Gen 0 回收 = 扫 2 张卡
│ 其余全部 0x00,直接跳过 │ 而不是扫描整个 Gen 2
└────────────────────────────────────┘
(示意图,卡数与覆盖范围为示意,非实际比例。)
6.2 脏卡比例决定收益
卡片扫描的收益不是恒定的,它完全取决于脏卡比例。引用改写温和的程序,脏卡零星分布,扫两张卡就能收完一个年轻代;引用改写剧烈的程序(每帧重建大对象图、反复清空填充集合),大范围老年代被持续写脏,脏卡比例逼近全扫,卡片机制退化成"多绕一层的全量扫描",还要额外付写屏障的钱。
这让写屏障密集型代码有了一个隐藏成本维度:它不只拖慢每次赋值,还会把后续每次 Gen 0 回收的扫描量一起抬上去。两笔账要合起来看,单看任何一笔都会低估这类代码的 GC 代价。
6.3 清卡与收尾的顺序约束
补扫与清卡的配对关系在第五节已经点出,这里补上它在整个标记周期里的位置:卡片表在 STW 根扫描之后、后台标记期间持续积累脏卡,收尾 STW 里一次性扫描并清空。顺序是"先标记完对象图 → 再补扫脏卡 → 最后清卡",任何一步提前,都会让一部分引用既没被遍历到、也没被卡片记录。
这条顺序约束也解释了为什么后台 GC 必须有收尾 STW:并发期间世界一直在变,光靠"某一时刻的快照"无法收口,卡片表把变化攒下来,收尾那一次短暂暂停把这些变化一次性消化掉。卡片表在这里的作用,用一句话概括就是把无限次的并发修改,压缩成一次可完成的补扫。
七、后台标记与用户线程的协作
7.1 后台标记线程的生命周期
背景标记(Background Mark)是 .NET Core GC 并发 GC 模式下的核心技术。在背景标记期间,应用线程不需要暂停,GC 线程在后台与应用线程并发执行。
// 背景 GC 线程入口
DWORD WINAPI BackgroundGCThread(LPVOID param)
{
gc_heap* heap = (gc_heap*)param;
while (true)
{
WaitForGCSignal(); // 等待 GC 触发信号
if (heap->ShouldStartBackgroundGC())
{
heap->BackgroundMarkPhase();
heap->BackgroundSweepPhase();
}
}
}
这段入口代码说明了三件事。后台 GC 是一条常驻线程 ,不是每次 GC 临时创建的:它平时阻塞在等待上,分配压力触发 GC 条件后被唤醒,跑完整轮再回去睡,线程的创建与销毁成本因此被摊平。Server GC 下每堆一条这样的线程 ,与"每核一堆"的布局对齐,各堆的后台标记互不阻塞;Workstation GC 默认一条。BackgroundMarkPhase 之后紧跟 BackgroundSweepPhase------清扫接在标记之后(清扫与压缩的内部结构见 07-04,本文只负责标记部分)。
一轮后台标记的完整时间线是这样的:
用户线程 [运行][运行] [运行][运行][运行] [运行]
─────┼──────────────────┼──────────────────────┼─────
后台GC线程 │ STW 初始标记 │ 后台标记(并发推进) │STW收尾│→ 后台清扫
│ 扫根、建起点 │ 三色推进 + 写屏障记账 │补扫脏卡│
└── 暂停 1 ────────┴──────────────────────┴─暂停 2─┘
↑ 应用线程照常分配、照常写引用
新对象出生即标记,改写由卡片表兜底
(阶段划分为通用模型示意,暂停次数与命名以运行时实现为准)
两次 STW 是这套机制里不可省略的部分:第一次拿到根的起点,第二次把并发期间攒下的变化收口。中间那段最长的工作------遍历整个对象图------完全搬到了后台,与用户线程并行,这正是后台 GC 把暂停从"与堆成正比"压到"毫秒级固定开销"的全部秘密。
7.2 分配过快时的前台插队
void GCHeap::TrySwitchToForegroundGC()
{
// 如果应用线程分配过快,背景标记跟不上
if (alloc_since_background_gc > background_gc_budget)
{
// 暂停所有应用线程
SuspendManagedThreads();
// 执行短暂的前台 GC(只扫描根和卡片)
ForegroundGCSweep();
// 恢复应用线程
ResumeManagedThreads();
}
}
这段切换逻辑是后台标记的安全阀。alloc_since_background_gc 统计后台标记启动以来的分配量,background_gc_budget 是允许它带病前进的预算:分配涨过预算,说明后台标记的推进速度已经追不上堆膨胀的速度,再拖下去堆会先撑爆,于是主动踩刹车------挂起所有托管线程,在前台把这轮工作做完。
"前台只扫根和卡片"这句注释背后有账可算:绝大部分对象图已经在后台标记里被染黑,前台不需要重走一遍,只需要处理两类增量------应用线程又跑出来的新的栈根,以及并发期间被写脏的卡片。所以插队的暂停远短于一次完整的前台全量 GC,这是后台标记"已完成部分"沉淀下来的资产。它同时是那笔代价的体现:这一轮既付了后台的写屏障与并发开销,又额外挨了一次暂停,属于分配失控时的止损,不是常态路径。
从用户线程的视角看,它在后台标记期间的配合动作只有三项:正常分配(新对象出生即标记,见 4.2)、正常写引用(写屏障记账,见 5.2)、以及在安全点响应 GC 的挂起请求。前两项是隐性成本,第三项是 STW 能发生的前提。协作式挂起意味着托管线程不会被强杀在任意指令上,而是走到安全点交出控制权,恢复时从同一点继续------应用代码对这一切毫无感知,只在 Profiler 里看到 GC 阶段名。
7.3 后台、前台、并行的分工
三种标记形态经常被混为一谈,它们回答的问题不同:
| 形态 | 解决什么 | 线程关系 | 代价 |
|---|---|---|---|
| 并行标记 | 缩短一次 STW 内的标记时长 | 多个 GC 线程同时扫 | 同步开销、抢核 |
| 后台标记 | 把标记移出 STW | GC 线程与用户线程并发 | 写屏障、卡片补扫、总 CPU 上升 |
| 前台插队 | 后台追不上时止损 | 短暂停顿中收口 | 一次额外暂停 |
三者可以叠加:后台标记的收尾 STW 内部照样是并行的,Server GC 的每个堆线程同时干活。理解它们的钥匙是分清"谁在等谁"------并行让 GC 线程之间不互相等,后台让 GC 不等用户、用户也不等 GC,前台插队则是用户必须等 GC 的那一个瞬间。后台 GC 的整体编排、与 Gen 0/Gen 1 短回收的配合见 07-05,本文只交代标记阶段内部的协作方式。
八、对象图遍历与标记队列优化
8.1 遍历顺序就是缓存局部性
标记的微观成本集中在"逐字段看一眼"这个动作上:对每个对象读取元数据判断类型、区分引用字段与值类型字段、对每个引用判断是否已标记,未标记则压栈。单次判断的成本接近常数,总成本于是等于"字段总数 × 判断成本"------这解释了为什么大数组是标记吞吐的头号负担:一个百万元素的引用数组,元素类型相同、判断逻辑最简,但基数巨大,一轮标记里往往一个数组就占掉可观的份额。
遍历顺序影响的是另一半成本:缓存。深度优先(标记栈的 LIFO 天然给出)让扫描器沿着一条引用链连续前进,链条上的对象大概率在同一片缓存行区域里,预取有效;广度优先则在对象图的同一层横向跳转,跳跃距离不可控,缓存失效增多。所以标记栈选栈不选队列不是随手为之,它直接换来了更少的 cache miss。
8.2 三级工作结构的回退链
单个标记栈装不下"边扫边涌"的工作,回退链是这么搭起来的:
| 层级 | 结构 | 特点 | 何时启用 |
|---|---|---|---|
| 第一级 | 私有标记栈 | 有上界、零同步、LIFO 局部性最好 | 默认路径 |
| 第二级 | 可增长标记列表 | 容量弹性,容纳栈装不下的部分 | 单对象引用数暴涨 |
| 第三级 | 按块批量挂载 | 整块内存直接挂成工作区,退化为批处理 | 列表仍不够的极端情况 |
(结构名称与分层依据 src/coreclr/gc/ 中 mark_stack / mark_list 一族符号归纳,属简化示意,具体边界条件以源码为准。)
为什么要分三级而不是直接给一个"无限大的栈"。原因有二。内存有上限:一个巨型数组引用的元素数没有理论上限,给栈分配能容纳最坏情况的容量,等于让最坏情况常驻消耗内存;而三级结构让快路径保持小而快,只有真的溢出才动用弹性空间。批处理更省:落到第三级的工作按整块处理,每块内部连续访问,虽然牺牲了深度优先的顺序,却换来了批量访问的局部性------在"栈溢出"这种本来就要失控的场景里,吞吐比顺序更重要。
回退链还承担了一层语义:它保证工作永不丢失。无论对象图长成什么形状,压不进栈的引用总有去处;若扩容失败直接丢弃工作项,丢掉的每一个引用背后都是一棵可能被误杀的子树。所以溢出处理的正确性优先级高于性能,宁可退化成慢速批处理,也不能丢工作。
8.3 扫描一个对象的完整流程
把视角压到单个对象上,一轮扫描的动作序列是:从栈弹出对象 → 读对象头确认类型与尺寸 → 按方法表逐字段下钻------引用字段逐个检查标记位,未标记则置位并压栈,值类型字段继续向内展开其引用成员,纯值类型(如 int、double)直接跳过 → 全部字段处理完毕,对象保持黑色留在栈外。
数组走的是特化路径:元素类型在元数据里已知,不需要逐元素查类型,循环可以批量推进,这也是编译器与运行时为标记专门开的快车道。LOH 上的大对象数组依旧受"基数巨大"的约束,优化方向只能是减少这类对象的存活数量,遍历技巧帮不上忙。
8.4 跨堆引用的归属
Server GC 下每个 GC 线程负责自己的堆,但引用不按堆划分------线程 A 分配的对象完全可能被线程 B 的对象引用着。标记时遇到"不属于自己堆"的对象,源码里有专门的归属处理路径:按对象头信息定位它所属的堆,再把工作交给对应回收堆的结构处理(通用模型描述,属合理推断,细节从略)。
值得强调的是这条路径的低频性。分配上下文按线程隔离,线程倾向于在自己的堆里分配、优先引用自己刚分配的对象,跨堆边是少数派;如果一个应用的跨堆引用比例异常高(例如一个线程疯狂写另一个线程分配的数据),Server GC 的标记效率会被这条路径拖累,这属于"Server GC 不一定更快"的一类场景,评估时要看 Profiler 里 GC 线程的耗时分布而不是只看核数。
九、标记阶段的量级与测量口径
| 标记模式 | STW 时间 | CPU 开销 | 适用场景 |
|---|---|---|---|
| 并行标记(Server GC) | 5-20ms | 高(多核满载) | 大堆、多核服务器 |
| 背景标记(Concurrent GC) | <1ms | 中(后台线程) | 交互式应用、延迟敏感 |
| 前台标记(Workstation) | 1-5ms | 低 | 桌面应用、小堆 |
口径说明:表中数字为典型估算与示例量级,不是实测常数。 实际 STW 取决于堆规模、存活对象数量、根的规模(线程数与栈深)与硬件;同一台机器上换一个负载,量级可以整体移动。这张表的正确用法是横向比较三种模式的相对形态------并行模式 STW 最长但吞吐最好,后台模式把 STW 压到亚毫秒但总 CPU 最高,前台模式开销最低但时长随堆线性增长------而不是把数字抄进性能报告当作本项目的实测值。
三种形态的差异本质上是把同一批工作放在不同地方付费。并行标记把钱花在"一次暂停里多核一起干",后台标记把钱花在"整个后台窗口的写屏障与补扫",前台标记把钱花在"一个线程从头干到尾的时间"。选型时问的问题不是"哪个快",而是"我的瓶颈是暂停还是吞吐"。
测量侧的三个抓手:GC 暂停事件看 STW 分布与最长暂停(对应上表第一列);GC 标记线程的 CPU 占用看后台与并行的真实开销(对应第二列);堆在一轮 GC 前后的已用字节变化看回收效果。ETW/LTTng 的 GC 事件、工具链与自省 API 的完整清单见 03-05 第九节,本文不复制。
十、常见误区与反模式
误区一:把"标记完成"当成"内存已经释放"。
错误做法是看到 Profiler 里标记阶段结束、或者 GC 事件回来,立刻去查进程工作集有没有降,据此判断"这次 GC 没效果"。为什么错:标记只决定生死,不产生任何释放动作------白色对象的内存是在清扫阶段被挂进空闲列表的,SOH 上的碎片归拢还要等压缩(两者都是 07-04 的内容)。标记结束后到清扫完成前,堆的已用字节数不会有任何变化。正确做法是把观察窗口对齐到完整的一轮 GC:看 GC 事件结束后的已用内存,而不是标记阶段的中间状态;只做标记不回收的中间态在后台 GC 里尤其常见,看到"标记完了内存没动"是正常现象而非故障。
误区二:认为后台标记期间引用写入是免费的。
错误做法是开启后台 GC 后就不再关心引用写入的频率,理由是"反正不暂停了"。为什么错:后台标记把暂停成本换成了全程成本------每一次引用赋值都要过写屏障,标记窗口内还要额外记账置卡;窗口越长,账单越大,同时脏卡越多,收尾补扫也越贵。一笔开销换了个位置收,没有消失。正确做法是把分配速率和引用改写频率一起纳入监控:压低临时分配让标记窗口变短,把"重建大对象图"的重活挪出帧循环,写屏障密集的热循环用 Profiler 单独标出来评估。后台 GC 治的是暂停尖峰,治不了分配过多,两者要分开开药。
误区三:把单次 STW 时长当成 GC 的全部开销。
错误做法是只盯着最长暂停一个指标,得出"这个服务 GC 开销很低"的结论。为什么错:后台标记与并行标记的大量 CPU 消耗发生在暂停之外------后台线程跑满一个核的时候,暂停只有零点几毫秒,但那个核的算力已经记在 GC 账上;Server GC 下多个 GC 线程在 STW 内抢核,也会挤占前台线程。只看暂停,等于只看了账单的第一页。正确做法是两条曲线一起看:暂停分布(P99、最长单次)衡量延迟,GC 线程 CPU 占比衡量吞吐代价,两个指标一起达标才算评估完成。
误区四:认为 Server GC 一定优于 Workstation GC。
错误做法是不分负载一律开 Server GC,把它当"性能优化开关"。为什么错:Server GC 每核一个堆、每个堆一套 GC 线程,标记与清扫的并行度确实更高,但 GC 线程本身要吃核------容器里只配了 2 核、却被 GC 开出多条线程时,GC 与业务线程互相抢占,延迟反而恶化;小堆场景下多堆并行的固定开销也未必摊得平。正确做法是把 Server GC 当成"用 CPU 换暂停"的交易来评估:核多、堆大、吞吐敏感才划算;核少、延迟敏感、容器限核的部署保持 Workstation,或显式限制 GC 线程数(GCHeapCount),并用真实负载验证(配置细节见 03-05 第三、八节)。
十一、工程实践与全专栏定位
11.1 标记阶段的排查清单
| 现象 | 优先检查 | 对应本文位置 |
|---|---|---|
| GC 后内存不回落 | 是否只完成标记、清扫未跑;是否有终结器对象滞留 | 2.3、误区一 |
| 后台 GC 期间 CPU 偏高 | 分配速率是否把标记窗口撑长;写屏障热点循环 | 4.2、7.2、误区二 |
| Server GC 暂停反而更差 | 核数与 GC 线程数是否匹配,是否容器限核 | 3.1、误区四 |
| 老年代扫描慢 | 脏卡比例是否过高,引用改写是否过于剧烈 | 6.2 |
| 随机位置崩溃、疑似 GC | 非托管代码是否绕过句柄持有托管指针 | 2.4 |
| 单次 GC 极慢 | 是否存在超大数组撑爆标记栈、退化到溢出批处理 | 3.2、8.2 |
11.2 与其他标记方案的横向对比
| 方案 | 分代 | 标记执行方式 | 跨代引用处理 | 正确性兜底 | 本文/专栏位置 |
|---|---|---|---|---|---|
| Boehm(Unity 增量) | 无 | 时间分片,分帧推进 | 不适用(不分代) | 保守扫描 + 脏页记录 | 03-04 |
| .NET 并行标记 | 3 代 + LOH | 一次 STW 内多线程 | 卡片表 | 精确扫描 + 卡表补扫 | 本文第三节 |
| .NET 后台标记 | 3 代 + LOH | 后台线程与用户并发 | 卡片表 + 收尾 STW | 出生即标记 + 脏卡补扫 | 本文第七节、07-05 |
读表的三个要点:第一,跨代与并发两项机制在 .NET 这边是共用卡片表的,理解一张表就理解了两条链路;第二,Boehm 用"保守"换鲁棒性、.NET 用"精确 + 句柄登记"换干净语义,这决定了两者的 API 习惯差异;第三,三种方案的 STW 形态完全不同------Boehm 增量是多次短暂停顿,.NET 并行是一次中等暂停,.NET 后台是两次亚毫秒暂停加后台窗口,选型时对齐自己的延迟预算即可。
11.3 给 Unity / .NET 开发者的落点
面向本专栏读者,标记阶段的可操作结论收成四条:其一,Unity 的 Boehm 增量与 .NET 的后台标记解决的是同一个问题(把遍历移出暂停),但记账手段不同------前者记脏页、后者记脏卡,理解了本文第五节的卡片模型,03-04 第 4.3 节的脏位机制可以直接对照读;其二,Unity 6 项目迁到 CoreCLR 后端,评估对象换成本文这套机制,GCHeapCount、GCSettings.LatencyMode 等配置项的语义见 03-05 第七、八节;其三,无论哪个运行时,"降分配"都是标记优化的第一杠杆------窗口短了,写屏障账单、脏卡数量、堆膨胀速度会一起下降;其四,怀疑标记出问题(随机崩溃、内存不回落)时,先查句柄与非托管交互,再查分配速率,最后才怀疑收集器本身。
本章总结
.NET Core GC 的标记系统是其高性能的关键。并行标记提供吞吐量,背景标记减少暂停,卡片标记优化跨代引用扫描。三者的配合使 .NET Core GC 能同时满足大堆吞吐和小堆低延迟的需求。
- 根扫描先行且必须静止:栈、寄存器、静态句柄、句柄表四类根在 STW 中一次扫完,耗时随线程与句柄规模走,不随堆规模走;精确式扫描的前提是所有引用要么可推导、要么已登记。
- 三色状态是正确性框架:白、灰、黑的单向转换加上"黑不指向白而不留记录"的不变式,构成并发标记的全部约束;新对象出生即标记,让"边扫边分配"不成为漏洞。
- 标记栈是三色的物理载体:LIFO 给出深度优先与缓存局部性,栈 → 可增长列表 → 按块挂载的三级回退保证极端对象图下工作不丢失。
- 卡片表一表两用:既是分代回收的跨代记忆集,又是并发标记的变脏记录;写屏障在 JIT 层自动记账,收尾 STW 扫卡与清卡配对完成,一次暂停消化全部并发修改。
- 后台标记用窗口换暂停:常驻后台线程与用户线程并发推进,代价是写屏障、脏卡与总 CPU;分配过预算时前台插队止损,已染黑的对象图是插队能保持短暂停的资产。
- 度量要两条腿:STW 分布看延迟,GC 线程 CPU 看吞吐代价,任何单指标结论都不完整;表中数字一律是示例量级,验收以本项目的 Profiler 数据为准。
一句话记忆点:标记只负责判生死,判的方式是"先把根钉死,再顺着三色走,走不动的地方用卡片记账,最后停一次世界把账结清"。 至于生死判完之后内存怎么还给系统、碎片怎么消掉,是下一篇 07-04 的清扫与压缩要回答的问题。