☰
07-04-源码-.NET Core GC清扫与压缩
2026/10/26 10:24:47 网站建设 项目流程

.NET Core GC 清扫与压缩

篇章:07-源码深度-.NET Core GC |阅读时间:约 45 分钟


一、引言

标记阶段识别了存活对象后,GC 进入回收阶段——清扫死亡对象、压缩存活对象。清扫和压缩是 GC 中内存效率的决定性因素:清扫决定了多少死亡内存被真正释放,压缩决定了碎片是否累积到影响分配的程度。

但清扫和压缩的代价也不容忽视。一次全堆压缩可能耗时数十到数百毫秒,直接反映在应用的暂停时间上。所以 .NET Core GC 的核心设计哲学是:能不清压就不清压,必须清压时尽量并行化。这个哲学贯穿了从计划决策到清扫实现、从压缩策略到 LOH 特殊处理的每一个环节。

在展开之前先把本章的定位说清楚。07-03 标记篇已经讲完了"哪些对象活着",本章接着讲"活着的东西怎么摆回去、死的东西怎么收走"。后台 GC 的并发编排见 07-05;分配路径见 07-02;堆结构与分代布局的背景见 07-01。


二、计划阶段(Plan Phase)

2.1 清扫前的决策

GC 在执行任何清扫动作之前,必须先制定计划。这个计划决定了:哪些代需要清扫、哪些代需要压缩、LOH 是否需要特殊处理、是否启用并行清扫。

// gc.h - GCPlan 结构体(简化示意,不含全部字段) struct GCPlan { bool compact_gen2; // Gen 2 是否需要压缩 bool compact_loh; // LOH 是否需要压缩 int sweep_gen0_segments; // Gen 0 需要清扫的段数 int sweep_gen1_segments; // Gen 1 需要清扫的段数 int sweep_gen2_segments; // Gen 2 需要清扫的段数 bool concurrent_sweep; // 是否并发清扫(后台 GC 时) bool parallel_sweep; // 是否并行清扫(多核时) float fragmentation_ratio_gen2; // Gen 2 碎片率 float fragmentation_ratio_loh; // LOH 碎片率 };

计划阶段的核心输入是碎片率。碎片率定义为:

碎片率 = 1.0 - (最大连续空闲块大小 / 总空闲内存)

换句话说,碎片率衡量的是"空闲内存被分散到了多小的块里"。碎片率为 0 表示所有空闲内存连成一块,碎片率为 1 表示每个空闲块都只有 1 字节(极端情况)。

2.2 压缩触发条件

压缩不是每次 GC 都做的——它只在碎片率超过阈值时才触发。.NET Core GC 的默认阈值因代而异:

代压缩触发阈值原因
Gen 0不压缩Gen 0 每次 GC 都重建,天然无碎片
Gen 1不压缩Gen 1 是 Gen 0 的"缓冲区",本身不独立管理
Gen 2~50%大堆碎片累积快,压缩收益高
LOH~70%(手动)LOH 不使用 bump pointer,碎片率天然较低

注意 Gen 0 和 Gen 1从不压缩。这是因为 Gen 0 在每次 GC 时都会"重建"——所有存活对象晋升到 Gen 1,Gen 0 的段被完全清空并重新使用。这种"全量晋升 + 段复用"的机制天然消除了 Gen 0 的碎片,不需要压缩。

Gen 1 也不压缩,因为它不是一个独立的内存区域——它和 Gen 2 共享同一组段。对 Gen 2 的压缩同时就是对 Gen 1 的压缩。

2.3 计划决策的时序

标记阶段完成 │ ▼ 统计各代碎片率 │ ├─ Gen 2 碎片率 > 50%? │ └─ Yes → compact_gen2 = true │ ├─ LOH 手动触发压缩? │ └─ Yes → compact_loh = true │ ├─ 并发 GC 且 Gen 2 需要清扫? │ └─ Yes → concurrent_sweep = true │ └─ CPU 核数 > 1 且需要清扫? └─ Yes → parallel_sweep = true

计划阶段本身非常快,通常只需几百微秒。它的输出GCPlan会被传递给清扫阶段和压缩阶段,指导后续的所有操作。


三、清扫阶段(Sweep Phase)

3.1 清扫的基本原理

清扫是 GC 回收阶段的第一步。它的任务很简单:遍历段中的所有对象,把死亡对象的内存标记为"可用"。

但实现起来并不简单。清扫需要处理以下问题:

  1. 对象遍历:如何高效地遍历段中的所有对象?
  2. 死亡检测:如何判断一个对象是否死亡?
  3. 空闲管理:如何管理回收后的空闲内存?
3.1.1 对象遍历

清扫遍历段的方式与标记遍历对象图的方式不同。标记是从根出发,沿着引用字段下钻;而清扫是顺序遍历段中的每个对象,按对象大小步进。

// SweepSegment 核心逻辑(简化示意) void SweepSegment(heap_segment* seg) { uint8_t* current = seg->start; uint8_t* end = seg->allocated; while (current < end) { Object* obj = (Object*)current; // 检查对象是否被标记 if (!obj->IsMarked()) { // 死亡对象:回收其内存 size_t size = obj->GetSize(); AddToFreeList(current, size); } else { // 存活对象:清除标记位,准备下一轮 obj->ClearMark(); } // 按对象大小步进到下一个对象 current += obj->GetSize(); } }

这个遍历是顺序的,对内存的访问模式非常友好——CPU 预取器可以很好地工作,缓存命中率极高。这也是为什么清扫阶段通常只占 GC 暂停时间的 10% 左右。

3.1.2 死亡检测

死亡检测通过检查对象的标记位(mark bit)实现。标记阶段会为每个存活对象设置标记位,清扫阶段只需检查这个位:

  • 标记位为 1:存活对象,清除标记位,继续下一个
  • 标记位为 0:死亡对象,回收内存

标记位通常存储在对象头的某个比特位中,访问速度极快(一条位操作指令)。

3.1.3 空闲管理

回收后的空闲内存需要被管理,以便后续分配能够快速找到可用空间。.NET Core GC 使用按大小分类的空闲列表(size-class free list):

空闲列表结构: ┌─────────────────────────────────────────────────┐ │ FreeList[0] (8-15 字节) ──► [块A] ──► [块B] │ │ FreeList[1] (16-31 字节) ──► [块C] ──► NULL │ │ FreeList[2] (32-63 字节) ──► [块D] │ │ ... │ │ FreeList[N] (>8KB) ──► [块E] ──► [块F] │ └─────────────────────────────────────────────────┘

空闲列表按大小分类的好处是:分配时可以根据请求大小直接定位到对应的链表,避免全表搜索。这与 slab 分配器的思想类似——将相近大小的空闲块归为一类,每类维护一个独立链表。

3.2 清扫的零分配保证

清扫阶段有一个重要的设计原则:不分配任何新对象。所有需要的临时数据结构(空闲链表节点等)都从已回收的对象本身借用内存。

这是经典的intrusive list(侵入式链表)技术:被回收的对象内存直接作为链表节点使用,不需要额外分配内存来维护空闲列表。具体来说,死亡对象的第一个字段(原本存储方法表指针之后的位置)被覆写为"下一个空闲块"的指针。

死亡对象回收前的内存布局: ┌────────────────────────────────────────────┐ │ 方法表指针 (8B) │ 同步块 (8B) │ 字段数据... │ └────────────────────────────────────────────┘ │ ▼ 回收后,方法表指针之后的内存被覆写 ┌────────────────────────────────────────────┐ │ 方法表指针 (8B) │ 空闲链表指针 ◄────────────┘ │ (保持不变,GC 不再需要) │ └────────────────────────────────────────────┘

这种设计的优势:

  1. 零额外内存:不需要为空闲链表分配任何额外内存。
  2. 零额外分配:不需要在清扫过程中调用malloc或new。
  3. 零额外开销:空闲链表的插入操作只是修改一个指针。
  4. 不清零:被回收的对象不需要清零,因为它的内存即将被重新分配使用。

3.3 并发清扫

在后台 GC(Background GC)模式下,清扫可以与应用线程并发执行。这是 .NET Core 3.0+ 的重要优化:

时间线示意: 应用线程: [Alloc][Alloc][Alloc][Alloc][Alloc][Alloc]... │ │ GC线程: ──[标记 Gen2]──[清扫 Gen2]──────[压缩 Gen2]── │ │ │ │ ├─ 并发清扫,应用线程继续分配 │ └─ 压缩时需要 STW

并发清扫的关键技术是标记-清扫并发:标记阶段在后台线程中并行执行,清扫阶段在标记完成后立即开始,不需要等待应用线程全部挂起。只有压缩阶段需要 STW(Stop-The-World)。

并发清扫的实现依赖于以下机制:

  1. 标记位的原子操作:标记阶段设置标记位使用原子操作,清扫阶段读取标记位不需要加锁。
  2. 段级锁:清扫器获取段的锁后,其他线程不能修改该段的内容。段级锁的粒度比全局锁小得多,减少了竞争。
  3. 卡片表补偿:并发期间应用线程可能创建新的引用,这些引用通过卡片表记录,清扫完成后由 GC 进行补偿扫描。

3.4 并行清扫

在多核机器上,清扫可以并行化。不同清扫器线程负责不同的段,互不干扰:

段 0 ──► 清扫器线程 0 段 1 ──► 清扫器线程 1 段 2 ──► 清扫器线程 2 段 3 ──► 清扫器线程 3

并行清扫的加速比接近线性——4 核机器上清扫 4 个段的耗时约等于清扫 1 个段的 4 倍。但并行清扫也有开销:

  1. 任务分发:需要将段分配给清扫器线程,这本身有同步开销。
  2. 空闲列表合并:各线程回收的空闲块需要合并到全局空闲列表中。
  3. 缓存竞争:多个线程同时写入空闲列表可能导致缓存行失效。

因此,并行清扫在段数较多时收益明显,段数较少时可能得不偿失。.NET Core GC 会根据段数和 CPU 核数自动决定是否启用并行清扫。


四、压缩阶段(Compact Phase)

4.1 为什么需要压缩

清扫虽然回收了死亡对象的内存,但它留下的空闲块是分散的。如果死亡对象和存活对象交替出现,空闲块也会交替出现:

清扫前的段布局: ┌──────┬────────┬──────┬────────┬──────┬────────┬──────┐ │ 存活 │ 死亡 │ 存活 │ 死亡 │ 存活 │ 死亡 │ 存活 │ │ ObjA │ ObjX │ ObjB │ ObjY │ ObjC │ ObjZ │ ObjD │ └──────┴────────┴──────┴────────┴──────┴────────┴──────┘ 空闲块: [ObjX] [ObjY] [ObjZ] 分散在存活对象之间 清扫后(未压缩): ┌──────┬────────┬──────┬────────┬──────┬────────┬──────┬──────┐ │ 存活 │ 死亡 │ 存活 │ 死亡 │ 存活 │ 死亡 │ 存活 │ 空闲 │ │ ObjA │ ObjX │ ObjB │ ObjY │ ObjC │ ObjZ │ ObjD │ │ └──────┴────────┴──────┴────────┴──────┴────────┴──────┴──────┘ 空闲块分散在 ObjA/ObjB/ObjC/ObjD 之间 → 碎片化 压缩后: ┌──────┬──────┬──────┬──────┬──────────────────────────────┐ │ 存活 │ 存活 │ 存活 │ 存活 │ 连续空闲空间 │ │ ObjA │ ObjB │ ObjC │ ObjD │ (可分配区域) │ └──────┴──────┴──────┴──────┴──────────────────────────────┘ 所有存活对象移到段开头,空闲空间集中在段尾 → 无碎片

压缩的核心价值是将分散的空闲块合并为连续的可用空间。没有压缩,碎片会逐步累积,最终导致即使总空闲内存足够,也无法分配大块对象(因为找不到足够大的连续空间)。

4.2 压缩的三个步骤

压缩是 GC 中最复杂的操作,涉及三个紧密耦合的步骤:

步骤 1:计算新地址
// ComputeNewAddresses(简化示意) void ComputeNewAddresses(int generation) { uint8_t* write_ptr = segment->start; // 写入指针,从段头开始 for (uint8_t* current = segment->start; current < segment->allocated; current += obj->GetSize()) { Object* obj = (Object*)current; if (obj->IsMarked()) { // 存活对象:记录新地址 obj->SetNewAddress(write_ptr); write_ptr += obj->GetSize(); } // 死亡对象:跳过,不分配新地址 } segment->allocated = write_ptr; // 更新段的分配上限 }

这一步是纯读取 + 顺序写入,对内存的访问模式非常友好。每个存活对象只被访问一次,按顺序从段头扫到段尾。

关键细节:

  • 新地址单调递增:存活对象按原顺序排列,新地址严格递增。这意味着压缩不会改变对象的相对顺序,有利于缓存局部性。
  • 死亡对象被跳过:死亡对象不占用新地址空间,相当于在计算新地址的同时完成了"收缩"。
  • 对象大小不变:压缩不改变对象大小,只改变对象的起始地址。
步骤 2:修复引用(Fix References)
// FixReferences(简化示意) void FixReferences(int generation) { // 修复栈上的引用 for (Thread* thread : suspended_threads) { for (RefLocation& ref : thread->stack_references) { if (ref.target >= segment_start && ref.target < segment_end) { *ref.location = ref.target->GetNewAddress(); } } } // 修复堆上的引用(存活对象中的引用字段) for (uint8_t* obj = segment->start; obj < segment->allocated; ) { if (obj->IsMarked()) { obj->FixReferences(); // 对象内部的引用字段全部更新 obj += obj->GetSize(); } } // 修复静态字段 for (StaticRef& ref : static_references) { if (ref.target >= segment_start && ref.target < segment_end) { *ref.location = ref.target->GetNewAddress(); } } // 修复句柄表 for (HandleEntry& entry : handle_table) { if (entry.target >= segment_start && entry.target < segment_end) { entry.target = entry.target->GetNewAddress(); } } }

引用修复是压缩中最复杂、最耗时的部分。需要修复的引用来源包括:

  1. 栈引用:各线程栈帧中指向压缩段的对象引用。
  2. 堆引用:存活对象内部的引用字段(指向同一段或其他段中的对象)。
  3. 静态字段:类静态变量中指向压缩段的引用。
  4. 句柄表:GCHandle 中指向压缩段的引用。
  5. GC 内部结构:如卡片表、根表等。

引用修复的关键约束:必须在复制之前完成。因为复制会移动对象,如果先复制再修复引用,修复时找不到旧地址。

步骤 3:复制对象数据
// CopyObjects(简化示意) void CopyObjects(int generation) { for (uint8_t* current = segment->start; current < segment->old_allocated; current += obj->GetSize()) { Object* obj = (Object*)current; if (obj->IsMarked()) { uint8_t* new_addr = obj->GetNewAddress(); if (current != new_addr) { // 对象需要移动:复制数据 size_t size = obj->GetSize(); MemMove(new_addr, current, size); } } } }

复制阶段使用MemMove批量移动对象数据。由于对象是按顺序处理的,且新地址也是顺序递增的,MemMove的调用可以很好地利用 CPU 的 DMA 缓存行预取和 SIMD 指令。

关键优化:

  • 原地跳过:如果对象的新地址等于旧地址(通常发生在段尾部的存活对象),直接跳过不复制。
  • 批量复制:MemMove内部使用 SIMD 指令(如 AVX2)一次复制 32 或 64 字节,比逐字节复制快数倍。
  • 写合并:多个小对象的复制可以合并为一次MemMove调用,减少函数调用开销。

4.3 压缩的代价

压缩涉及大量内存复制和引用更新,是 GC 中最耗时的操作。以下是一个典型场景的耗时分解:

100MB Gen 2 堆,碎片率 60%,一次全量压缩: 步骤 耗时 占比 ───────────────────────────────────── 计算新地址 5-10ms ~15% 修复引用 20-80ms ~60% ← 最耗时 复制对象数据 5-10ms ~15% 清理段元数据 1-2ms ~5% ───────────────────────────────────── 合计 30-100ms 100%

各步骤的耗时特征:

  1. 计算新地址:O(n) 顺序遍历,缓存友好,耗时与存活对象数成正比。
  2. 修复引用:O(n + r),n 为存活对象数,r 为引用字段总数。引用字段越多,耗时越长。栈引用和句柄表引用的修复需要逐个处理,无法批量优化。
  3. 复制对象数据:O(n),但MemMove的常数极小,实际耗时通常低于引用修复。
  4. 清理段元数据:O(1) 或 O(段数),耗时可忽略。

4.4 压缩的并发化

.NET Core GC 在 .NET 6+ 中引入了并发压缩的部分优化。核心思想是:将压缩拆分为多个阶段,其中"计算新地址"和"修复引用"可以在后台线程中并行执行,只有最后的"复制 + 最终引用修复"需要 STW。

传统压缩(全 STW): [计算新地址][修复引用][复制对象][最终修复] ← 全部 STW 并发压缩(.NET 6+): [计算新地址]──[修复引用] ← 后台线程并行执行 │ ▼ STW 切换点 [复制对象][最终修复] ← 短 STW

这种设计的优势是将 STW 时间从几十毫秒降低到几毫秒。后台阶段虽然也消耗 CPU 和内存带宽,但不影响应用线程;只有最后的 STW 阶段会暂停应用。


五、LOH 清扫与压缩

5.1 LOH 为什么不同

LOH(大对象堆)的清扫和压缩与 SOH(小对象堆)有本质区别,原因在 07-02 中已经提到过:

  1. 不使用 bump pointer:LOH 使用空闲列表管理,天然不存在"清扫后合并空闲块"的需求。
  2. 对象生命周期长:LOH 对象通常存活整个应用生命周期,碎片率增长缓慢。
  3. 压缩代价极高:LOH 对象可能从 85KB 到几 GB,复制一个大对象的代价相当于复制成千上万个小对象。

5.2 LOH 清扫

LOH 的清扫与 SOH 类似,也是遍历死亡对象并回收内存。但由于 LOH 对象数量远少于 SOH 对象,LOH 清扫的耗时通常可以忽略不计。

// LOH Sweep(简化示意) void SweepLOH() { for (auto& entry : loh_free_list) { if (!entry.obj->IsMarked()) { // 死亡 LOH 对象:回收 AddToLOHFreeList(entry.obj, entry.size); } else { // 存活 LOH 对象:清除标记 entry.obj->ClearMark(); } } }

5.3 LOH 压缩

LOH 压缩是一个手动触发的操作,默认不自动执行。这是因为 LOH 压缩的代价极高——它需要移动所有存活的大对象,并更新所有指向它们的引用。

// 手动触发 LOH 压缩 GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce; // 下一次 GC.Collect() 会压缩 LOH GC.Collect();

LOH 压缩的触发条件:

触发方式条件说明
手动触发GCSettings.LargeObjectHeapCompactionMode = CompactOnce下一次 GC.Collect() 触发
永久模式GCSettings.LargeObjectHeapCompactionMode = CompactEveryTime每次 GC.Collect() 都触发(不推荐)
自动触发无.NET Core 默认不自动压缩 LOH

LOH 压缩的代价:

  • 内存复制:所有存活 LOH 对象需要被移动,复制量可能达到数 GB。
  • 引用更新:所有指向 LOH 对象的引用需要更新,包括栈引用、堆引用、静态字段、句柄表。
  • STW 时间长:LOH 压缩通常需要 STW 数百毫秒到数秒,取决于 LOH 大小和存活对象数。

因此,LOH 压缩应该谨慎使用,只在确实存在严重碎片问题且影响分配时才触发。

5.4 LOH 碎片问题与缓解

LOH 碎片是 .NET 中一个经典问题。当频繁分配和释放大小不一的 LOH 对象时,空闲列表会逐渐碎片化,最终导致即使总空闲内存足够,也无法分配新的大对象。

缓解 LOH 碎片的策略:

  1. 避免频繁分配/释放 LOH 对象:使用对象池复用 LOH 对象。
  2. 控制对象大小:尽量使对象大小低于 85,000 字节阈值,使其走 SOH 路径。
  3. 定期手动压缩:在低峰期手动触发 LOH 压缩。
  4. 使用ArrayPool<T>:对于数组类型的 LOH 对象,使用数组池复用。

六、压缩的代价与触发策略

6.1 压缩耗时分解

一次完整的 Gen 2 压缩(100MB 堆)的耗时构成:

Full GC 耗时分解(100MB 堆,碎片率 60%): 标记: ████████████████████████ 60% (15-30ms) ← 后台GC改造目标 清扫: ████ 10% (2-5ms) 压缩: ██████ 30% (10-50ms) ← 最慢但仅在碎片率超阈值时触发 ───────────────────────────────────────────────── 合计: 100% (27-85ms)

关键观察:

  1. 标记占大头:标记是耗时最长的阶段,也是后台 GC 优化的主要目标。
  2. 清扫最稳定:清扫耗时波动最小,因为它只依赖存活对象数,与碎片率无关。
  3. 压缩最可变:压缩耗时波动最大,从几毫秒(低碎片)到数百毫秒(高碎片 + 大堆)。

6.2 压缩触发策略

.NET Core GC 的压缩触发策略可以概括为:保守触发,按需压缩。

是否压缩的决策逻辑: 1. Gen 0? → 不压缩(每次 GC 都重建) 2. Gen 1? → 不压缩(与 Gen 2 共享段,随 Gen 2 一起压缩) 3. Gen 2? ├─ 碎片率 > 50%? → Yes → 压缩 └─ No → 不压缩 4. LOH? ├─ 手动触发? → Yes → 压缩 └─ No → 不压缩(即使碎片率很高)

这个策略的核心考量:

  • 压缩的代价高:压缩需要移动对象、更新引用,耗时可能远超清扫。
  • 碎片不是最紧急的问题:只要还有足够大的连续空闲块,碎片不会导致分配失败。
  • 预算机制优先:通过 budget 机制控制 GC 频率,比通过压缩控制碎片更经济。

6.3 压缩的替代方案

在某些场景下,压缩不是最优选择。.NET Core GC 提供了以下替代方案:

  1. 段复用:回收的段不还给操作系统,留给下轮复用。这避免了"还了再要"的系统调用开销。
  2. 空闲列表优化:通过按大小分类的空闲列表,提高小块的分配命中率,减少对新段的需求。
  3. 晋升策略调优:调整对象晋升策略,让短命对象更快离开 Gen 0,减少 Gen 0 的碎片累积。

七、GC 耗时分解与优化方向

7.1 一次 Full GC 的完整耗时

一次 Full GC(100MB Gen 2 堆,碎片率 40%)的耗时构成: 阶段 耗时 是否 STW 可并发 ───────────────────────────────────────────────── 挂起线程 1-5ms Yes 否 根扫描 1-3ms Yes 否 标记 Gen 0/1 5-10ms 部分 是(后台) 标记 Gen 2 10-20ms 部分 是(后台) 清扫 Gen 0 1-2ms 部分 是(并发) 清扫 Gen 1/2 2-5ms 部分 是(并发) 压缩 Gen 2 10-30ms Yes 否 修复引用 5-20ms Yes 部分 恢复线程 1-3ms Yes 否 ───────────────────────────────────────────────── 总 STW 时间 20-60ms 总耗时(含并发) 30-100ms

7.2 优化方向

基于耗时分解,GC 优化的优先级如下:

  1. 减少标记耗时:后台 GC 已经将标记移到后台,进一步优化空间有限。
  2. 减少压缩触发:通过 budget 自适应和晋升策略,降低碎片率,减少压缩触发频率。
  3. 减少 STW 时间:并发压缩(.NET 6+)将大部分压缩工作移到后台,进一步降低 STW 时间。
  4. 减少清扫耗时:清扫已经很快(10%),优化空间有限。

八、清扫和压缩的时机选择

8.1 何时压缩

.NET Core GC 根据以下因素决定是否压缩:

  1. 碎片率(空闲空间分散度):超过阈值时触发压缩。Gen 2 默认阈值约 50%,LOH 默认不自动压缩。
  2. 最近一次压缩的时间间隔:避免频繁压缩。如果刚做过压缩,即使碎片率略有上升也不会立即再次压缩。
  3. 堆大小:大堆压缩的收益更大——碎片浪费的绝对空间更多,压缩后释放的连续空间也更可观。
  4. 内存压力:当操作系统通知内存紧张时,GC 可能降低压缩阈值,更积极地压缩以释放空间。

8.2 实际项目中的观察

在实际项目中,大多数 GC 只需要清扫不需要压缩。压缩通常只在以下场景触发:

  • 长运行服务:运行时间越长,碎片累积越多,压缩触发越频繁。
  • 对象生命周期差异大:如果同时存在短命对象和长命对象,长命对象之间的空洞会逐步累积为碎片。
  • LOH 频繁分配:虽然 LOH 不自动压缩,但 LOH 碎片会影响 Gen 2 的碎片率,间接触发 Gen 2 压缩。

九、清扫的零分配保证

9.1 Intrusive List 技术

清扫阶段有一个重要的设计原则:不分配任何新对象。所有需要的临时数据(空闲链表节点等)从已回收的对象本身借用内存。这是经典的intrusive list(侵入式链表)技术。

具体来说,被回收的死亡对象的内存被直接用作空闲链表的节点。死亡对象的第一个字段(原本存储方法表指针之后的位置)被覆写为"下一个空闲块"的指针。这样,空闲链表不需要任何额外的内存分配——它"寄生"在已回收的对象内存上。

死亡对象回收前的内存布局: ┌──────────────────────────────────────────────────────┐ │ 方法表指针 (8B) │ 同步块 (8B) │ 字段数据 (N 字节) │ │ ← 对象头 ← 对象数据 │ └──────────────────────────────────────────────────────┘ │ ▼ 回收后,方法表指针之后的内存被覆写为链表指针 ┌──────────────────────────────────────────────────────┐ │ 方法表指针 (8B) │ 空闲链表下一个节点指针 (8B) │ ... │ │ (GC 不再需要方法表指针) │ └──────────────────────────────────────────────────────┘ │ ▼ 插入空闲链表 FreeList[size_class] ──► [此块] ──► [下一个空闲块] ──► ...

9.2 零分配的优势

这种设计的优势是多方面的:

  1. 零额外内存:不需要为空闲链表分配任何额外内存。空闲链表的节点就是死亡对象本身。
  2. 零额外分配:不需要在清扫过程中调用malloc或new,避免了在 GC 过程中触发递归 GC 的风险。
  3. 零额外开销:空闲链表的插入操作只是修改一个指针,时间复杂度 O(1)。
  4. 不清零:被回收的对象不需要清零。因为它的内存即将被重新分配使用,清零是浪费。
  5. 缓存友好:空闲链表节点与对象数据在同一块内存中,访问局部性好。

9.3 与 Java GC 的对比

Java 的 OpenJDK GC(如 G1)使用独立的空闲列表结构,每个空闲块需要额外的元数据来记录大小和下一个节点。这意味着清扫阶段需要为每个死亡块分配一个空闲节点。.NET 的 intrusive list 技术避免了这一开销,是"零分配保证"的关键。


十、与 Unity GC 的对比

10.1 Unity (SGen) 的清扫与压缩

Unity 的 GC 基于 Mono 的 SGen(Scavenge-Sweep Garbage Collector),与 .NET Core GC 有显著差异:

特性.NET Core GCUnity (SGen)
清扫方式顺序遍历 + intrusive list标记-清扫 + 独立空闲列表
压缩策略Gen 2 按需压缩分代压缩(Scavenge 阶段)
LOH 压缩手动触发无专门 LOH
并发清扫.NET 3.0+ 支持增量清扫(可配置)
零分配保证清扫阶段不分配部分支持
压缩暂停时间30-100ms(100MB)10-50ms(100MB)

Unity 的增量清扫是应对移动设备资源限制的特殊设计——将清扫拆分成多个小片段,避免单次清扫暂停过长。而 .NET Core GC 更倾向于"一次性完成",通过后台 GC 和并发压缩来降低单次暂停的代价。

10.2 为什么 Unity 的压缩暂停更短

Unity SGen 的压缩暂停通常比 .NET Core GC 短,原因有三:

  1. 堆更小:移动设备的堆通常只有几十 MB,而 .NET 服务端应用的堆可能达到数 GB。压缩耗时与堆大小成正比。
  2. 压缩策略更保守:SGen 只在必要时压缩小代,不压缩整个堆。
  3. 对象更小:移动应用的对象通常较小,复制代价更低。

但这些差异也反映了平台约束:移动设备内存有限,不能像服务端那样使用大堆和 LOH。


十一、量化数据的口径说明

11.1 数据性质

本文给出的所有量化数据(耗时、百分比、阈值等)均为典型估算或示例量级,非实测常数。实际数值取决于以下因素:

  • 堆大小:100MB 堆与 10GB 堆的耗时相差两个数量级。
  • 碎片率:碎片率从 10% 上升到 80%,压缩耗时可能增加 5-10 倍。
  • CPU 核数:并行清扫和压缩的加速比接近线性,但受内存带宽限制。
  • 对象大小分布:大对象多的堆压缩耗时更长。
  • 引用密度:引用字段多的对象修复耗时更长。

11.2 典型场景参考

以下数据基于 16 核 64GB 服务端场景的典型估算(非实测):

场景Gen 0 耗时Gen 2 耗时是否压缩
低碎片 (<20%)0.5-2ms10-30ms否
中碎片 (20-50%)0.5-2ms20-60ms否
高碎片 (>50%)0.5-2ms50-150ms是
LOH 压缩N/A100-500ms是(手动)

十二、总结与实践建议

12.1 核心要点回顾

清扫和压缩是 GC 回收阶段的核心,它们的设计体现了"按需触发、零分配保证、并发优化"的思想:

  1. 清扫回收死亡对象:通过 intrusive list 技术实现零分配,耗时仅占 GC 的 10%。
  2. 压缩消除碎片:将分散的空闲块合并为连续空间,但代价高(30% 的 GC 耗时)。
  3. 按需触发:压缩只在碎片率超过阈值时触发,大多数 GC 只需清扫。
  4. 并发优化:后台 GC 将清扫移到后台,.NET 6+ 将压缩的部分工作移到后台。
  5. LOH 特殊处理:LOH 不自动压缩,需要手动触发,代价极高。

12.2 实践建议

基于对清扫和压缩的理解,以下是针对 C# 开发者的实践建议:

  1. 理解压缩触发条件:压缩只在碎片率超过阈值时触发。如果你的应用碎片率始终很低,压缩几乎不会成为性能瓶颈。
  2. 谨慎使用 LOH 压缩:GCSettings.LargeObjectHeapCompactionMode = CompactOnce是"核武器"级别的操��,只在确实存在严重 LOH 碎片且影响分配时才使用。
  3. 关注 Gen 2 堆大小:Gen 2 堆越大,压缩耗时越长。通过减少长命对象的数量和大小,可以降低压缩耗时。
  4. 避免"对象生命周期混叠":短命对象和长命对象混在一起会导致碎片快速累积。可以通过分层设计(将短命和长命对象放在不同的类中)来减少碎片。
  5. 监控碎片率:使用 PerfView 或 ETW 事件监控 GC 碎片率,发现异常上升趋势。
  6. 理解零分配保证:清扫阶段不分配新对象,所以不会触发递归 GC。这意味着清扫的耗时是确定性的,不会因 GC 嵌套而膨胀。

12.3 清扫与压缩的决策树

GC 回收阶段 │ ├─ 标记阶段完成 │ │ │ ├─ Gen 0? │ │ └─ 清扫(重建段,天然无碎片) │ │ │ ├─ Gen 1? │ │ └─ 清扫(随 Gen 2 一起处理) │ │ │ ├─ Gen 2? │ │ ├─ 碎片率 > 50%? │ │ │ ├─ Yes → 清扫 → 压缩 │ │ │ └─ No → 清扫(不压缩) │ │ └─ 并发清扫? │ │ ├─ Yes → 后台清扫 │ │ └─ No → STW 清扫 │ │ │ └─ LOH? │ ├─ 手动压缩? │ │ ├─ Yes → 清扫 → 压缩(STW,耗时高) │ │ └─ No → 清扫(不压缩) │ └─ 碎片率 > 70%? │ └─ 仍不自动压缩(LOH 压缩代价过高)

十三、深入:压缩的引用修复细节

13.1 引用修复的挑战

引用修复是压缩阶段最复杂的部分,面临以下挑战:

  1. 引用来源多样:栈引用、堆引用、静态字段、句柄表、GC 内部结构,每种来源的修复方式不同。
  2. 引用目标可能移动:被引用的对象可能在同一段(需要修复)、在不同段(不需要修复,因为 GC 内部结构已经处理了跨段引用)。
  3. 修复顺序:必须先修复所有引用,再移动对象数据。否则修复时找不到旧地址。

13.2 栈引用的修复

栈引用的修复是最直接的部分。在压缩开始前,所有应用线程被挂起,GC 可以安全地扫描每个线程的栈帧:

// 栈引用修复(简化示意) void FixStackReferences(Thread* thread) { for (StackFrame* frame : thread->frames) { for (RefLocation& ref : frame->local_variables) { if (ref.is_gc_ref && ref.target_in_compressed_segment) { // 更新引用为新地址 *ref.location = ref.target->GetNewAddress(); } } for (RefLocation& ref : frame->parameters) { if (ref.is_gc_ref && ref.target_in_compressed_segment) { *ref.location = ref.target->GetNewAddress(); } } } }

栈引用修复的关键点:

  • 线程必须挂起:修复栈引用时,应用线程不能运行,否则栈内容可能被改写。
  • 寄存器也需要修复:除了栈引用,挂起时保存的寄存器快照中的引用也需要修复。
  • 修复是原地进行的:直接修改栈帧中的引用值,不需要移动栈本身。

13.3 堆引用的修复

堆引用的修复比栈引用复杂得多。存活对象内部的引用字段可能指向同一段中的任何对象,也可能指向其他段。只有指向同一段的引用需要修复:

// 堆引用修复(简化示意) void FixHeapReferences(heap_segment* segment) { for (uint8_t* obj = segment->start; obj < segment->old_allocated; ) { if (obj->IsMarked()) { // 遍历对象的引用字段 MethodTable* mt = obj->GetMethodTable(); for (RefField* field : mt->reference_fields) { uint8_t* ref_addr = obj + field->offset; Object* target = *(Object**)ref_addr; // 只修复指向同一段的引用 if (target >= segment->start && target < segment->end) { *(Object**)ref_addr = target->GetNewAddress(); } } obj += obj->GetSize(); } else { obj += obj->GetSize(); // 死亡对象,跳过 } } }

堆引用修复的耗时取决于引用字段的数量。一个包含 10 个引用字段的对象,修复时需要检查 10 次、最多更新 10 次。对于引用字段多的对象(如字典、列表),修复耗时可能显著高于简单对象。

13.4 句柄表的修复

句柄表的修复相对简单,因为句柄表是一个集中管理的数据结构:

// 句柄表修复(简化示意) void FixHandleTable(heap_segment* segment) { for (HandleEntry& entry : handle_table) { if (entry.target >= segment->start && entry.target < segment->end) { entry.target = entry.target->GetNewAddress(); } } }

句柄表的大小通常远小于堆,修复耗时可以忽略不计。但需要注意固定句柄(pinned handle)——固定句柄指向的对象在压缩时不能被移动,压缩算法需要跳过这些对象。


十四、深入:压缩算法的变体

14.1 传统压缩:移动对象

前面描述的压缩方式是移动对象:将存活对象从旧地址复制到新地址。这是最常见的压缩方式,优点是实现简单、碎片消除彻底。

但移动对象有一个问题:指针不变性。如果外部代码持有对象的原始指针(如通过GCHandle的固定句柄),移动对象会导致指针失效。这就是为什么固定句柄指向的对象不能被移动。

14.2 间接压缩:更新指针

另一种压缩方式是不移动对象,只更新引用。具体来说:

  1. 不移动任何对象数据。
  2. 为每个对象维护一个"转发指针"(forwarding pointer),指向压缩后的逻辑位置。
  3. 所有引用指向转发指针,而不是直接指向对象。
传统压缩(移动对象): 旧地址: [ObjA][ObjB][ObjC] → 新地址: [ObjA][ObjB][ObjC][空闲] 引用 → ObjA 引用 → ObjA (新地址) 间接压缩(更新指针): 旧地址: [ObjA][ObjB][ObjC] → 逻辑地址: [ObjA][ObjB][ObjC][空闲] 引用 → ObjA 引用 → 转发指针 → ObjA (逻辑地址)

间接压缩的优点是不需要移动对象数据,缺点是引入了额外的间接寻址,每次引用都需要多一次内存访问。这在性能敏感的场景中是不可接受的,所以 .NET Core GC 不使用这种方式。

14.3 部分压缩:只压缩热点段

还有一种优化思路是只压缩热点段(即分配最频繁的段),而不是压缩整个代。这种部分压缩的策略是:

  1. 识别热点段(分配最频繁的段)。
  2. 只压缩热点段,非热点段保持原样。
  3. 非热点段中的引用指向热点段时,通过 GC 内部结构进行间接寻址。

这种方式的优点是压缩范围小、耗时短;缺点是实现复杂、间接寻址有性能开销。.NET Core GC 目前不使用这种方式,但在 .NET 7+ 中引入了更细粒度的段级压缩优化。


十五、结语

清扫和压缩是 GC 回收阶段的两个核心操作,它们共同决定了 GC 的内存效率和暂停时间。清扫通过 intrusive list 技术实现了零分配,耗时稳定且可预测;压缩通过移动对象和修复引用消除了碎片,但代价较高。

.NET Core GC 的设计哲学是能不清压就不清压,必须清压时尽量并行化。通过碎片率阈值、budget 自适应、后台 GC、并发压缩等技术,.NET Core GC 在大多数场景下将压缩的 STW 时间控制在几毫秒到几十毫秒之间。

理解清扫和压缩的机制,有助于开发者写出更友好的对象生命周期模式——减少碎片累积、降低压缩触发频率、避免 LOH 碎片问题。在下一章中,我们将深入后台 GC 的实现(07-05),看看 .NET Core GC 如何将清扫和压缩的耗时进一步摊薄到应用线程的分配间隙中。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询