.NET Core GC 架构解析
篇章:03-架构篇 |阅读时间:约 50 分钟 |前置知识:了解 Server/Workstation GC
一、引言
.NET 的垃圾回收器是工业界迭代时间最长的 GC 之一。从 .NET Framework 1.0 时代单一的 Workstation 模式,到今天支持 Server 多堆并行、后台并发标记、LOH 手动压缩、POH 固定堆的完整体系,它前后演进了近 20 年。对后端开发者来说,这套体系意味着大部分性能问题只需要调几个开关;对 Unity 开发者来说,摸清 .NET GC 的天花板在哪,才能准确判断 Mono SGen 和 Unity 增量式 GC 还差在哪一层。
它和本系列前面讨论过的两个对象定位很不一样。Boehm 是保守式、不分代、不移动的回收器,用最低的集成成本换来了碎片化和吞吐上限;SGen 是精确分代、移动式的回收器,但只有 Nursery 和 Major 两代,停在 Stop-The-World 一种执行模式上,也没有多核协同。.NET Core GC 把分代、移动压缩、并发标记、多堆并行全放进了一个实现里,代价是架构复杂度陡增:段、代、卡表、分配上下文、后台线程、分配预算,每一层都要看懂,才能解释清楚"为什么这个服务的 GC 停顿是 20ms 而不是 2ms"。
本文按"堆结构、运行模式、并发、配置、诊断"的顺序展开。先把堆在内存里的真实形状画出来,再看 Workstation 和 Server 两种模式怎么使用这个堆,然后拆 SOH / LOH / POH 三个子堆与分配路径,接着讲 Background GC 和触发机制,最后落到配置最佳实践与线上诊断手段。读完你应该能回答三个问题:对象到底落在哪里,GC 什么时候被叫醒,出了问题从哪里拿证据。
二、堆的物理结构:段与代
2.1 段:虚拟内存的分配单元
.NET GC 不会逐个对象向操作系统要内存。它先预留一大块虚拟地址空间,切成若干段(Segment),之后所有分配都在段内部完成。段采用两阶段管理:先 Reserve 占住地址范围,真正写入时才 Commit 物理页;回收后先 Decommit 归还物理页、保留地址空间,内存紧张时才整段 Release。运行时把回收掉的段留在缓存里复用,这就是段缓存,它省下了大量系统调用。
Workstation 模式的段通常只有数 MB,Server 模式下每个堆的段可以达到几十 MB 甚至 GB 量级,具体随核数变化。同一代的对象可能分布在多个段里:
托管堆的一个堆(Server 模式下每个逻辑处理器各有一份) Gen0/Gen1 短暂段(ephemeral segment) ┌────────────────────────────────────────────────┐ │ 对象A │ 对象B │ 已分配 → │ 空闲 │ └────────────────────────────────────────────────┘ Gen2 段 ┌────────────────────────────────────────────────┐ │ 长寿对象 │ 长寿对象 │ 碎片 │ 空闲 │ └────────────────────────────────────────────────┘ LOH 段 ┌────────────────────────────────────────────────┐ │ 大数组 128KB │ 空闲 │ 大字符串 96KB │ 空闲 │ └────────────────────────────────────────────────┘ POH 段(.NET 5+) ┌────────────────────────────────────────────────┐ │ pinned 缓冲区 1MB │ 空闲 │ └────────────────────────────────────────────────┘2.2 对象按代分布
Gen0 和 Gen1 共用短暂段。新对象一律往短暂段里 bump 分配,一次 Gen0 / Gen1 回收会把幸存对象压缩到段的一端,腾出来的空间继续给新分配使用。Gen2 对象住在自己的段里,只有 Gen2 回收才允许移动它们。LOH 与 POH 各自持有独立的段,完全不参与 SOH 的代际晋升。
对象的典型轨迹: new Foo() → Gen0(短暂段) │ 存活过一次 Gen0 GC ▼ Gen1 ── 存活过一次 Gen1 GC ──→ Gen2(长寿,只在 Gen2 回收时移动) ≥ 85,000 字节的对象 ─────────→ LOH(不晋升,只随 Gen2 回收) pinned: true 分配 ─────────→ POH(不分代,地址永久固定)这个设计的收益很直接:绝大多数对象活不过一次 Gen0 回收,GC 只需要盯着一小块短暂段,不用为"长寿对象可能引用了新生对象"去遍历整个老年代。
2.3 卡表与分配上下文
分代回收要解决的第一个问题是"老年代里有没有引用指向新生代"。卡表(Card Table)把堆划成固定大小的卡,每张卡对应一个记录字节;当应用把引用从老对象写向新对象时,写屏障(write barrier)顺手把对应的卡标脏。Gen0 回收只扫根集合和脏卡,不必遍历整个 Gen2,这就是分代 GC 的效率来源,也是写屏障给每次引用赋值增加的那点开销换来的。
每个托管线程还持有自己的分配上下文(allocation context),本质是alloc_ptr和alloc_limit两个指针,圈出该线程当前独占的一段分配窗口。线程在窗口内分配对象时只碰自己的数据,完全不经过全局锁。第五节会把这条路径逐行拆开。
关于实现细节要留一个口子:不同版本的运行时一直在调整堆的物理布局,例如把"按代划分的段"进一步做成 region 粒度的管理单元。这类调整属于持续演进中的实现细节,不构成对外契约。本文统一按"段 + 代 + 短暂段"的主线讲解,你读源码时看到不一样的命名不必意外。
三、Server GC 与 Workstation GC
3.1 Workstation GC
- 单堆单线程:一个托管堆配一个 GC 线程
- 两种执行方式可选:阻塞式(回收期间应用全停)和并发式(标记与应用同时跑)
- 内存占用小:一套堆元数据、一张卡表
- 暂停时长与堆大小正相关,堆越大单次回收越慢
它是默认模式,适合桌面应用、低核容器,以及任何"内存比吞吐更贵"的场景。
3.2 Server GC
- 多堆多线程:默认每个逻辑处理器一个独立堆,配一个专属 GC 线程
- GC 线程与处理器绑定(affinitize),避免跨核迁移造成的缓存失效
- 吞吐更高、单次暂停更短:每个堆更小,各堆还能并行回收
- 内存占用成比例上升,碎片风险同步上升
触发一次 GC 时,各核的 GC 线程并行清理本核的堆,堆与堆之间几乎没有同步点。16 核机器上,标记与清扫的工作量被摊到 16 条线程,理论并行度就是 16。这里给的是量级说明而不是基准测试:真实加速比受串行的根扫描阶段、内存带宽、各堆大小不均的影响,一般到不了 16 倍,但"吞吐随核数近似线性扩展"确实是 Server GC 的设计目标。
3.3 配置示例
{ "runtimeOptions": { "configProperties": { "System.GC.Server": true, "System.GC.Concurrent": true, "System.GC.HeapCount": 8 } } }同一组开关还有两种等价写法:项目文件里的<Server>true</Server>,以及环境变量DOTNET_GCServer=1。容器部署几乎都用后者,因为它不改镜像。
3.4 性能特征
Server 模式下每个核各有一套堆,元数据(卡表、段结构、分代统计)也按堆复制一份,通常占堆总大小的 3% 到 5%。工作集 4GB 时这部分就是 120 到 200MB。"Server GC 吞吐高但更吃内存"这句话的账全在这里,第八节的选择指南也是从这条推出来的。
四、堆结构:SOH / LOH / POH
4.1 三种堆总览
| 堆 | 大小阈值 | 分代 | 压缩策略 | 主要用途 |
|---|---|---|---|---|
| SOH | < 85,000 字节 | 三代(Gen0/1/2) | 每次回收都压缩 | 绝大多数日常对象 |
| LOH | ≥ 85,000 字节 | 视作 Gen2 | 不自动压缩(可手动触发) | 大数组、大字符串 |
| POH | 任意大小 | 不分代 | 不压缩,地址永久固定(.NET 5+) | 需要固定地址的缓冲区 |
4.2 SOH:三代与压缩
SOH 承接了 99% 以上的日常分配,内部再分三代,各自分工明确。Gen0 只装最新分配的对象,回收最频繁也最便宜;Gen1 是缓冲区,接住 Gen0 里活下来但还没证明自己长寿的对象,避免它们过早挤进 Gen2;Gen2 收留真正的长寿对象,回收昂贵但频率低。
每一代被回收时都会压缩,把幸存对象挪到一起,因此 SOH 内部基本不产生外部碎片,对象布局始终紧凑。代价是对象地址会变,任何缓存了托管指针的代码都必须依赖句柄或固定机制,而不能长期持有裸地址。这条规则贯穿 .NET 的整个互操作设计。
4.3 LOH:大对象与碎片
85,000 字节这个阈值来自对象对齐与段管理的成本权衡,不是随手挑的整数。达到它的对象直接进 LOH,跳过 Gen0 和 Gen1 的复制晋升,因为每次 Gen2 回收都可能去动它们,没有必要再让它们在年轻代里反复搬运。
LOH 的关键规则是不自动压缩:搬动一个几 MB 的大数组,CPU 和内存带宽的代价都很高,收益却不一定有。副作用是外部碎片:大对象频繁分配释放后,LOH 会留下大量尺寸不匹配的空洞,后续大分配可能因为找不到连续空间而提前触发 Gen2 回收,哪怕总空闲量足够。缓解手段有三种。
第一是复用缓冲区,而不是反复 new。第二是显式请求一次 LOH 压缩:
// LOH 压缩是 opt-in 的,默认关闭 GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce; GC.Collect(2, GCCollectionMode.Forced, blocking: true);第三是选对时机:LOH 压缩会移动对象,如果有原生代码正持有其中某个数组的指针(P/Invoke 缓冲区、第三方库句柄),压缩会立刻制造悬垂指针。安全时机是"进程内没有外借的 LOH 指针",例如请求间隙、批处理任务的阶段切换处。若这类缓冲区长期存在,正确做法是改用 POH,而不是反复压 LOH。
还有一条容易忘的规则:LOH 上的对象只有 Gen2 回收时才会被处理,这点和 SOH 的 Gen2 一致。所以 LOH 膨胀会直接拖长 Gen2 回收的间隔与单次成本。
4.4 POH:固定对象堆
POH 是 .NET 5 引入的第三类堆,专门存放地址必须稳定的大块内存。它的规则简单:不分代、不压缩、不移动,分配后地址永久有效,回收只在 Gen2 回收时发生。
POH 解决的是 pin 的老问题。传统做法里,把一个托管数组交给原生代码,必须用 GCHandle 把它钉住,否则下一次压缩回收就会把它搬走。钉住的对象会拖慢它所在堆的压缩,因为压缩算法要绕开它;而且句柄本身需要手动释放。POH 把这类对象单独隔离出来:它们本来就不参与压缩,自然不需要钉,也不会占用压缩算法的注意力。
代价同样要说清楚。POH 的对象只能整块等待 Gen2 回收,没有"部分回收"这回事,所以它适合生命周期本身就长的缓冲区;把大量短命对象往 POH 上塞,等于把它们全变成要等 Gen2 才能释放的垃圾。POH 不是万能的分配池,它是固定地址这个需求的专用解。
五、分配路径
5.1 快速路径:bump pointer 分配
new 一个对象时,JIT 生成的代码进入分配入口,第一步只做一次边界检查:
线程的分配上下文 [ 已分配区 | alloc_ptr → 空闲窗口 … alloc_limit ] 快速路径: ptr = ctx.alloc_ptr if ptr + size <= ctx.alloc_limit: // 边界检查 ctx.alloc_ptr = ptr + size // 指针前移 初始化对象头(方法表、同步块索引) return ptr else: 进入慢速路径核心就是加法、比较、一次写回,指令数在个位数。没有空闲链表遍历,没有按尺寸分桶查找,没有锁。因为窗口由线程独占,连原子操作都不需要。这是 .NET 分配吞吐高的直接原因。
5.2 慢速路径:新窗口、新段、触发回收
上下文用尽时进入慢速路径,按顺序尝试四件事:
- 向当前段申请一个新的分配窗口,段归堆所有,这一步可能需要和 GC 协调
- 当前段不够,向运行时申请一个新段
- 新段也拿不到,说明分配预算耗尽,触发 GC 后重试
- GC 回收后仍然放不下,说明堆确实现货不足,只能抛
OutOfMemoryException
慢路径是分配开销的主要来源,也是 GC 被实际叫醒的地方。健康运行的服务里,慢路径命中率应该极低;如果它频繁出现,要么分配速率失控,要么堆的预算配置不合理。
5.3 大对象路径
请求达到 85,000 字节时直接走 LOH 分配器,绕开分配上下文:先在 LOH 的空闲区里找一块足够大的连续空间,找不到就触发 Gen2 回收。POH 的路径类似,只是目标堆换成了 POH。这条路径没有 bump pointer 的运气成分,每次都要真正搜索空间,所以大对象分配本身就比小对象贵一个量级。频繁分配大对象的服务,问题往往出在这条路径上,而不是 GC 本身。
5.4 为什么 .NET 的分配吞吐这么高
对照本系列 03-02 讲过的 Boehm:保守式回收器大多用空闲链表管理内存,分配时要按尺寸找合适的块,容易受碎片影响,还得周期性整理。SGen 用 TLAB(线程本地分配缓冲)解决并发分配,思路上和 .NET 的分配上下文属于同一类设计,都是把堆切一块给线程独占,让绝大多数分配退化成指针加法。
.NET 的差别在于整条路径的完成度:分代让慢路径很少被触发,段缓存让向操作系统要内存的成本极低,Server 模式下每个核还有独立的堆,连跨线程的窗口竞争都省了。几个因素叠加,才有了"分配几乎免费"的观感。任何一条腿短了,这个观感都会消失。
六、Background GC(后台并发回收)
6.1 阻塞式回收的问题
最朴素的 GC 流程是全程 Stop-The-World:挂起所有托管线程,标记、清扫、压缩一次做完,再恢复。堆越大、存活对象越多,暂停越长。对 Web 服务来说,一次 5 到 50ms 的阻塞式 Gen2 回收(典型范围)足以让一批请求的尾延迟明显抖动。Background GC 就是为压掉这部分停顿而生的。
6.2 四个阶段
后台回收把最耗时的标记阶段挪到后台线程,和应用同时运行:
时间轴 ──────────────────────────────────────────────────► 应用线程 正常运行 ▥停█ 正常运行(并发标记期间) ▥停█ 正常运行… │ GC 线程 ├─ 1. 初始标记(STW,极短) ├─ 2. 并发标记(与应用并行,遍历对象图) ├─ 3. 重标记(STW,处理并发期的引用变更) └─ 4. 并发清扫(与应用并行,归还空闲空间)- 初始标记(STW):暂停所有线程,只扫描根集合和卡表脏项,确定一批必然存活的起点。这一步必须独占视图,否则根会在扫描过程中被改掉。
- 并发标记:GC 线程从起点出发遍历对象图,应用照常运行。期间应用修改引用,由写屏障记录下来,留给下一步处理。
- 重标记(STW):再次暂停,消化并发期间积累的引用变更,把标记补完整。这一步同样很短。
- 并发清扫:把未标记的内存归还或整理,应用继续运行。
两次 STW 的耗时都与根集合规模相关,而不是与堆大小相关,所以堆涨上去之后,单次停顿基本不动。这是后台回收最值钱的性质。
6.3 与 Gen0/Gen1 短回收的配合
后台回收只负责 Gen2。Gen0 和 Gen1 回收依然是短阻塞式,因为它们要处理的存活对象少、区间小,暂停本来就在亚毫秒到几毫秒量级。并发标记跑到一半时 Gen0 预算用完,运行时会插入一次前台的 Gen0 或 Gen1 回收,处理紧急的分配需求后立刻把线程还给并发标记。两条流水线并行不悖,这正是这套设计的精巧之处。
一个必须记住的约束:分配速率过快时并发标记可能来不及跑完,Gen2 空间先耗尽了,此时只能退化成一次完整的阻塞式 Gen2 回收,暂停反而比平常更长。监控里看到"本该后台跑的回收变成了 blocking",通常意味着分配速率或堆规模需要介入。
6.4 延迟量级与开关
典型范围:阻塞式 Gen2 回收 5 到 50ms,Background GC 下真正的 STW 切片通常在 1ms 以下。这两个数字是量级参考而不是承诺,实际取决于堆大小、根集合规模和硬件。
后台回收默认开启,也可以显式关闭:
{ "runtimeOptions": { "configProperties": { "System.GC.Concurrent": false } } }关掉之后回到全程阻塞模式,总 CPU 可能略降(省掉写屏障和并发协调的开销),代价是暂停回归 5 到 50ms 量级。批处理任务可以这么换,交互式服务别动。
七、GC 触发机制
7.1 预算触发
GC 不是被定时器叫醒的,是被分配预算花光叫醒的。运行时给每个代记账:Gen0 有一份分配预算,初始值随模式与核数变化,每次分配往上累加,用尽就触发一次 Gen0 回收;Gen1 的预算在 Gen0 回收后重新结算;Gen2 的预算跨度最大,通常由存活集大小和压缩收益共同决定。
这种按分配量计费的设计让 GC 频率自动正比于分配速率:分配少就回收少,分配猛就回收猛,不需要人工调周期。预算还会根据历史存活率动态修正,回收后存活比例高就调低预算,让下次回收来得更早。
7.2 分配失败与代际递进
慢路径拿不到内存是另一类触发器,而且是逐级升格的:先试 Gen0 回收,不够再 Gen1,再不够 Gen2,最后 Full GC。每升一级,单次暂停时间和能回收的量都上一个台阶。线上如果频繁看到高代回收,通常意味着长寿对象在增长,或者分配速率已经超出了预算触发的处理能力。
7.3 显式触发:GC.Collect 的正确用法
GC.Collect(); // 等价于 Collect(2, Forced, blocking: true) GC.Collect(0, GCCollectionMode.Forced); // 只回收 Gen0 GC.Collect(2, GCCollectionMode.Optimized); // 交给运行时判断值不值得 GC.Collect(2, GCCollectionMode.Forced, blocking: false); // 非阻塞,允许后台完成第一个参数是代掩码,决定回收到哪一代;blocking决定是否停到底,设为 false 时运行时可以把它拆成后台执行。生产代码里手写GC.Collect往往是个信号,说明分配模式有问题。少数合理场景:性能基准测试的起点对齐、测试用例需要确定性、批处理任务在阶段切换时主动归还内存。
7.4 内存压力通知
宿主环境和操作系统会推送内存压力事件,比如容器接近限额、系统整体吃紧。运行时收到后会收紧回收预算、提前触发高代回收,尽量避免进程被 OOM 杀掉。托管代码也能主动感知:监听内存使用率回调,在压力升高时自己先降级,丢弃大缓冲区、清掉缓存,把命运掌握在自己手里,而不是等内核出手。
7.5 LatencyMode:四种语义
GCSettings.LatencyMode = GCLatencyMode.Interactive; // 默认值| 模式 | 行为 | 适用场景 |
|---|---|---|
| Interactive(默认) | 后台回收开启,Gen2 回收被拆成短暂停 | Web API、桌面交互应用 |
| Batch | 关闭后台回收,追求总吞吐,不关心单次暂停 | 批处理、离线计算、基准测试 |
| LowLatency | 压制 Gen2 回收,尽量只跑 Gen0/Gen1 | 有明确时间边界的延迟敏感窗口 |
| SustainedLowLatency | 长期压制 Gen2 回收,可常驻 | 持续低延迟要求的服务 |
LowLatency 和 SustainedLowLatency 本质是借未来的债:Gen2 回收被推迟,堆只涨不缩,用久了最终还是要还一次更大的回收。它们适合"接下来 30 秒不能卡"这种有边界的场景,不适合常开。判断标准很简单,如果压了两分钟堆还在涨而你开始担心内存,就该切回 Interactive 让 GC 正常工作。
八、配置最佳实践
8.1 配置键总览
{ "runtimeOptions": { "configProperties": { "System.GC.Server": true, "System.GC.Concurrent": true, "System.GC.HeapCount": 8, "System.GC.NoAffinitize": false, "System.GC.RetainVM": false } } }| 配置键 | 作用 |
|---|---|
System.GC.Server | 开关 Server GC,与项目文件的<Server>和环境变量DOTNET_GCServer等价 |
System.GC.Concurrent | 开关 Background GC,默认为 true |
System.GC.HeapCount | 显式指定堆数量,默认按逻辑处理器数推导 |
System.GC.NoAffinitize | 关闭 GC 线程与处理器的绑定,改由操作系统调度 |
System.GC.RetainVM | 控制回收后的段是否把虚拟地址空间也归还操作系统,此处列出作为可选项 |
配置改完必须验证,第九节的两行代码就是干这个的。见过太多案例:调了半天 Workstation 参数,最后发现进程是 Server GC 起的,所有调整都没落在生效的配置上。
8.2 容器化部署
容器里最常见的坑是 GC 看到的 CPU 数与容器配额对不上。Kubernetes 的 pod 限了 2 核,运行时却按宿主机 64 核初始化了 64 个堆,既浪费内存又互相抢内存带宽。做法有两条:
- 显式设置
System.GC.HeapCount,数值与容器的 CPU limit 对齐,limit 是 2 就设 2,不要沿用默认值。 - 限核较小时同时考虑
System.GC.NoAffinitize。容器内的处理器本来就可能被宿主机调走,硬绑定反而制造停顿。
System.GC.RetainVM在容器里通常保持默认,除非进程内存曲线频繁大幅波动,才需要放开它让虚拟地址空间的归还更积极。
8.3 什么时候不要用 Server GC
| 场景 | 推荐 | 原因 |
|---|---|---|
| ASP.NET Web API(多核) | Server GC | 多堆并行,吞吐与暂停双赢 |
| WPF / WinForms 桌面应用 | Workstation GC | 单机内存有限,多堆元数据是纯浪费 |
| 微服务容器(1 到 2 核) | Workstation GC | 堆数太少,并行收益覆盖不了内存开销 |
| 批处理、离线计算 | Server GC + Batch 模式 | 极致吞吐,不关心单次暂停 |
| 交易、音视频管线 | Workstation GC + SustainedLowLatency | 内存可控,暂停可压制 |
判断标准压缩成一句话:核数少、内存紧、对单次暂停敏感,就用 Workstation;核多、吞吐优先、内存有富余,才上 Server。1 到 2 核的容器强行开 Server GC,是实践中最典型的配置错误之一。
九、诊断与可观测性
9.1 GC 事件:ETW 与 LTTng
GC 的每个阶段都会发事件。Windows 上走 ETW(Event Tracing for Windows),Linux 上走 LTTng,事件名两边保持一致,因此工具链可以跨平台复用:
- GCStart / GCEnd:一次回收的起止,带代数和是否 blocking,两条配对就是单次暂停时长
- GCHeapStats:回收前后的各代存活量与分配量,用来判断晋升速率
- GCPerHeapHistory:Server 模式下每个堆的回收明细,用来定位"哪个核的堆特别大"
- GCGenerationRange:各代地址范围的变化,观察段的扩张与收缩
拿到这些事件之后,"暂停长"就不再是一个感觉,而是一条可以精确到毫秒的时间线。
9.2 工具链
| 工具 | 用途 |
|---|---|
| PerfView | Windows 上最全的 ETW 分析器,GC 日志、分配速率、时间线一把抓 |
| dotnet-trace | 跨平台采样与 GC 事件采集,线上排障首选 |
| dotnet-counters | 实时计数器,盯 GC 堆大小与回收次数的斜率 |
| dotnet-gcdump | 抓托管堆快照,对比两次抓取定位泄漏对象 |
dotnet-counters的典型用法是一条命令挂上去看趋势:
dotnet-counters monitor --process-id <pid> System.Runtime关注GC Heap Size和GC Count两条曲线:前者单调上涨说明有泄漏或者预算失衡,后者斜率突变说明分配速率变了。排查节奏建议按"趋势、事件、对象"推进,先用 dotnet-counters 看趋势,再用 dotnet-trace 抓事件确认是哪一代、是不是 blocking,最后才上 gcdump 找具体对象。反过来做,成本会高一个量级。
9.3 自省 API
不借助外部工具也能拿到关键信息:
var info = GC.GetGCMemoryInfo(); long heapSize = info.HeapSizeBytes; // 当前堆大小 long committed = info.TotalCommittedBytes; // 已提交内存 long fragmented = info.FragmentedBytes; // 碎片量,LOH 问题的直接证据 int gen0 = GC.CollectionCount(0); // 各代回收次数 int gen2 = GC.CollectionCount(2);GC.GetGCMemoryInfo()在高代回收之后数据最准,适合打进周期性日志。CollectionCount要看差值而不是绝对值,两个时间点相减就能算出区间内的回收频率。
9.4 运行时确认当前模式
配置到底有没有生效,运行时一查便知:
Console.WriteLine($"Server GC : {GCSettings.IsServerGC}"); Console.WriteLine($"LatencyMode: {GCSettings.LatencyMode}");这两行应该出现在服务的启动日志里。它排掉的是一整类误判:模式判断错、配置键拼错、环境变量没注入容器,全都会在这里现形。
十、POH 实战
10.1 固定缓冲区的两种做法
// 方式一(.NET 5+):直接分配在 POH byte[] pinnedBuffer = GC.AllocateArray<byte>(1024 * 1024, pinned: true); // 地址永久固定,压缩回收永远绕开它,不需要任何句柄 // 原生代码可以长期安全地持有它的指针 // 方式二(传统):普通堆 + 手动钉住 byte[] buffer = new byte[1024 * 1024]; GCHandle handle = GCHandle.Alloc(buffer, GCHandleType.Pinned); try { IntPtr ptr = Marshal.AddrOfPinnedObject(buffer); // 交给原生代码使用… } finally { handle.Free(); // 漏掉这一行就是资源泄漏 }差别不只是代码行数。方式二里,被钉住的对象会让它所在堆的压缩变慢,因为算法必须绕开它;GCHandle 本身还是需要释放的资源。方式一把这类对象彻底隔离到 POH,SOH 的压缩不再受干扰,"忘了 Free"这个错误类别也从根上消失。字符串同理,GC.AllocateString可以直接分配到 POH。
10.2 GCHandle 泄漏这一类 bug
传统 pin 模式下的典型事故有三种:正常路径写了Free()而异常路径忘了;两个方法各自Alloc同一个缓冲区却只Free一次;以及循环里反复Alloc但只在循环外释放。后果不只是内存泄漏,被钉住的对象会持续拖累后续所有压缩回收,线上表现往往是"堆不算大,GC 却特别慢",查起来非常隐蔽。
POH 把这类问题消解掉了,因为根本没有句柄需要管理。除了互操作缓冲区,POH 还适合另一类场景:长期复用的网络收发缓冲、序列化中间结果。它们的生命周期本来就是整个请求乃至整个进程,交给 Gen2 回收完全合理,还能省掉反复 pin 和 unpin 的开销。
十一、与 Mono SGen / Boehm 的架构对比
| 维度 | Boehm | Mono SGen | .NET Core GC |
|---|---|---|---|
| 分代 | 无 | 2 代(Nursery + Major) | 3 代(Gen0/1/2) |
| 是否移动 | 否,保守标记不搬对象 | 是,Nursery 复制,Major 可压缩 | 是,SOH 全压缩,LOH 默认不压 |
| 并发能力 | 无 | 有限并发 | Background GC(并发标记 + 并发清扫) |
| Server 模式 | 无 | 无 | 有,每核一堆 + 专属 GC 线程 |
| 大对象处理 | 无独立堆,同样走空闲链表 | ≥8KB 直接进 Major | 独立 LOH(≥85,000 字节) |
| 固定对象 | 本来就不移动 | GCHandle 钉住 | POH 专用堆(.NET 5+) |
| 线程模型 | STW + safepoint | STW + safepoint | STW 切片 + 后台线程并发 |
| GC 接口嵌入点 | Mono 的 GC 接口层,实现可热插拔 | 同左,与 Boehm 实现同一组接口 | CLR 的 EE 层 GCHeap 接口 |
| 配置方式 | 无 | MONO_GC_PARAMS 环境变量 | runtimeconfig / 项目文件 / 环境变量 |
三者的差异不是功能清单的长短,而是设计目标不同:Boehm 追求零依赖的嵌入成本,SGen 追求在 Unity 这种受限环境里给出可预期的分代回收,.NET Core GC 追求在多核服务器上同时拿到吞吐和低延迟。理解这条光谱,也就理解了 Unity 为什么做增量式 GC,以及为什么"把 .NET 的后台标记思路搬进游戏运行时"会成为方向。
总结
.NET Core GC 的架构可以用三句话概括。堆被切成段和代,短命对象在短暂段里快速 bump 分配,长寿对象和大对象各有专属区域;模式上 Workstation 求省内存、Server 求并行吞吐,差别只在几个配置键;并发上 Background GC 把最贵的 Gen2 标记拆到后台,只留下与根集合成正比的两次短暂停。
落到工程实践,最重要的三个结论是:第一,先确认自己跑在什么模式上,IsServerGC和LatencyMode两行代码就能排掉一半的误判;第二,配置要和部署环境对齐,容器里HeapCount不匹配 CPU limit 是最常见的隐性浪费;第三,诊断要按"趋势、事件、对象"的顺序推进,dotnet-counters 起步,dotnet-trace 定位,gcdump 收尾。
把这条线对照回本系列前面的 Mono 章节,会看到一条清晰的演进路径:从 Boehm 的保守不分代,到 SGen 的两代 STW,再到 .NET 的三堆、并发与多核并行。GC 架构的历史,本质就是在吞吐、暂停、内存三者之间不断重新切分代价的历史,而 .NET Core GC 目前把这道题答得最完整。
代码示例:运行时 GC 状态 dump
using System; using System.Runtime; class GCStatusDump { static void Main() { Console.WriteLine($"IsServerGC : {GCSettings.IsServerGC}"); Console.WriteLine($"LatencyMode : {GCSettings.LatencyMode}"); Console.WriteLine($"LOH compaction: {GCSettings.LargeObjectHeapCompactionMode}"); for (int gen = 0; gen <= 2; gen++) Console.WriteLine($"Gen{gen} collections: {GC.CollectionCount(gen)}"); var info = GC.GetGCMemoryInfo(); Console.WriteLine($"Heap size : {info.HeapSizeBytes / (1024.0 * 1024):F1} MB"); Console.WriteLine($"Committed : {info.TotalCommittedBytes / (1024.0 * 1024):F1} MB"); Console.WriteLine($"Fragmented : {info.FragmentedBytes / (1024.0 * 1024):F1} MB"); Console.WriteLine($"Available : {info.TotalAvailableMemoryBytes / (1024.0 * 1024):F1} MB"); } }跑一次就能确认当前是 Server 还是 Workstation、LatencyMode 落在哪一档、各代回收了几次、堆上堆了多少碎片。上线前后各打一次,配置是否生效、内存是否恶化,一眼就能对上。