.NET Core GC 总览与架构
篇章 :07-源码深度-.NET Core GC
阅读时间:约 45 分钟
一、引言
.NET Core GC(在 .NET 5+ 中简称为 .NET GC)是当今工业界最先进的垃圾回收器之一。与 Mono 的 SGen 和 Unity 的增量式 GC 不同,.NET Core GC 经历了近 20 年的持续演进,从 .NET Framework 1.0 时代的简单 Workstation GC 发展到了如今支持 Server GC、Background GC、并发标记、LOH 压缩、硬件加速等高级特性的工业级 GC。
理解 .NET Core GC 的架构不仅有助于 C# 后端开发者优化服务器应用的性能,也能帮助 Unity 开发者理解"GC 的天花板在哪里"------因为 Unity 正在逐步将 .NET Core GC 的优秀设计理念引入游戏运行时。
.NET Core GC 的源码位于 dotnet/runtime 仓库的 src/coreclr/gc/ 目录下,核心文件是 gc.cpp(超过 40,000 行)、gc.h 和 gcimpl.h。本章将带您纵览 .NET Core GC 的整体架构。
在展开之前先把本章边界说清楚。架构篇的 03-05 已经从"使用视角"把这套 GC 讲过一遍,本章换一个视角:从源码文件与核心数据结构出发,回答"这套 GC 在内存里长什么样、由谁管理"。分配怎么发生归 07-02,标记与扫描归 07-03,清扫与压缩归 07-04,后台 GC 的实现归 07-05,极致优化技术归 07-06,本章只负责发地图和立坐标,不越界展开后面四篇的细节。读完本篇你应该拿到三样东西:一张源码地图(知道每个问题该翻哪个文件)、一幅堆结构图(段、代、三个子堆怎么落位)、一份能力清单(Server / Workstation、前台 / 后台 GC 各自解决什么问题)。这三样东西是后面五篇的公共前置,后面的章节默认你已经装好了它们。
二、源码地图:GCHeap 与核心文件
2.1 总入口:GCHeap 与它的工厂
读一套几十万行量级的 C++ 代码,第一件事是找总入口,否则任何函数都是一座孤岛。.NET Core GC 的总入口是 GCHeap 这个类,它由工厂方法 GCHeap::GetGCHeap() 返回,进程内只此一份。执行引擎(EE,Execution Engine,也就是托管代码真正跑起来的那一层)不直接触碰 GC 的内部数据结构,所有操作都经由这个对象发起:分配请求从它进,GC.Collect() 从它进,内存压力通知与显式触发也从它进。
把这条边界画出来,源码的分层就清楚了:
托管代码 new Foo()
│
▼
JIT 生成的分配调用 ──► GCHeap 接口层(gcimpl.h / gc.cpp 中的 GCHeap 类)
│ ▲
▼ │ 回调:挂起线程、句柄扫描、根枚举
gc_heap 实现层 │(gcee.cpp 与 EE 交互)
(分配、标记、清扫、压缩) │
│
▼
heap_segment(虚拟内存段)──► OS(VirtualAlloc / mmap 系列)
(结构示意,用于说明分层,具体字段与函数签名以实际仓库为准。)
这条边界的实际价值在于两端都被约束住了:EE 侧只认识接口,GC 内部怎么换算法、怎么加并发,都不影响托管代码;反过来,GC 想暂停应用线程、想枚举句柄时,也只能通过约定好的回调去请求 EE 配合,不能绕过安全点直接伸手。整套 GC 的并发能力之所以成立,很大程度上取决于这层协作协议设计得够干净------后台 GC 能一边跑应用一边标记,靠的就是这层"谁在什么时候可以停谁"的契约。
2.2 文件职责表
同一份 src/coreclr/gc/ 目录下,文件分工相当清晰:
| 文件 | 职责 | 阅读优先级 |
|---|---|---|
gc.h |
gc_heap 类声明、核心结构与阶段定义 |
最高,先建立名词表 |
gcimpl.h |
GCHeap 类与对外接口的实现声明、内部辅助结构 |
高,看清接口边界 |
gc.cpp |
GC 主体实现:分配、标记、清扫、压缩的主流程 | 中,按函数名检索读 |
gcee.cpp |
GC 与执行引擎的交互:挂起线程、根枚举、异常与回调 | 中高,理解并发协作必读 |
gcinf.cpp |
GC 信息(指针表、安全点)相关支持 | 低,做工具时才需要 |
gchandletable.cpp |
句柄表(GC Handle Table)管理 | 中,排查句柄泄漏时读 |
gcconfig.h |
GC 配置参数的解析与存取 | 高,调优键都在这里 |
gcrecord.h / gceventprovider.cpp |
GC 历史记录、ETW/LTTng 事件 | 中,做诊断时读 |
(表中"职责"是按文件命名与公开资料归纳的定位描述,不含行号断言;各文件行数随版本演进变化,以实际仓库为准。)
读这张表要抓住一个事实:gc.cpp 是唯一意义上的"主文件",分配、标记、清扫、压缩四条主流程全在里面,所以它天然巨大。大文件的读法不是通读,而是先在 gc.h 里拿到名词表------哪些结构代表堆、哪些代表段、哪些代表一代------再带着名词去 gc.cpp 检索入口函数。反过来,凡是涉及"GC 和应用线程谁先动"的问题,答案基本不在 gc.cpp,而在 gcee.cpp 与 EE 的交界处。这个位置感比记住任何函数名都有用:它决定了你排查问题时该往哪个文件下钻。
2.3 建议的阅读顺序
本专栏给的路线是 gc.h(接口与结构)→ gcimpl.h(对外边界)→ gc.cpp(核心逻辑),中间按需穿插 gcee.cpp。实际操作时还有两个更省力的入口。一是从现象反查:想知道分配有多快,就从分配入口函数入手;想知道暂停为什么长,就从 GC 的阶段推进逻辑入手;想知道某个开关到底改了什么,就去 gcconfig.h 找它的定义。二是从配置反查:gcconfig.h 里出现的每一个配置键,背后都对应一条真实存在的策略分支,把配置键读一遍,等于把 GC 的可调面过了一遍。
本章后续各节就是按这条路线展开的:先讲段(第三节),再讲三个子堆(第四节),然后是代(第五节)、两种模式(第六节)、核心数据结构(第七节)、并发能力总览(第八节),最后落到误区与实践(第九、十节)。
三、段管理(Segment Management)
3.1 什么是段
.NET Core GC 将托管堆划分为多个段(Segment)。每个段是一块连续的虚拟内存区域,大小通常为 4MB(Workstation GC)或 64MB ~ 4GB(Server GC,取决于 CPU 核数)。段是 GC 管理内存分配和回收的基本单元。
(上述尺寸为典型估算/示意量级,实际由模式、核数与配置共同决定,不同版本的默认值有过调整,不要当常数引用。)
Segment 0 (Gen0/Gen1)
┌────────────────────────────────────────────┐
│ 存活对象 │ 存活对象 │ 空闲空间 │ │
└────────────────────────────────────────────┘
Segment 1 (Gen2)
┌────────────────────────────────────────────┐
│ 存活对象 │ 存活对象 │ 碎片 │ 空闲 │
└────────────────────────────────────────────┘
Segment 2 (LOH)
┌────────────────────────────────────────────┐
│ 大对象 │ 大对象 │ 空闲 │ │
└────────────────────────────────────────────┘
Segment 3 (POH)
┌────────────────────────────────────────────┐
│ 固定对象 │ 固定对象 │ │
└────────────────────────────────────────────┘
段存在的第一个理由是向操作系统要内存的成本:一次系统调用的开销远大于一次托管分配,而托管分配在热点服务里可以到每秒百万次量级(示例量级,取决于分配速率),所以 GC 一次申请一整段虚拟地址空间,之后所有分配都在段内完成。第二个理由是地址连续性:段内地址连续,bump pointer 分配才有落点,压缩回收才有地方把对象挪到一起。段既是 GC 与 OS 的结算单元,也是分配与回收的物理舞台------后面"堆怎么长大"的问题,最后都要落到"什么时候要一个新的段"。
3.2 heap_segment:段的源码形态
段在源码里由 heap_segment 结构描述,它记录的全部是"这条地址区间推进到哪了":
// gc.h 中的关键结构体(简化)
struct heap_segment {
uint8_t* allocated; // 已分配内存的结束位置
uint8_t* committed; // 已提交内存的结束位置
uint8_t* reserved; // 预留内存的结束位置
heap_segment* next; // 下一个段
int flags; // 段标志(Gen0/Gen1/Gen2/LOH/POH)
size_t gc_pinned; // 被固定的对象数
};
(简化版示意,省略了大量字段;真实结构里还有所属堆、上一个段、重用链等指针,具体字段以版本为准。)
逐段解读这个结构。reserved、committed、allocated 三个指针是段的三条边界线,分别对应虚拟内存的三种状态:预留(地址占住,尚未可用)、已提交(物理页到位,可以写入)、已分配(对象已占用);分配永远走在 allocated 之前,提交永远走在 committed 之前,"预留不等于提交、提交不等于占用"在源码层就是这两条推进线。next 把散落的段串成链表,"堆是段的集合"在指针层面就是这一根指针。flags 回答这段内存归哪个子堆管------小对象的第几代、大对象、还是固定对象;gc_pinned 记录段上被钉住的对象数量,直接决定压缩阶段能不能搬动它。
合起来看,段的语义可以压成一句话:一段地址加三条推进线,再加一个归属标记。GC 任意时刻只要拿到段链表,就能完整还原堆的物理布局;反过来,任何"堆为什么涨了"的问题,最后都能查到是哪条推进线在往前走。
3.3 段的生命周期
段状态机:
VirtualAlloc → Reserved (预留虚拟地址空间)
↓
Commit (提交物理内存,首次分配时触发)
↓
Decommit (GC 回收后释放物理内存)
↓
Release (整个段释放归还给 OS)
这条状态机里藏着 .NET GC 一个关键的设计取向:预留与提交分离。预留只占虚拟地址空间,代价极低;提交才真正消耗物理内存。所以 GC 可以放心预留一大片地址,等对象真要写进去时再按需提交,分配慢路径几乎不需要和 OS 同步。代价是虚拟地址空间的占用,这在 64 位进程里基本可以忽略(地址空间以 TB 计,工作集才是真金白银),在 32 位进程里才是稀缺资源------这也是历史上 32 位部署对段大小格外抠门的原因。最后两级 Decommit 与 Release 要分开看:前者只归还物理页、虚拟地址还留着,是常规动作;后者连地址一起还给 OS,只在内存压力大时发生。两者成本差着量级,这个频度差正是下一段段缓存策略的由来。
3.4 段重用与段缓存
当一段的对象被全部回收后,GC 不会立即释放整个段(避免频繁 VirtualAlloc/Release 的系统调用开销),而是先 Decommit 物理页面,保留虚拟地址空间供后续分配重用。仅在内存压力较大时才会 Release 整个段。
这种策略称为段缓存(Segment Caching),是 .NET Core GC 减少 OS 交互开销的重要优化。
段缓存的本质是把"释放"和"复用"解耦。托管应用的分配曲线通常是脉冲式的:加载期猛涨,运行期回落,切场景时再来一波。如果每次回收都把段还掉,下一波猛涨又要原样申请回来,等于把 OS 当免费中转站来回折腾;留一个段缓存,下次分配直接从缓存里取,几乎零成本。配置键 System.GC.RetainVM(03-05 第八节列过)控制的正是这条策略:默认保留段以换分配性能,需要压低虚拟地址占用或做资源隔离时才关掉它。
段缓存还解释了一个线上常见现象:进程的虚拟地址占用(VSZ)看起来很大,工作集(RSS)却很小------前者是预留与缓存的段,后者才是真正提交的页。看内存指标时这两个口径必须分开,否则很容易得出"GC 在泄漏"的错误结论:判断泄漏要看提交量与托管堆大小的长期趋势,而不是看地址空间占了多少。容器监控里这类误判尤其常见,因为内存限额通常按工作集计费,VSZ 涨得再快也不代表要被杀。
3.5 短暂段与代的落位
段是物理单元,代是逻辑单元,两者不是一一对应:同一代的对象可能分布在多个段里,同一个段也可能先后承载过不同代。真正把两者绑在一起的是短暂段(ephemeral segment)------Gen0 与 Gen1 共享正在使用的短暂段,新对象一律往这里 bump 分配;Gen2 对象住在自己的段里;LOH 与 POH 各有独立的段,完全不参与 SOH 的代际晋升。
这样切分的收益在于回收范围可控。绝大多数对象活不过一次 Gen0 回收,GC 只需要盯着那一小段短暂段,不必为了"老对象可能引用了新对象"去遍历整个老年代------后者的扫描量由卡表和写屏障兜住(机制细节见 07-03 与 07-05)。换句话说,段的划分让"小回收只碰小区域"在物理上成立,分代假说才有了落地的容器;没有这个容器,代就只是账本上的标签,省不下任何实际扫描量。
还要留一个口子:不同版本的运行时一直在调整堆的物理布局,例如把按代划分的段进一步做成 region 粒度的管理单元。这类调整属于持续演进中的实现细节,不构成对外契约。本章统一按"段 + 代 + 短暂段"的主线讲解,你在源码里看到不一样的命名不必意外,抓住"分配单元、归属标记、推进指针"三个要素就能对上号。
3.6 扩堆与回收的权衡
段不是越多越好,也不是越少越好。段多了,元数据、系统调用、地址碎片一起涨;段少了,单个段变大,判断失误就是整段归还或整段提交带来的抖动。GC 的做法是维持"正在用的段 + 少量备用段"的结构:分配优先吃当前段,吃不下才向运行时要新段;回收后优先把段降级成备用而不是直接销毁;只有内存压力明确时才真正归还。
这套策略的代价也说得清楚:备用段本身就是已提交或已预留的内存,等于为下一波分配高峰预付的定金。定金收得回来(下次分配零成本)就是赚,收不回来(高峰不再来)就是白占内存。整条链路的触发时机与失败处理属于分配路径,本章只点到这里,完整推演放在 07-02 第八节"段耗尽与扩堆"。
四、堆结构:SOH / LOH / POH
4.1 三种堆的划分
.NET Core GC 将托管堆分为三个独立的区域:
| 堆 | 全称 | 对象大小阈值 | 压缩策略 | 主要用途 |
|---|---|---|---|---|
| SOH | Small Object Heap | < 85,000 字节 | 分代压缩 | 大多数日常对象 |
| LOH | Large Object Heap | ≥ 85,000 字节 | 可选压缩(4.5.1+) | 大数组、大字符串 |
| POH | Pinned Object Heap | 任意大小 | 不压缩 | 需要固定地址的对象 |
85,000 字节这个阈值不是随手挑的整数,它来自对象对齐与段管理的成本权衡(推导见 03-05 第四节),在源码里以常量形式存在。三个堆共用同一套段机制,但各有各的段、各有各的回收规则------"分堆"的意义就在于让性质不同的对象走不同的策略,而不是被同一套策略拖累。你可以把它理解成物流分拣:小包裹走高频流水线,大件走专车,必须原地存放的走恒温仓,混在一起只会互相拖慢。
4.2 SOH(小对象堆)
SOH 占所有托管分配的绝大多数(>99%,典型估算,视应用类型而定)。SOH 进一步分为三代:
- Gen 0:最新分配的对象。大小通常为 16-36MB(取决于配置和模式)
- Gen 1:从 Gen 0 晋升的对象。充当"缓冲区",减少 Gen 2 的 GC 频率
- Gen 2:最老的对象。包含长时间存活的对象和从 Gen 1 晋升的对象
(Gen0 大小为典型估算量级,实际随 Workstation/Server 模式、核数与配置变化,非固定常数。)
SOH 内部结构:
┌────────────────────────────────────────────────────┐
│ Gen 0 (36MB) │
│ ┌──────────┬──────────┬─────────────────────────┐ │
│ │ 分配指针 →│ 已分配 │ 空闲 │ │
│ └──────────┴──────────┴─────────────────────────┘ │
│ Gen 1 (缓冲区) │
│ ┌────────────────────────────────────────────┐ │
│ │ 从 Gen0 晋升的短期存活对象 │ │
│ └────────────────────────────────────────────┘ │
│ Gen 2 (大区域) │
│ ┌────────────────────────────────────────────┐ │
│ │ 长期存活对象 │ │
│ └────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────┘
三代的分工可以理解成三级筛选。Gen0 是"入职第一周":进来的人最多,淘汰得也最快,回收便宜到可以频繁做。Gen1 是"试用期":接住从 Gen0 活下来但还没证明自己长寿的对象,给它们一个缓冲,避免它们直接挤进 Gen2 把老年代搅乱。Gen2 是"正式员工":真正长寿的对象住在这里,回收昂贵但频率低。每晋升一级,对象被再次判死的概率下降一档,GC 处理这一档的成本上升一档------代际设计的全部精妙,就是让便宜的筛选发生得足够频繁,让昂贵的深挖发生得足够稀疏。
SOH 还有一条隐含属性:每次被回收都会压缩,幸存对象被挪到一起,所以 SOH 内部基本不产生外部碎片。代价是对象地址会变,任何长期持有裸托管地址的代码都必须依赖句柄或固定机制。这条规则贯穿 .NET 的整个互操作设计,也是 POH 存在的根本动机------与其让 pin 的对象散落在 SOH 里拖累压缩,不如把它们整体请出去。
4.3 LOH(大对象堆)
LOH 存储 ≥ 85,000 字节的对象。它的关键特性:
-
默认不压缩:移动大对象的代价太高(CPU 复制开销大)
-
碎片化问题:频繁分配和释放大对象会导致 LOH 碎片
-
总是 Gen 2 回收:LOH 的对象只有在 Gen 2 GC 时才会被回收
// LOH 分配示例
byte[] largeArray = new byte[85000]; // 85KB,分配在 LOH
byte[] notLarge = new byte[84999]; // < 85KB,分配在 SOH
这两行展示的是阈值判定发生在分配瞬间,而且按对象总大小算:总字节数(含对象头与对齐)越过线就进 LOH,从此它的一生与 Gen2 绑定。业务代码不必自己掐算,但排查"为什么这个数组的分配把 Gen2 回收提前了"时,这条判定就是答案的起点。还要注意数组长度越线与字节数越线并不完全等价,对象头和对齐会让有效长度略小于按字节反推的值,临界附近的代码以实测分配行为为准,不要在源码之外另立一套经验值。
.NET 4.5.1+ 引入了 GCSettings.LargeObjectHeapCompactionMode,允许开发者显式触发 LOH 压缩:
// 手动触发 LOH 压缩
GCSettings.LargeObjectHeapCompactionMode =
GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect(2, GCCollectionMode.Forced);
这段代码做了两件事:把 LOH 压缩模式设为"只压这一次",再强制一次 Gen2 回收去执行它。CompactOnce 的语义是下一次 Gen2 回收完成后自动复位,不留长期副作用------这是刻意设计,因为 LOH 压缩要搬动大对象,任何缓存了这些对象原生指针的代码都会立刻拿到悬垂指针,所以它必须是显式、一次性、由调用者承担责任的行为。使用时机的判断标准很朴素:进程内此刻没有外借的 LOH 指针(没有 P/Invoke 正持有、没有第三方库握着)才允许压;长期存在的固定缓冲区应该改用 POH,而不是反复压 LOH。压缩本身怎么执行属于清扫与压缩环节,见 07-04。
4.4 POH(固定对象堆)
POH 是 .NET 5 引入的新堆类型,专门存放需要固定地址的对象(主要供 P/Invoke 和 Marshal 使用)。在 POH 出现之前,固定对象会分散在 SOH 中,导致 GC 压缩效率降低。POH 的核心特征:
-
不压缩:对象一旦分配到 POH,地址永久固定
-
不晋升:POH 内的对象不分代
-
仅在 Full GC 时回收
// 在 POH 上分配固定对象
var pinnedBuffer = GC.AllocateArray(1024, pinned: true);
// 等价于在 POH 上分配,不会被 GC 移动
这行代码解决的是 pin 的老问题。传统做法要把托管数组交给原生代码,必须用 GCHandle 把它钉住,否则下一次压缩回收就把它搬走;被钉住的对象还会拖慢所在堆的压缩,因为算法必须绕开它;句柄本身又是需要手动释放的资源,忘了释放就是一类隐蔽泄漏。POH 把这类对象单独隔离:它们本来就不参与压缩,自然不需要钉,SOH 的压缩也不再被它们干扰。代价同样明确------POH 的对象只能整块等 Gen2 回收,没有"部分回收"这回事,所以它适合生命周期本身就长的缓冲区;把大量短命对象往 POH 上塞,等于把它们全变成要等 Gen2 才能释放的垃圾。POH 不是万能的分配池,它是"地址必须稳定"这个需求的专用解,实战细节见 03-05 第十节。
4.5 三堆分工的本质
把三个堆放一起看,切分依据其实只有两条轴:对象多大 、地址要不要动。小且可动的进 SOH,走三代压缩,拿到最高的回收效率;大且可动的进 LOH,不自动压缩,用碎片换复制成本;要地址稳定的进 POH,不分代不压缩,用回收粒度换互操作安全。三条策略各自让渡了不同的代价,而不是某一条通吃------这正是工业级 GC 的典型形态:不追求单一最优解,而是给不同工作负载各自的出口。往后遇到"为什么我的对象被这样处理"的疑问,回到这两条轴上基本都能自答。
五、分代结构与触发
5.1 代际参数
.NET Core GC 的三代各有不同的触发阈值和大小策略:
| 参数 | Gen 0 | Gen 1 | Gen 2 |
|---|---|---|---|
| 初始大小 (Workstation) | 16 MB | 256 KB | 256 KB |
| 初始大小 (Server, 4核) | 64 MB | 4 MB | 4 MB |
| 触发条件 | Gen 0 满 | Gen 0 GC 后存活进入 Gen 1 | Gen 1 GC 后存活进入 Gen 2 |
| GC 频率 | 最高 | 中等 | 最低 |
| 单次 GC 暂停 | <1ms | 1-5ms | 5-100ms+ |
(表中尺寸与耗时均为典型估算/示例量级,非实测常数:初始尺寸随版本、模式与核数变化,暂停时长取决于堆规模、存活率与硬件。)
这张表最该被记住的不是数字,而是比例关系:Gen0 的预算比 Gen1/Gen2 大一到两个数量级(示例量级),单次暂停却短一到两个数量级。预算大、单次便宜,Gen0 回收就能频繁发生而不被感知;Gen2 反过来,单次昂贵所以必须稀疏。分代 GC 的日常表现几乎就是这两列数字的乘积------你感知到的卡顿频率来自第一列,单次卡顿时长来自最后一列,优化时先分清自己要压哪一列。
5.2 分代协调触发
// gc.cpp 中的分配路径(简化伪代码)
void* GCHeap::Alloc(size_t size)
{
// 1. 尝试在当前 Gen 0 分配上下文中快速分配(bump pointer)
alloc_context* ctx = GetCurrentAllocContext();
void* ptr = ctx->alloc_ptr;
void* new_ptr = (uint8_t*)ptr + size;
if (new_ptr <= ctx->alloc_limit)
{
// 快速路径:bump pointer 分配成功
ctx->alloc_ptr = new_ptr;
return ptr;
}
// 2. 慢速路径:需要 GC
return AllocationAttemptSlowPath(size, ctx);
}
void* AllocationAttemptSlowPath(size_t size, alloc_context* ctx)
{
if (size >= LARGE_OBJECT_SIZE)
{
// 大对象 → 分配在 LOH
return AllocLOH(size);
}
// 小对象 → 触发 Gen 0 GC
int generation_to_collect = 0;
// 检查 Gen 0 预算是否耗尽
if (ctx->allocation_count_since_last_gc > gen0_budget)
{
generation_to_collect = 0; // 只回收 Gen 0
}
// 如果 Gen 0 GC 后仍然不够,尝试 Gen 1
// 如果 Gen 1 GC 后仍然不够,尝试 Gen 2
// 这就是"分代递进触发"
GarbageCollect(generation_to_collect);
// 再次尝试分配
return TryAllocNewGen0Segment(size);
}
(简化伪代码/示意:函数名与分支结构用于说明路径,字段命名与真实实现可能有出入,以源码为准。)
逐段看这段代码。前半段是快路径:拿本线程的分配上下文,算 ptr + size,跟上限比一次,装得下就移动指针返回------加法、比较、一次写回,这就是托管分配的全部开销,没有链表遍历,没有按尺寸分桶,也没有锁。后半段是慢路径,按顺序回答三个问题:这是不是大对象(是就转 LOH,与分代预算无关)、Gen0 预算还够不够(不够就触发一次 Gen0 回收)、回收之后还放不下怎么办(注释里的"分代递进",逐级升格到 Gen1、Gen2)。这段代码把分配与触发焊在了一起:GC 不是被定时器叫醒的,是被预算花光叫醒的。预算模型、慢路径完整分支、LOH/POH 细节由 07-02 专门拆解,本章只要求记住这个骨架------快慢分离,触发藏在慢路径里。
5.3 晋升机制
对象在 GC 中晋升的规则:
-
Gen 0 GC 后存活的对象 → 晋升到 Gen 1
-
Gen 1 GC 后存活的对象 → 晋升到 Gen 2
-
Gen 2 GC 后存活的对象 → 留在 Gen 2(Gen 2 是最高代)
// 晋升示例
void PromotionDemo()
{
object obj = new object(); // 分配在 Gen 0GC.Collect(0); // 只回收 Gen 0 // obj 存活,晋升到 Gen 1 GC.Collect(1); // 回收 Gen 0 + Gen 1 // obj 存活,晋升到 Gen 2 GC.Collect(2); // Full GC // obj 存活,留在 Gen 2}
这段示例把"代是递进的"这句抽象话变成了可执行实验:GC.Collect(n) 回收的是第 n 代及以下的全部代,所以调用 0 只看 Gen0,调用 1 连 Gen1 一起看,调用 2 就是全堆。三次调用之后 obj 还活着,它就一路走到了 Gen2。真实运行时的晋升比这更细:并非所有幸存者都立刻晋升,有的走代内晋升(Gen0 的一部分存活区域整体升成 Gen1),晋升策略也不止一条,但"活过一次低代回收就往上走一级"这条主线不变。想验证就在本地跑这段代码,再用 GC.CollectionCount 观察各代计数差值,比背结论牢靠得多。顺带澄清一个常见疑问:手动 GC.Collect 不会破坏自动触发的预算,它只是一次额外的记账点,代价是这次回收可能并不划算------生产代码里手写它,通常说明分配模式本身有问题。
5.4 卡表、写屏障与本章边界
分代还要解决一个不对称问题:Gen0 回收怎么知道老年代里有没有引用指向新生代。答案是卡表加写屏障------引用从老对象写向新对象时,写屏障顺手把对应的卡标脏,Gen0 回收只扫根和脏卡。这套机制在 03-05 第二节讲过原理,到了 07 章要落到源码层:卡表的结构挂在 gc.h 的 gc_heap 里(第七节会看到),脏卡的扫描与标记算法归 07-03"标记与扫描",写屏障在并发标记期间的协作归 07-05"后台 GC"。本章只把它登记在案:分代效率的来源是一张卡表,代价是每次引用写入多出的几条指令。这句话后面五篇会反复用到。
六、Server 与 Workstation 模式
6.1 两种模式的核心差异
| 特性 | Workstation GC | Server GC |
|---|---|---|
| GC 线程数 | 1 个 GC 线程 | 每个 CPU 核 1 个线程 |
| 应用线程挂起 | 非并发 GC 时全挂起 | 全挂起(但 GC 更快) |
| 堆数量 | 1 个堆 | 每个 CPU 核 1 个堆 |
| 内存开销 | 较小 | 较大(每个堆有独立的段和元数据) |
| 性能特征 | GC 暂停频繁但较短 | GC 暂停少但较长 |
| 适用场景 | 桌面应用、UI 应用 | ASP.NET、微服务、游戏服务器 |
(表中"暂停频繁/较长"是相对关系,具体毫秒数随堆规模变化;多核上 Server 的单次暂停通常更短,因为它处理的堆更小且并行推进。)
这张表最容易被误读的是性能特征那一行。Workstation 不是"暂停长",而是"一个线程串行干完全部活";Server 不是"暂停短",而是"每个堆更小、还有并行加成"。真正决定选哪个的是另外两条:核数与内存预算。1 到 2 核的容器开 Server GC 是典型错误配置------堆数太少,并行收益盖不住多套元数据的内存开销;16 核的 Web 服务用 Workstation,则是把多核算力晾在一边。两条都违反,代价分别表现为内存浪费和尾延迟抖动,方向完全相反,所以选型不能凭感觉,得看部署环境的实际核数与内存限额。
6.2 Server GC 的多堆架构
// Server GC 模式下,每个 CPU 核独立管理一个堆
struct gc_heap
{
heap_segment* segment; // 当前段
alloc_context alloc_context; // 本堆的分配上下文
int heap_number; // 堆编号(0 ~ n-1)
size_t gc_clock; // GC 时钟(本堆的 GC 频率)
// 每个堆独立的分代大小和预算
size_t generation_size[3]; // Gen 0/1/2 大小
};
// Server GC 初始化
void InitializeServerGC()
{
int heap_count = GetCPUProcessorCount();
gc_heap* heaps = new gc_heap[heap_count];
for (int i = 0; i < heap_count; i++)
{
heaps[i].heap_number = i;
heaps[i].alloc_context.alloc_ptr = ...;
}
}
(简化伪代码/示意:字段与初始化逻辑用于说明多堆结构,真实初始化路径更复杂,以源码为准。)
逐段解读。segment 是这个堆当前正在吃的段,alloc_context 是本堆的分配窗口,heap_number 是身份编号,generation_size[3] 是它自己那一份代际预算------四个字段合起来说明每个堆都是自给自足的 :有独立的内存、独立的记账、独立的分配指针。初始化函数更直白:heap_count 取自 CPU 逻辑处理器数,然后把这套结构按核数复制若干份。这就是 Server GC 的全部魔法,没有更玄的东西------它不是"一个堆被多线程同时操作",而是"每个核一套堆,各干各的"。
这个结构同时解释了 Server GC 的两个特征。好处是回收时各核的 GC 线程并行处理本堆,堆之间几乎没有同步点,吞吐随核数扩展(实际加速比受串行根扫描、内存带宽与各堆不均影响,通常达不到核数的理论上限,这是量级说明而非实测结论)。坏处是元数据按堆复制一份:卡表、段结构、代际统计全部翻倍,堆多了这部分开销相当可观,这也是 6.1 里"内存开销较大"的具体来源。
6.3 配置方式
// 在 app.config 或 runtimeconfig.json 中配置
// *.runtimeconfig.json
{
"runtimeOptions": {
"configProperties": {
"System.GC.Server": true, // 启用 Server GC
"System.GC.Concurrent": true, // 启用后台 GC(并发标记)
"System.GC.HeapCount": 8, // 限制堆数量(可选)
"System.GC.HeapHardLimit": 0, // 硬限制堆大小
"System.GC.LOHThreshold": 100000 // 自定义 LOH 阈值
}
}
}
这段配置的价值在于它是进程级 的:改完要重启才生效,运行时无法在中途把 Workstation 切成 Server。System.GC.Server 决定堆的套数,System.GC.Concurrent 决定 Gen2 回收走不走后台(默认开启),HeapCount 在容器里尤其重要------容器限了 2 核却让运行时按宿主机 64 核初始化,是实践中最典型的配置事故之一。HeapHardLimit 与 LOHThreshold 属于压力线与阈值的微调,前者给内存封顶,后者改变"多大算大对象"的判定,动它们之前要先确认自己的堆画像,否则容易把对象在 SOH/LOH 之间搬错家。同一组开关还有两种等价写法:项目文件里的 <Server>true</Server>,以及环境变量 DOTNET_GCServer=1,容器部署常用后者,因为它不改镜像。配置改完必须验证,GCSettings.IsServerGC 一行就能确认(03-05 第九节给了完整自省代码),见过太多案例调了半天 Workstation 参数,最后发现进程根本是 Server 起的。
6.4 两种模式怎么选
选型可以压成一张判断表:
| 环境特征 | 推荐模式 | 理由 |
|---|---|---|
| 核多(≥8)、吞吐优先、内存有富余 | Server GC | 多堆并行,单次暂停更短 |
| 1 到 2 核容器 | Workstation GC | 堆太少,元数据开销不划算 |
| 桌面/客户端、内存紧张 | Workstation GC | 一套堆元数据足够 |
| 内存受限但核多(批量计算) | Server + 限制 HeapCount | 兼顾并行与内存上限 |
| 延迟敏感且核少 | Workstation + 后台 GC | 暂停可控、内存可控 |
(表中阈值为经验区间,属典型估算,实际以压测数据为准。)这张表里没写但同样重要的一条:模式选错是可以被观测到的,GCSettings.IsServerGC 打进启动日志,配置有没有生效一目了然。选型问题在工程上从来不是哲学问题,是"先确认现状、再对照环境"的核对问题。
七、关键数据结构:gc_heap
7.1 gc_heap 结构体
gc_heap 是整个 GC 的核心数据结构。以下列出关键字段:
class gc_heap
{
// === 堆配置 ===
int heap_number; // 堆编号
int heap_count; // 总堆数
size_t generation_size[3]; // 各代大小
// === 段管理 ===
heap_segment* segment_standby_list; // 备用段列表
heap_segment* deleted_segment_list; // 待删除段列表
// === 分配上下文 ===
alloc_context alloc_contexts[MAX_ALLOC_CONTEXTS];
// === GC 状态 ===
int gc_structure; // GC 当前阶段
bool concurrent_gc_in_progress; // 后台 GC 是否进行中
uint32_t gc_clock; // GC 计数
// === 标记 ===
uint8_t* mark_stack; // 标记栈
size_t mark_stack_size; // 标记栈大小
// === 写屏障 ===
uint8_t* card_table; // 卡表
size_t card_table_size;
// === 统计数据 ===
size_t allocated_since_last_gc; // 上次 GC 后的分配量
size_t total_allocated; // 总分配量
};
(简化伪代码/示意:字段名与分组用于建立心智模型,真实 gc_heap 的字段数量远不止于此,以 gc.h 为准。)
逐组解读。堆配置组 回答"我是谁、我管多大"------heap_number 与 heap_count 决定它在 Server 模式里的编号和总堆数,generation_size 是它自己那一份代际预算。段管理组 是第三节的落点:segment_standby_list 就是段缓存本体,回收后的段先挂进备用链,下次分配直接取;deleted_segment_list 则是真正要归还 OS 的队列,两条链把"降级"与"销毁"分开了。分配上下文组 是 07-02 的主角,这里只要知道它以数组形式挂在堆上,供多个分配线程各取一个窗口。GC 状态组 里 gc_structure 表示当前处在哪个阶段,concurrent_gc_in_progress 标记后台 GC 是否在跑------这两个字段是判断"暂停为什么这么长"时最想看到的东西。标记组与写屏障组 分别是 07-03 与卡表机制的存放处。统计组 是预算触发的记账来源,allocated_since_last_gc 每次分配往上累加,超预算就叫醒 GC。
把七组字段连起来读,得到的是一个完整闭环:记账(统计组)→ 触发(状态组)→ 执行(标记/写屏障组)→ 落位(段管理组)→ 再分配(上下文组)。这个闭环在后面五篇里会被反复拆开讲,但骨架永远是它。读源码时如果迷路,回到这个闭环问一句"我现在看的代码属于哪一环",位置感立刻就回来了。
7.2 关键常量与参数
// gcimpl.h
const int GENERATION_COUNT = 3; // 三代:Gen 0, 1, 2
const int LOH_GENERATION = 3; // LOH 作为"第 4 代"
const size_t LARGE_OBJECT_SIZE = 85000; // LOH 阈值
const int CARD_SIZE_BYTES = 8; // 卡表每卡包含 8 字节
const int CARD_BUNDLE_SIZE = 64; // 64 卡合并为 1 卡束
(简化伪代码/示意:命名与数值用于说明常量的语义,具体声明位置与取值以实际版本为准,其中 LOH 阈值可被配置键覆盖。)
这段常量里藏着三个设计事实。第一,GENERATION_COUNT = 3 而 LOH_GENERATION = 3------LOH 在源码里被当作"第 4 代"来编号 ,这解释了为什么很多内部逻辑把 SOH 与 LOH 统一按代遍历:对 GC 来说,代只是一个编号,第 3 号恰好是不分代的大对象区。第二,LARGE_OBJECT_SIZE = 85000 是可以被配置覆盖的阈值,所以"85,000 字节"是默认值而非语言规范承诺,跨版本做容量规划时要留出这个变量。第三,卡表按 8 字节一张卡、64 张卡再捆成一束,这两级粒度决定了写屏障标脏的最小单位和标记阶段的扫描粒度------卡太细则元数据贵,卡太粗则误扫多,8/64 是工程上取的折中点,属于设计权衡而非数学最优。
7.3 结构与章节的对应关系
到此为止,本章出现的三个核心结构已经各就各位:heap_segment 是物理层(内存怎么分),gc_heap 是管理层(谁在记账、谁在推进),GCHeap 是接口层(EE 怎么调进来)。三者是包含关系------GCHeap 持有 gc_heap,gc_heap 持有 heap_segment 链表。后续五篇的分工也能用这三层标注:07-02 讲 alloc_context 如何在 gc_heap 里取窗口,07-03/07-04 讲标记与压缩如何沿 heap_segment 链遍历,07-05 讲 concurrent_gc_in_progress 背后的状态机,07-06 讲段这一层的极致优化。记住这张对应表,六篇文章就读成了一篇。
八、后台 GC 与并发能力总览
8.1 为什么需要并发
最朴素的 GC 流程是全程 Stop-The-World:挂起所有托管线程,标记、清扫、压缩一次做完,再恢复。堆越大、存活对象越多,暂停越长。对延迟敏感的负载来说,一次 5 到 50ms 的阻塞式 Gen2 回收(典型范围,视堆规模而定)足以让一批请求的尾延迟明显抖动。并发能力要解决的就是这件事:把最贵的阶段从"应用全停"改成"边跑边做"。本节只给总览,四阶段的完整推演属于 07-05。
8.2 前台与后台的协作时序
时间轴 ─────────────────────────────────────────────────►
应用线程 正常运行 ▥停█ 正常运行(并发标记期间) ▥停█ 正常运行...
│
GC 线程 ├─ 1. 初始标记(STW,极短)
├─ 2. 并发标记(与应用并行,遍历对象图)
├─ 3. 重标记(STW,处理并发期的引用变更)
└─ 4. 并发清扫(与应用并行,归还空闲空间)
期间 Gen0 预算耗尽 → 插入一次前台 Gen0/Gen1 短回收 → 立即把线程还给并发标记
(时序为结构示意,阶段名称与协作关系为通用描述,具体切分以实现为准。)
这张图要读出三个信息。第一,两次 STW(初始标记与重标记)都很短,因为它们的工作量与根集合规模相关,而不是与堆大小相关------堆涨上去之后单次停顿基本不动,这是后台 GC 最值钱的性质。第二,第二、四阶段与应用并行,代价是写屏障必须全程在岗,记录并发期间被改写的引用,留给重标记处理。第三,后台标记跑到一半时 Gen0 预算用完,运行时会插入一次前台短回收,处理完立刻把线程还给并发标记------两条流水线并行不悖,这正是这套设计的精巧之处,也是它比"一刀切停顿"复杂的地方。
8.3 执行方式对比
| 执行方式 | 标记阶段 | 应用状态 | 适用代 | 典型暂停特征 |
|---|---|---|---|---|
| 阻塞式(blocking) | 一次做完 | 全程挂起 | 全代 | 暂停随堆规模增长 |
| 并发前台标记 | 与应用并行 | 短暂挂起后恢复 | Workstation Gen2 | 两次短 STW |
| 后台 GC(Background) | 后台线程推进 | 绝大部分时间在跑 | Gen2 主战场 | STW 切片常在 1ms 以下 |
| Gen0/Gen1 短回收 | 快速阻塞 | 挂起 | 低代 | 亚毫秒到几毫秒 |
(表中时长为量级参考,非实测常数,取决于堆规模、根集合与硬件。)
这张表的读法是按行分工:低代永远走短阻塞,因为它们的存活对象少、区间小,暂停本来就在毫秒级以下,为它们引入并发协调反而不划算;Gen2 才是并发能力的主战场,因为它单次最贵、频率又不至于太低。分配速率过快时并发标记可能来不及跑完,Gen2 空间先耗尽,此时只能退化成一次完整的阻塞式回收,暂停反而比平常更长------监控里看到"本该后台跑的回收变成了 blocking",通常意味着分配速率或堆规模需要介入,这是后台 GC 唯一会"失灵"的场景。
8.4 与本章其余部分的关系
并发能力不是凭空挂在 GC 上的,它对前面几节都提出了额外要求。对段管理,后台标记期间分配仍在继续,段必须边吃边扩,"边跑边扩"的协调逻辑比纯阻塞模式复杂(详见 07-02 第八节)。对分代,后台标记期间的低代回收要与之共存,卡表脏项要被两拨消费者共享(详见 07-03)。对多堆,Server 模式下每个堆各有各的后台线程,调度与同步的复杂度按堆数放大(详见 07-05)。所以第八节既是能力总览,也是把前面七节串起来的线索------理解了它,你就知道为什么 gc_heap 里要留 concurrent_gc_in_progress 这样的字段。
九、常见误区与反模式
误区一:把 85,000 字节当成语言规范。
错误做法:在业务代码里写死 if (bytes >= 85000) ... 来预测对象落在哪个堆,或者拿它做容量分支。
为什么错:这个值来自源码里的默认常量,可被配置覆盖,且阈值判定按对象总字节数(含对象头与对齐)计算,与数组"有效长度"并不一一对应;把它当契约,等于把实现细节当 API。
正确做法:需要按堆分治时,用 GC.GetGCMemoryInfo() 与分配诊断工具观测实际落位;代码里只按业务语义决定"这个缓冲区该不该复用",不按阈值打分支。
误区二:看到 VSZ 涨就判定 GC 泄漏。
错误做法:用容器的虚拟地址占用或 top 里的 VIRT 列当内存告警阈值,一涨就怀疑 GC 没回收。
为什么错:第三节讲过,段缓存会保留虚拟地址空间,预留与缓存本来就是 VSZ 的组成部分;地址占得多不代表提交得多,更不代表托管堆在涨。
正确做法:盯 GC.GetGCMemoryInfo() 的 HeapSizeBytes 与 TotalCommittedBytes 的长期趋势,配合 GC.CollectionCount 的差值判断回收是否还在正常工作;容器限额按工作集计费时,盯 RSS 而不是 VSZ。
误区三:把 Server GC 当成"更快的 GC"无脑开。
错误做法:部署到 2 核容器仍然 System.GC.Server: true,理由是"服务器就该用 Server GC"。
为什么错:Server 的收益来自多堆并行,堆数 = 核数;核数少时并行收益趋近于零,而每堆一套的卡表、段结构、代际统计照收不误,纯亏内存。元数据通常占堆总大小的百分之几(典型估算,视堆规模与碎片而定),堆基数小的时候这部分相对成本更高。
正确做法:按 6.4 的判断表选型,并在启动日志里打印 GCSettings.IsServerGC 确认生效;容器里同时把 HeapCount 与 CPU limit 对齐。
误区四:读源码从 gc.cpp 第一行开始通读。
错误做法:打开 4 万行量级(以仓库实际为准)的主文件从头啃,两周后放弃。
为什么错:这个文件是四条主流程的合集,线性阅读会不断在分配、标记、清扫、压缩之间跳转,且大量代码是状态机的分支处理,脱离上下文毫无意义。
正确做法:按第二节的路线,先在 gc.h 建立名词表,再按函数名检索目标逻辑;配合配置键反查策略分支,从你真正关心的现象往回推。
十、工程实践:怎么用这张地图
读源码不是学术活动,它的产出应该是可执行的判断。下面这份清单按"拿到问题 → 定位层次 → 选对工具"的顺序排:
| 你观察到的现象 | 首要怀疑层 | 去哪找证据 |
|---|---|---|
| 分配速率高、Gen0 GC 频繁 | 分配路径 / 预算 | 07-02;dotnet-counters 的分配速率曲线 |
| Gen2 回收少但单次很长 | 段与老年代规模 | 本文第三、四节;GC.GetGCMemoryInfo() |
| 后台 GC 变成 blocking | 分配速率超过并发标记 | 07-05;GC 事件里的 blocking 标记 |
| LOH 碎片、大分配提前触发 | LOH 布局与压缩策略 | 本文 4.3;FragmentedBytes 指标 |
| 配置似乎没生效 | 模式与配置层 | 本文 6.3;GCSettings.IsServerGC 启动日志 |
| 多核 CPU 被 GC 占满 | Server 模式与堆数 | 本文 6.4;HeapCount 与 CPU limit 对齐情况 |
实践上有三条通用纪律值得单独强调。第一,先确认模式再谈优化 :IsServerGC、LatencyMode 两行启动日志排掉的是一整类误判------调了半天 Workstation 参数,最后发现进程是 Server 起的,是最常见的白工。第二,指标要带口径 :堆大小看提交量与趋势,暂停看分位数而非平均值,分配看每帧或每秒速率,任何单点截图都不构成结论。第三,跨篇交叉验证:本文给出的是结构(堆长什么样),分配异常去 07-02,暂停异常去 07-03/07-05,碎片异常去 07-04,只在本文里找答案会找错地方。
十一、与相关 GC 及本专栏位置
先看横向对照,明确 .NET Core GC 在整个专栏光谱里的位置:
| 维度 | Boehm(Unity 增量 GC) | Mono SGen | .NET Core GC |
|---|---|---|---|
| 分代 | 无 | 2 代(Nursery + Major) | 3 代 + LOH + POH |
| 段/堆管理 | 空闲链表 + 堆块 | 有 nursery/major 区划 | 段 + 三子堆 + 段缓存 |
| 是否移动 | 否,保守标记不搬对象 | 是 | 是,SOH 全压缩,LOH 默认不压 |
| 并发能力 | 无,全量 STW 或分片标记 | 有限并发 | 后台 GC(并发标记 + 并发清扫) |
| Server 模式 | 无 | 无 | 有,每核一堆 + 专属 GC 线程 |
| 固定对象 | 本来就不移动 | GCHandle 钉住 | POH 专用堆(.NET 5+) |
| 配置方式 | 无 | MONO_GC_PARAMS | runtimeconfig / 项目文件 / 环境变量 |
(表中功能项为定性对照,具体实现细节以各收集器源码与文档为准。)
三者的差异不是功能清单的长短,而是设计目标不同:Boehm 追求零依赖的嵌入成本,SGen 追求在 Unity 这种受限环境里给出可预期的分代回收,.NET Core GC 追求在多核服务器上同时拿到吞吐与低延迟。理解这条光谱,也就理解了 Unity 为什么做增量式 GC、为什么 Unity 6 要引入 CoreCLR 后端,以及为什么"把 .NET 的后台标记思路搬进游戏运行时"会成为方向(对照 03-04 第十一节的同一张光谱)。
再看纵向位置:07-01 是本章的入口,它交付的三样东西------源码地图、堆结构图、能力清单------是后面五篇的公共前提。07-02 站在第三节与第四节上往下钻分配,07-03/07-04 站在第五节的卡表与压缩上往下钻标记和清扫,07-05 站在第八节上往下钻并发状态机,07-06 站在段与多堆上往下钻极致优化。读完本章就该带着地图往后走,而不是在总览里反复盘旋。
总结
.NET Core GC 的架构设计体现了工业级 GC 的核心理念:
- 段管理是 GC 与 OS 内存交互的基础,段缓存策略平衡了性能与内存占用
- 三堆架构(SOH/LOH/POH) 针对不同大小和用途的对象采用差异化管理策略
- 分代设计通过代际假说大幅提高了 GC 效率,Gen 0 收集的性价比最高
- Server/Workstation 双模式适配了从桌面应用到云原生的不同需求
- 核心数据结构(gc_heap)集中管理所有 GC 状态,设计清晰
收成几句话便于带走:入口只有一个 GCHeap::GetGCHeap(),往下是 gc_heap 的记账闭环,再往下是 heap_segment 的地址推进线;堆按"多大、要不要动"切成 SOH、LOH、POH 三块,SOH 内部再按代做三级筛选;模式只在核数与内存预算之间做取舍,配置改完必须用 IsServerGC 验证;并发能力集中在 Gen2,两次短 STW 换掉一整段随堆增长的暂停,代价是写屏障全程在岗。
一句话记忆点:.NET Core GC 是一套"用段跟 OS 结账、用代跟时间结账、用后台线程跟延迟结账"的三层账本------本章对完第一层的账,后面五篇对另外两层。
在后续文章中,我们将深入分析 .NET Core GC 的分配路径、标记算法、清扫压缩以及后台 GC 的实现细节。