07-04-源码-.NET Core GC清扫与压缩

.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 GC Unity (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-2ms 10-30ms 否
中碎片 (20-50%) 0.5-2ms 20-60ms 否
高碎片 (>50%) 0.5-2ms 50-150ms 是
LOH 压缩 N/A 100-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 如何将清扫和压缩的耗时进一步摊薄到应用线程的分配间隙中。

相关推荐
深频率2 小时前
上线一年就撤:微软关掉Outlook的AI邮件助理
人工智能·1024程序员节
橙光成长3 小时前
PDF离线工具箱深度解析:4MB纯前端单文件,40+功能全本地运行,文件绝不上传
前端·pdf·1024程序员节
可爱系程序猿6 小时前
佳能 MF645Cx Windows 11 驱动下载安装:UFR II 队列可打印但扫描入口缺失的排查
程序人生·电脑·1024程序员节
zwm2698888159 小时前
hd 3#负一西送风,能远程启不能远程停
c#
irislinAmelia9 小时前
缺陷检测AI关键点在哪
人工智能·嵌入式硬件·1024程序员节
Ai-_Man9 小时前
Meta Muse还是不可以导出左侧所有对话,只能点进去单条内容导出,方法不对吗?
开发语言·人工智能·ai·小程序·c#·1024程序员节
梓豪csd9 小时前
消息订阅中心:谁该收到哪条通知
java·开发语言·人工智能·低代码·自动化·1024程序员节
王中阳Go9 小时前
两个入口答案不一致先别调模型:6 行入口口径对账表(附脚本)
人工智能·后端·python·大语言模型·1024程序员节
Yang96119 小时前
0.8米超短盲区,解析BM-T1如何服务矿石能源光缆测试
1024程序员节