07-02-源码-.NET Core GC分配路径

.NET Core GC 分配路径

篇章 :07-源码深度-.NET Core GC

阅读时间:约 40 分钟


一、引言

分配路径是 GC 中最常执行的代码路径------每次 new 一个托管对象都会经过它。在一个每秒处理数万请求的 Web API 服务中,分配路径每秒可能被调用数百万次甚至上亿次。如果每次分配都陷入内核、获取锁或执行复杂计算,性能会瞬间崩塌。因此,.NET Core GC 的分配路径经过精心优化,在理想情况下只需几条 CPU 指令(bump pointer 加法 + 边界检查)即可完成。

本章深入分析分配路径的每一个环节,从线程级的分配上下文到堆级的段管理,从 Gen 0 的 bump 分配到 LOH 的空闲列表搜索,从预算驱动的 GC 触发到慢速路径的完整流程。理解分配路径是分析 GC 性能问题的前提------因为几乎所有 GC 相关的性能瓶颈,追根溯源都是分配路径上的某个环节出了问题。

在展开之前先把本章的定位说清楚。07-01 架构篇已经给出了源码地图和堆的整体结构,本章沿着那条地图向下钻,聚焦"一个对象从 new 到拿到指针"的完整旅程。标记与清扫的细节不在本章展开,它们分别归 07-03 和 07-04。后台 GC 的并发协作只在需要处一句话回指 07-05。


二、分配上下文(Allocation Context)

2.1 为什么需要线程级分配上下文

分配路径的第一个设计原则是消除锁竞争。在一个典型的 .NET 服务端应用中,可能有数百个托管线程同时创建对象。如果所有线程共享同一个分配指针,每次分配都需要获取互斥锁------这不仅引入了锁竞争,还会让分配路径变成缓存行乒乓(cache line ping-pong)的源头。

.NET Core GC 的解决方案是每个线程维护自己的分配上下文(allocation context)。这个上下文存储在线程对象中,包含了当前线程分配所需的全部状态:当前分配指针、分配上限、已分配次数以及性能计数器。

复制代码
// gc.h - alloc_context 结构体(简化示意,不含全部字段)
struct alloc_context {
    uint8_t* alloc_ptr;      // 当前分配指针------下一个可用的内存地址
    uint8_t* alloc_limit;    // 分配上限------当前 segment 的结尾
    int      alloc_count;    // 已分配次数------用于预算跟踪
    int      alloc_flags;    // 分配标志------区分 SOH / LOH / POH
    
    // 性能计数器------用于 GC 预算自适应
    size_t   alloc_bytes_since_last_gc;  // 上次 GC 后的总分配字节数
    size_t   alloc_bytes_upto_last_gc;   // 本次 GC 周期内的分配量
};

这个结构体非常精简,只有不到 48 字节(64 位平台),可以完全塞进一个 L1 缓存行。这意味着线程切换时,分配上下文的污染极小。更重要的是,每个线程的分配上下文是完全独立 的------线程 A 修改 alloc_ptr 不会影响线程 B,从根本上消除了锁竞争。

2.2 分配上下文的生命周期

分配上下文在线程启动时创建,在线程退出时销毁。它的生命周期与线程完全绑定:

复制代码
线程启动
  │
  ▼
EE 创建 Thread 对象
  │
  ▼
初始化 alloc_context(alloc_ptr = NULL, alloc_limit = NULL)
  │
  ▼
线程开始执行托管代码
  │
  ▼
首次 Alloc() 调用 → 分配第一个 segment → 初始化 alloc_ptr
  │
  ▼
后续 Alloc() 调用 → bump pointer(99.9% 走快速路径)
  │
  ▼
线程退出 → 释放 alloc_context 关联的资源

注意一个关键细节:alloc_context 在线程启动时 alloc_ptr 和 alloc_limit 都是 NULL。这意味着线程的第一次分配请求会走慢速路径,触发第一个 segment 的分配。这个设计是合理的------线程刚启动时通常不会立即创建大量对象,第一次分配走慢速路径的代价可以忽略不计。

2.3 分配上下文与 TLAB 的关系

这里需要澄清一个常见的概念混淆:.NET Core GC 的分配上下文不是 Java 风格的 TLAB(Thread-Local Allocation Buffer)。Java 的 TLAB 是 Eden 区中为每个线程预先划分的一块固定大小内存,用完即丢弃。而 .NET 的分配上下文更像是一个"动态窗口"------它没有固定的大小,而是随着 segment 的分配和释放动态变化。

具体来说,.NET 的分配窗口由 alloc_ptr 和 alloc_limit 定义,这两个指针之间的范围就是当前线程的"可分配区域"。当窗口耗尽时(alloc_ptr >= alloc_limit),线程会请求新的 segment 来扩展窗口。这个窗口可能跨越多个 segment(虽然通常一个线程在同一时间只使用一个 segment 的分配窗口)。

理解这个区别很重要,因为它直接影响了对 .NET 分配性能的分析。.NET 的分配窗口不是固定大小的,它的上限取决于 segment 的大小(Workstation GC 默认约 4MB,Server GC 可达 64MB 以上)。这意味着在低分配速率的场景下,一个线程的分配窗口可能长时间不被耗尽。


三、Bump 分配

3.1 快速路径:两条指令完成分配

Bump 分配是 .NET Core GC 分配路径的核心。它的思想极其简单:维护一个指向"下一个可用地址"的指针,每次分配只需将指针向前移动 size 字节。

复制代码
// gc.cpp - GCHeap::Alloc 快速路径(简化示意)
void* GCHeap::Alloc(size_t size, uint32_t flags)
{
    // 获取当前线程的分配上下文
    alloc_context* ctx = GetThreadAllocContext();
    
    // 快速路径:bump pointer 分配
    uint8_t* result = ctx->alloc_ptr;
    uint8_t* new_ptr = result + size;
    
    if (new_ptr <= ctx->alloc_limit)
    {
        // 请求大小满足当前段预算 → 简单移动指针
        ctx->alloc_ptr = new_ptr;
        ctx->alloc_bytes_since_last_gc += size;
        return result;
    }
    
    // 慢速路径:需要更多空间或触发 GC
    return AllocSlow(size, ctx, flags);
}

这段代码的核心逻辑可以编译为大约 5-7 条 x86-64 指令:

复制代码
; 伪汇编示意(非实际编译结果)
mov   rax, [rcx + alloc_ptr]      ; result = ctx->alloc_ptr
add   rax, r8                      ; new_ptr = result + size
cmp   rax, [rcx + alloc_limit]     ; new_ptr <= alloc_limit?
ja    slow_path                    ; 如果超出上限,跳转慢速路径
mov   [rcx + alloc_ptr], rax       ; ctx->alloc_ptr = new_ptr
add   [rcx + alloc_bytes_since_last_gc], r8
ret                                ; 返回 result

这就是为什么 .NET 的分配可以快到纳秒级别。没有锁、没有系统调用、没有内存屏障(因为每个线程只访问自己的 alloc_context),只有指针加法和边界检查。在现代 CPU 上,这条路径的延迟通常低于 1 纳秒。

关键优化点分析:

  1. 分支预测友好 :99.9% 的情况下分支 new_ptr <= alloc_limit 为真,CPU 的分支预测器可以轻松预测。即使偶尔预测失败,惩罚也仅为 10-20 个时钟周期。

  2. 缓存局部性 :alloc_context 只有不到 48 字节,通常与线程栈指针一起缓存在 L1 中。每次分配访问的是同一个缓存行。

  3. 无内存屏障 :因为每个线程只修改自己的 alloc_context,不存在跨线程共享数据,所以不需要 mfence 或 lock 前缀指令。

  4. 指令级并行 :result = alloc_ptr 和 new_ptr = result + size 之间没有依赖关系,CPU 可以并行执行。

3.2 对象头初始化

分配完内存后,GC 需要初始化对象头(object header)。每个托管对象在内存中有固定的布局:

复制代码
┌──────────────────────────────────────────────────┐
│ 对象头 (Object Header)                           │
│ ┌────────────────┬─────────────────────────────┐ │
│ │ Method Table   │ Sync Block / Context        │ │
│ │ (8 字节)       │ (8 字节)                    │ │
│ └────────────────┴─────────────────────────────┘ │
│ 对象数据 (Instance Fields)                       │
│ ───────────────────────────────────────────────  │
│ 对齐填充 (Padding, 可选)                         │
└──────────────────────────────────────────────────┘

对象头初始化由 JIT 在 newobj 指令生成的代码中完成,而不是由 GC 的 Alloc 函数完成。这是因为对象头的内容(特别是方法表指针)在编译时就已经确定:

复制代码
// JIT 生成的 newobj 指令对应的机器码(简化示意)
// 假设要 new Foo(),Foo 的大小为 32 字节,方法表指针为 0x7FFF...

mov   rax, [Foo::s_methodTable]       ; 加载 Foo 的方法表指针
call  GCHeap::Alloc                   ; 调用分配函数,返回对象指针
mov   [rax], rax                      ; 设置方法表指针(实际由 JIT 内联)
xor   ecx, ecx                        ; 同步块初始化为 0
; 零初始化对象数据(.NET 保证新分配的对象内存为零填充)
movdqa  [rax + 16], xmm6              ; 零填充剩余字段
ret

注意几个重要细节:

  1. 方法表指针的设置 :在 .NET 中,方法表指针的设置通常由 JIT 内联到 newobj 指令中,而不是在 Alloc 函数内完成。这使得方法表指针的设置与分配合并在同一条热路径上,减少了函数调用开销。

  2. 零初始化保证 :.NET 规范保证新分配的对象内存被零填充。这个保证由 GC 在分配 segment 时通过 VirtualAlloc/mmap 的零语义自动满足------操作系统分配新页时已经将其清零。因此 GC 不需要额外执行 memset。

  3. 同步块索引 :每个对象有一个同步块(sync block),用于支持 Monitor.Enter/Exit 等同步原语。同步块索引初始化为 0,表示未锁定。

  4. 对齐 :.NET 要求对象按 8 字节对齐(64 位平台)或 4 字节对齐(32 位平台)。分配时 size 会自动向上取整到 8 的倍数。

3.3 快速路径的边界条件

快速路径虽然高效,但它有一个天然的边界:alloc_limit。这个上限由当前 segment 的结尾决定。当 alloc_ptr + size > alloc_limit 时,快速路径失败,必须进入慢速路径。

alloc_limit 的设置不是随意定的。它考虑了以下因素:

  • Segment 的实际可用大小:segment 的总大小减去头部开销和对齐填充。
  • 对象大小上限:对于 SOH(小对象堆),对象大小不能超过 85,000 字节,否则直接走 LOH 路径。
  • 对齐要求 :alloc_limit 会向下取整到 8 字节边界,确保下一个分配不会越界。

四、Budget 管理

4.1 什么是分配预算

分配预算(allocation budget)是 .NET Core GC 控制 GC 触发频率的核心机制。它的核心思想是:Gen 0 的 GC 不应该在固定时间间隔触发,而应该在分配了足够多的对象后触发。

具体来说,每个 Gen 都有一个预算值(generation_budget),表示该代在触发 GC 前可以分配的最大字节数。当线程的 alloc_bytes_since_last_gc 超过 Gen 0 的预算时,就会触发 Gen 0 GC。

复制代码
// Gen 0 预算的动态调整(简化示意)
size_t generation_budget[3];  // generation_budget[0/1/2] 分别对应 Gen 0/1/2

void AdjustBudget()
{
    // 计算上次 GC 的存活率
    double survival_ratio = (double)survived_gen0 / (double)total_gen0;
    
    // 根据存活率动态调整 Gen 0 预算
    if (survival_ratio < 0.1)
    {
        // 存活率极低(<10%)→ 大部分对象都是短命的 → 扩大预算
        // 这样可以让 Gen 0 分配更多对象再触发 GC,减少 GC 频率
        generation_budget[0] *= 1.5;
    }
    else if (survival_ratio > 0.7)
    {
        // 存活率极高(>70%)→ 大量对象晋升到 Gen 1 → 缩小预算
        // 这样会更频繁地触发 Gen 0 GC,给短命对象更多回收机会
        generation_budget[0] *= 0.7;
    }
    // 如果存活率在 10%-70% 之间,预算保持不变
}

这个自适应算法的精妙之处在于它完全由运行时数据驱动,不需要人工调参。存活率低说明大部分对象都是短命的,GC 可以"大方"一些,多分配一些再回收;存活率高说明对象生命周期变长,GC 需要"谨慎"一些,更频繁地回收。

4.2 预算调整的时序

预算调整发生在每次 Gen 0 GC 结束后。此时 GC 已经统计了 Gen 0 中存活对象的总数和总大小,可以精确计算存活率。

复制代码
Gen 0 GC 结束
  │
  ▼
统计 survived_gen0 和 total_gen0
  │
  ▼
计算 survival_ratio = survived / total
  │
  ▼
survival_ratio < 0.1?  ──Yes──► budget[0] *= 1.5
  │ No
  ▼
survival_ratio > 0.7?  ──Yes──► budget[0] *= 0.7
  │ No
  ▼
budget[0] 保持不变
  │
  ▼
后续分配使用新的 budget 值

预算调整有一个重要的约束:预算值有上下限。下限防止预算过小导致 GC 过于频繁(thrashing),上限防止预算过大导致 Gen 0 无限膨胀。典型的下限是几 MB,上限是几百 MB,具体数值取决于运行时的配置和平台。

4.3 Budget 与 TLAB 的协同

Budget 机制与分配上下文(TLAB 式窗口)协同工作,形成了两级流量控制:

  1. 第一级(线程级):每个线程在自己的 alloc_context 中 bump 分配,完全无锁。这个级别不检查 budget。
  2. 第二级(堆级) :当线程的 alloc_bytes_since_last_gc 超过 budget 时,在慢速路径中触发 Gen 0 GC。

这种设计的优势是 budget 检查只在慢速路径中执行,不会污染快速路径的性能。快速路径仍然是纯粹的两条指令,而 budget 检查只在分配窗口耗尽时才会触发。


五、LOH 分配

5.1 LOH 为什么需要独立路径

大对象堆(Large Object Heap, LOH)是 .NET 中一个特殊的内存区域,用于存放大小超过 85,000 字节(约 85KB)的对象。LOH 的分配路径与 SOH(小对象堆)完全不同,原因如下:

  1. 对象大小差异巨大:SOH 中的对象通常在几字节到几十字节之间,而 LOH 中的对象可能从 85KB 到几 GB。bump pointer 分配在对象大小差异如此大的场景下效率极低------大对象会迅速耗尽 segment,导致频繁的段分配。

  2. 碎片问题:LOH 上的对象生命周期通常较长,频繁回收会导致严重的内存碎片。如果像 SOH 一样使用 bump pointer,LOH 的碎片会迅速累积,最终导致内存浪费甚至 OOM。

  3. 压缩策略不同:SOH 在 Gen 2 GC 时会进行压缩(compaction),将存活对象挪到一起消除碎片。但 LOH 对象通常很大,压缩的代价极高,所以 LOH 不使用 bump pointer,而是通过空闲列表管理。

5.2 LOH 分配流程

LOH 的分配走独立的路径,核心逻辑如下:

复制代码
// LOH 分配(简化示意)
void* AllocLOH(size_t size)
{
    // LOH 不使用 bump pointer,而是从空闲列表查找
    loh_alloc_context* loh_ctx = GetLOHAllocContext();
    
    // 尝试从 LOH 空闲列表分配(best-fit 策略)
    void* ptr = loh_ctx->free_list.Alloc(size);
    if (ptr) return ptr;
    
    // 空闲列表无合适块 → 提交新段
    heap_segment* segment = AllocNewLOHSegment(size);
    if (!segment)
    {
        // 内存压力 → 触发 Gen 2 GC 尝试回收
        GarbageCollect(2);
        segment = AllocNewLOHSegment(size);
    }
    return segment->mem;
}

LOH 分配的关键差异点:

特性 SOH LOH
分配策略 Bump pointer 空闲列表(best-fit)
分配速度 O(1),2-3 条指令 O(n),需要搜索空闲列表
碎片处理 GC 压缩 空闲列表复用
压缩 Gen 2 GC 时压缩 不压缩(.NET Core 2.1+ 支持 LOH 压缩)
对象大小 < 85,000 字节 >= 85,000 字节
线程局部性 每个线程独立上下文 共享上下文(需要锁)

5.3 LOH 空闲列表的结构

LOH 的空闲列表是一个按大小分类的链表。每个空闲块记录了自己的大小和下一个空闲块的指针。分配时,算法会遍历链表找到第一个大小 >= 请求大小的块(best-fit 策略)。

复制代码
LOH Free List:
┌─────────────┬──────────┬──────────────────┐
│ 块大小: 1MB │ 指针 → B2│ 可用,偏移 0     │
└─────────────┴──────────┴──────────────────┘
                    │
                    ▼
┌─────────────┬──────────┬──────────────────┐
│ 块大小: 512K│ 指针 → B3│ 可用,偏移 0     │
└─────────────┴──────────┴──────────────────┘
                    │
                    ▼
┌─────────────┬──────────┬──────────────────┐
│ 块大小: 2MB │ 指针 → NULL│ 可用,偏移 0    │
└─────────────┴──────────┴──────────────────┘

当 LOH 对象被回收时(即对象不再被引用且 LOH 被扫描),对应的内存块会被重新插入空闲列表。注意 LOH 的回收是惰性的------它只在 Gen 2 GC 时才会被扫描和回收,平时不会主动回收 LOH 中的死对象。

5.4 LOH 的性能影响

LOH 分配比 SOH 慢得多,主要原因有三:

  1. 空闲列表搜索:需要遍历链表找到合适的块,时间复杂度 O(n)。虽然实际中链表不会太长(LOH 的空闲块数量有限),但相比 bump pointer 的 O(1),差距是数量级的。

  2. 锁竞争:LOH 是共享的,所有线程的 LOH 分配都需要获取同一个锁。在高并发场景下,这会成为明显的瓶颈。

  3. 内存碎片:LOH 的 best-fit 策略会导致碎片累积。当空闲列表中没有足够大的连续块时,即使总空闲内存足够,分配也会失败。

这也是为什么 .NET 文档反复强调避免频繁分配 LOH 对象的原因。一个 100KB 的临时对象在 SOH 中分配只需纳秒级,但在 LOH 中可能需要微秒级------相差三个数量级。


六、分配触发 GC

6.1 AllocSlow 的完整流程

当快速路径失败时(分配窗口耗尽或预算超限),分配进入慢速路径 AllocSlow。这是分配路径中最复杂的部分,需要处理多种情况:

复制代码
// AllocSlow 完整流程(简化示意)
void* AllocSlow(size_t size, alloc_context* ctx, uint32_t flags)
{
    // 步骤 1:检查 Gen 0 预算
    size_t total_alloc = ctx->alloc_bytes_since_last_gc;
    if (total_alloc > generation_budget[0])
    {
        // 预算超限 → 触发 Gen 0 GC
        GarbageCollect(0);
        
        // GC 后重置预算
        total_alloc = 0;
    }
    
    // 步骤 2:尝试从当前 segment 分配新窗口
    heap_segment* current_seg = GetCurrentSegment();
    if (current_seg && current_seg->HasFreeSpace(size))
    {
        SetupAllocWindow(current_seg, ctx);
        return BumpAlloc(ctx, size);
    }
    
    // 步骤 3:分配新 segment
    heap_segment* new_seg = AllocNewSegment(size);
    if (!new_seg)
    {
        // 段分配失败 → 内存压力 → 触发 Gen 2 GC
        GarbageCollect(2);
        new_seg = AllocNewSegment(size);
        if (!new_seg)
        {
            // 仍然失败 → 真正的 OOM
            ThrowOutOfMemoryException();
        }
    }
    
    // 步骤 4:在新 segment 上建立分配窗口
    SetupAllocWindow(new_seg, ctx);
    return BumpAlloc(ctx, size);
}

这个流程可以概括为以下决策树:

复制代码
AllocSlow(size)
  │
  ├─ 预算超限?
  │   └─ Yes → Gen 0 GC → 重置计数器
  │
  ├─ 当前 segment 有空间?
  │   └─ Yes → 建立新窗口 → BumpAlloc
  │
  ├─ 分配新 segment
  │   └─ 失败?
  │       └─ Yes → Gen 2 GC → 重试分配
  │                   └─ 仍失败?→ OOM
  │
  └─ 建立新窗口 → BumpAlloc

6.2 Gen 0 GC 的触发时机

Gen 0 GC 的触发由 budget 控制,但 budget 不是固定值。它在每次 Gen 0 GC 结束后根据存活率动态调整。这意味着 GC 的触发频率是自适应的:

  • 高分配速率 + 低存活率:预算会逐步扩大,GC 频率降低。因为大部分对象都是短命的,不需要频繁回收。
  • 低分配速率 + 高存活率:预算会逐步缩小,GC 频率升高。因为对象生命周期变长,需要更频繁地清理。
  • 分配速率波动:预算会平滑地跟随变化,不会突然跳变。

这种自适应机制使得 .NET GC 能够在不同负载下自动调优,无需人工干预。

6.3 Gen 2 GC 的触发

Gen 2 GC 的触发条件比 Gen 0 更严格。它通常在以下情况触发:

  1. 显式调用 :GC.Collect(2) 或 GC.Collect()(默认收集 Gen 2)。
  2. 内存压力 :操作系统通知运行时内存紧张(Windows 的 SetMemoryLimit 或 MemoryLoad 通知)。
  3. 段分配失败:当无法分配新的 segment 时,运行时被迫触发 Gen 2 GC 尝试回收。
  4. LOH 分配失败:LOH 找不到足够大的空闲块时,触发 Gen 2 GC。

Gen 2 GC 是"全堆回收",需要扫描所有代(Gen 0、Gen 1、Gen 2、LOH),因此代价最高,暂停时间也最长。运行时尽量避免自动触发 Gen 2 GC,而是依赖 budget 机制让 Gen 0 GC 承担主要的回收工作。

6.4 慢速路径的性能影响

虽然慢速路径只在快速路径失败时执行,但它的影响不容忽视:

  1. 延迟尖峰:一次 AllocSlow 调用可能触发 Gen 0 GC,导致分配延迟从纳秒级飙升到毫秒级。对于延迟敏感的应用(如游戏、高频交易),这种尖峰是不可接受的。

  2. 线程阻塞:Gen 0 GC 通常是单线程的(STW),触发 GC 的线程会被阻塞直到 GC 完成。在高并发场景下,多个线程可能同时触发 GC,导致短暂的线程阻塞潮。

  3. 缓存失效:GC 过程中所有托管线程被挂起,GC 完成后线程恢复执行时,L1/L2 缓存中的对象引用可能已经失效。

因此,减少慢速路径的触发频率是 GC 性能优化的核心目标之一。这也是为什么 budget 自适应、段预分配、LOH 优化等技术如此重要------它们的目标都是让分配尽可能留在快速路径上。


七、POH(持久对象堆)

7.1 什么是 POH

持久对象堆(Pinned Object Heap, POH)是 .NET Core 4.8 及 .NET 5+ 中引入的一个特殊内存区域,用于存放被 pin 住的对象。对象被 pin 住意味着 GC 不能移动它------这通常发生在非托管代码持有对象指针的场景(如 P/Invoke 调用、unsafe 代码中的指针操作)。

7.2 POH 的分配路径

POH 的分配路径与 SOH 和 LOH 都不同:

复制代码
// POH 分配(简化示意)
void* AllocPOH(size_t size)
{
    // POH 使用独立的段,不与 SOH/LOH 共享
    poh_segment* seg = GetPOHSegment();
    
    // POH 也使用 bump pointer,但有自己的 alloc_context
    poh_alloc_context* ctx = GetPOHAllocContext();
    
    uint8_t* result = ctx->alloc_ptr;
    uint8_t* new_ptr = result + size;
    
    if (new_ptr <= ctx->alloc_limit)
    {
        ctx->alloc_ptr = new_ptr;
        return result;
    }
    
    // 窗口耗尽 → 分配新 POH segment
    return AllocNewPOHSegment(size);
}

POH 的关键特性:

  • 不压缩:POH 上的对象永远不会被移动(因为它们被 pin 住了),所以 POH 不需要压缩。
  • 独立段:POH 使用独立的段,与 SOH 和 LOH 完全隔离。这避免了 POH 的碎片影响其他堆。
  • 不回收:POH 上的对象只在进程退出时释放。这是因为 pin 住的对象通常与非托管代码的生命周期绑定,GC 无法安全地回收它们。

7.3 POH 对 GC 的影响

POH 的存在对 GC 有重要影响:

  1. 碎片隔离:POH 的碎片不会影响 SOH 和 LOH,因为它们是独立的段。
  2. 内存泄漏风险:POH 不回收,如果大量对象被 pin 住且长期不释放,POH 会持续增长,最终导致内存浪费。
  3. GC 根扫描:POH 上的对象在 GC 标记阶段需要被扫描,因为它们可能是可达对象的根。

八、分配路径的并发协作

8.1 分配与 GC 的并发

在 .NET Core 的并发 GC 模型中,分配路径和 GC 标记可以并行执行。这是通过以下机制实现的:

  1. 分配上下文隔离:每个线程有自己的 alloc_context,GC 不会修改其他线程的上下文。

  2. GC 标记阶段的并发性:Gen 0 和 Gen 1 的标记可以在应用线程继续分配的同时进行。只有 Gen 2 的标记和清扫阶段需要暂停应用线程。

  3. 段级锁:segment 的分配和释放使用细粒度的锁,不影响段内的 bump 分配。

    时间线示意:

    线程 A: [Alloc][Alloc][Alloc][AllocSlow→Gen0 GC][Alloc][Alloc]...
    线程 B: [Alloc][Alloc][Alloc][Alloc][AllocSlow→Gen0 GC][Alloc]...
    │ │
    GC线程: ───────────[标记 Gen0/Gen1]──────────[清扫]──────────
    │ │
    └────── 应用线程继续分配 ────────┘

8.2 后台 GC 对分配的影响

后台 GC(Background GC)在 .NET Core 3.0+ 中默认启用。它的核心思想是:在应用线程继续分配的同时,后台线程已经开始标记 Gen 2 对象。

后台 GC 对分配路径的影响是间接的但重要的:

  1. 减少 Gen 2 暂停:后台 GC 将 Gen 2 标记工作分摊到应用线程的分配间隙,显著减少了 Gen 2 GC 的暂停时间。
  2. 分配不受影响:后台 GC 期间,应用线程可以继续走快速路径分配,完全不受影响。
  3. 资源竞争:后台 GC 会占用 CPU 和内存带宽,可能与应用线程产生资源竞争。在 CPU 密集型场景中,需要权衡后台 GC 的开启与否。

九、分配路径的观测与诊断

9.1 关键性能计数器

.NET 提供了多个 GC 性能计数器,用于观测分配路径的行为:

计数器 含义 正常范围
Gen[0-2] Collections 各代 GC 触发次数 取决于负载
Promoted Mark[0-2] 晋升到下一代的对象数 越低越好
Heap Size 当前堆大小 不应持续增长
Allocated Bytes/sec 每秒分配字节数 取决于负载
GC Latency GC 暂停时间 Gen 0 < 1ms, Gen 2 < 100ms

9.2 使用 PerfView 诊断分配问题

PerfView 是 .NET 性能诊断的利器,可以采集 GC 和分配的详细数据:

复制代码
# 采集 GC 事件
PerfView.exe /GC /EtwKeyword=GC /OutputEtwEvents collect MyApp.exe

# 分析分配热点
PerfView.exe Collect /AllocStacks MyApp.exe

关键分析点:

  1. AllocStacks 报告:找出分配最频繁的调用栈,定位"分配热点"。
  2. GC 事件时间线:观察 GC 触发频率和暂停时间,判断 budget 是否合理。
  3. Gen 晋升率:如果 Gen 0 → Gen 1 的晋升率过高,说明短命对象过多,可能需要调整对象生命周期。

9.3 常见分配问题模式

  1. LOH 碎片 :频繁分配/释放 LOH 对象导致内存碎片,表现为 Heap Size 持续增长但可用内存减少。解决方案:复用 LOH 对象、使用对象池。

  2. Gen 晋升风暴:大量对象本应是短命的却晋升到了 Gen 1/2,导致 GC 效率下降。解决方案:检查对象生命周期,避免在短命对象中引用长命对象。

  3. 分配速率突增:某个代码路径突然开始大量分配对象,导致 budget 迅速耗尽、GC 频率飙升。解决方案:使用 AllocStacks 定位热点,考虑缓存或池化。


十、分配路径的横向对比

10.1 与 Unity GC 的对比

Unity 的 GC 基于 Mono 的 SGen,与 .NET Core GC 有显著差异:

特性 .NET Core GC Unity (SGen)
分配策略 Bump pointer + 自适应 budget Bump pointer + 固定预算
并发标记 Gen 0/1 并发,Gen 2 后台 增量标记(可配置)
LOH 独立堆,空闲列表 无专门 LOH
POH 独立持久对象堆 无
大页支持 原生支持 依赖 Mono 配置
暂停时间优化 后台 GC + 预取 增量标记 + 并行清扫

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

10.2 与 Java GC 的对比

特性 .NET Core GC Java (G1/ZGC)
TLAB 动态窗口(非固定大小) 固定大小 TLAB
预算调整 基于存活率自适应 基于目标暂停时间
大对象堆 LOH(独立堆) Humongous 区域(G1)
对象 Pin POH(独立堆) Pin 表 + 特殊处理
并发级别 Gen 0/1 并发,Gen 2 后台 ZGC: 完全并发

Java 的 ZGC 在并发程度上超越了 .NET Core GC,实现了真正的"无停顿并发 GC"(暂停时间不超过 10 微秒)。但 ZGC 的复杂度也远高于 .NET Core GC,且需要硬件支持(Load Barriers)。.NET Core GC 在并发程度和复杂度之间取得了更好的平衡。


十一、总结与实践建议

11.1 核心要点回顾

分配路径是 GC 系统中最关键的代码路径,它的设计体现了"快慢分离"的思想:

  1. 快速路径(99.9%):bump pointer 分配,只需指针加法 + 边界检查,延迟低于 1 纳秒。
  2. 慢速路径(0.1%):处理预算超限、段耗尽、LOH 分配等场景,可能触发 GC。
  3. 自适应预算:根据存活率动态调整 Gen 0 预算,无需人工调参。
  4. LOH 独立路径:空闲列表管理,避免 bump pointer 在大对象场景下的低效。
  5. POH 隔离:持久对象使用独立堆,避免碎片扩散。

11.2 实践建议

基于对分配路径的理解,以下是针对 C# 开发者的实践建议:

  1. 避免频繁分配 LOH 对象:LOH 分配比 SOH 慢 3-4 个数量级。如果对象大小接近 85KB 阈值,考虑调整对象布局使其低于阈值。

  2. 复用对象而非频繁 new :对于高频分配的场景(如游戏循环、网络包处理),使用对象池(ObjectPool<T>)或数组预分配。

  3. 关注 Gen 晋升率 :高晋升率意味着短命对象占用了更多代空间,增加了 GC 负担。使用 GC.GetGeneration(obj) 检查对象代际。

  4. 理解 budget 自适应 :不要手动调用 GC.Collect()。.NET 的 budget 自适应机制比手动调优更可靠。

  5. 监控分配速率 :使用 GC.GetTotalMemory(false) 和性能计数器监控分配速率,发现异常突增。

  6. 注意 POH 泄漏 :避免不必要的 fixed 语句和 GCHandle.Alloc。未释放的 GCHandle 会导致 POH 持续增长。

11.3 分配决策树

最后,用一张决策树总结整个分配路径的决策流程:

复制代码
new T() → Alloc(size, flags)
  │
  ├─ size >= 85,000?
  │   └─ Yes → LOH 路径
  │             ├─ 空闲列表 best-fit?
  │             │   └─ Yes → 返回指针(O(n))
  │             │   └─ No → 分配新 LOH segment
  │             │             └─ 失败 → Gen 2 GC → 重试
  │             └─ No → SOH 路径
  │                       ├─ alloc_ptr + size <= alloc_limit?
  │                       │   └─ Yes → BumpAlloc(O(1),2-3 条指令)
  │                       │   └─ No → AllocSlow
  │                       │             ├─ alloc_bytes > budget?
  │                       │             │   └─ Yes → Gen 0 GC
  │                       │             ├─ 当前 segment 有空间?
  │                       │             │   └─ Yes → 建立新窗口 → BumpAlloc
  │                       │             └─ 分配新 segment
  │                       │               └─ 失败 → Gen 2 GC → 重试
  │                       │             └─ 仍失败 → OOM
  │                       └─ 建立新窗口 → BumpAlloc
  │
  └─ flags 包含 PIN?
      └─ Yes → POH 路径
                └─ POH bump alloc → 失败 → 分配新 POH segment

十二、深入:分配路径的底层实现细节

12.1 JIT 内联与分配

.NET 的 JIT 编译器对分配路径有一个关键优化:将 Alloc 函数内联到 newobj 指令生成的代码中 。这意味着在大多数情况下,Alloc 的调用不会生成实际的函数调用指令,而是直接将分配逻辑展开到调用点。

复制代码
// C# 代码
var obj = new MyClass();

JIT 生成的代码大致如下(x86-64 伪汇编):

复制代码
; 假设 MyClass 大小为 32 字节,方法表指针在 r10 中
mov   r10, [MyClass::s_methodTable]       ; 加载方法表指针
mov   rax, [rsp + alloc_ptr]              ; 加载 alloc_ptr
add   rax, 32                             ; new_ptr = alloc_ptr + 32
cmp   rax, [rsp + alloc_limit]            ; 边界检查
ja    alloc_slow                          ; 如果超出,跳转慢速路径
mov   [rsp + alloc_ptr], rax              ; 更新 alloc_ptr
mov   [rsp + 0], r10                      ; 写入方法表指针
xor   ecx, ecx                            ; 同步块清零
movdqa  [rsp + 16], xmm0                  ; 零填充对象数据
mov   rax, [rsp + alloc_ptr - 32]         ; 返回对象指针
ret

alloc_slow:
call  GCHeap::AllocSlow                   ; 调用慢速路径
; 慢速路径返回后,JIT 代码继续初始化对象头

内联使得分配快速路径完全消除了函数调用开销。在没有内联的情况下,每次分配需要 15-25 个时钟周期(call + ret + 栈操作),内联后降低到 5-7 个时钟周期。

12.2 内存屏障与内存模型

.NET 的内存模型保证了分配路径的正确性,同时避免了不必要的内存屏障。关键原则是:

  1. alloc_context 的线程局部性 :每个线程只访问自己的 alloc_context,不存在跨线程共享,因此不需要内存屏障。
  2. GC 对分配的可见性:当 GC 扫描堆时,它保证能看到所有已完成的分配。这是通过 GC 根扫描和安全点机制保证的------在安全点,所有活跃的对象引用都是可见的。
  3. 对象初始化的可见性:对象字段在分配后由托管代码写入,JIT 和运行时保证这些写入在 GC 标记之前对其他线程可见。

具体来说,.NET 的内存模型遵循 C++11 的内存模型,使用 acquire-release 语义。分配操作本身是 release 语义(确保对象字写的写入在分配之前完成),GC 根扫描是 acquire 语义(确保能看到所有已写入的对象字段)。

12.3 对齐与填充

.NET 对象的内存对齐有严格的要求:

  1. 对象对齐:所有托管对象必须按 8 字节对齐(64 位平台)或 4 字节对齐(32 位平台)。这是由 GC 的指针压缩和扫描算法决定的。
  2. 字段对齐 :结构体字段按其自然对齐要求排列。例如,long 字段必须按 8 字节对齐,int 字段按 4 字节对齐。
  3. 对象填充 :对象的大小会自动向上取整到 8 的倍数(64 位平台)。例如,一个包含 int(4 字节)的对象实际占用 8 字节(4 字节对象头 + 4 字节字段 + 4 字节填充)。

对齐要求对分配路径的影响是:size 参数在 Alloc 函数中会自动向上取整到 8 的倍数。这个取整操作在 JIT 编译时完成(对于已知大小的类型)或在运行时通过位运算完成(对于动态大小的分配)。

复制代码
// 8 字节对齐的向上取整
size_t aligned_size = (size + 7) & ~7ULL;

这个位运算比除法快得多,通常只需 2-3 条指令。

12.4 分配路径的 SIMD 优化

在 .NET 9+ 中,JIT 引入了对分配路径的 SIMD 优化。核心思想是:在循环中连续分配多个对象时,使用 SIMD 指令并行处理多个分配。

复制代码
// 编译器可以自动优化的模式
var arr = new MyClass[1000];
for (int i = 0; i < 1000; i++)
{
    arr[i] = new MyClass();
}

在这个模式中,JIT 可以识别出连续的分配模式,使用 SIMD 寄存器并行计算多个对象的分配地址,然后一次性执行边界检查。这可以将连续分配的吞吐量提升 2-4 倍。


十三、分配路径的调优策略

13.1 环境变量的影响

.NET Core GC 的分配行为可以通过环境变量调优:

环境变量 作用 典型值
DOTNET_GCConcurrent 是否启用并发 GC 0/1
DOTNET_GCEmulateProcessorCount 模拟 CPU 核数(影响段大小) 1-128
DOTNET_GCHeapCount Server GC 的堆数 1-8
DOTNET_GCHeapHardLimit 堆硬限制(字节) 1G-64G
DOTNET_GCHeapGrowFactor 堆增长因子 10-40

这些环境变量直接影响分配路径的行为。例如,DOTNET_GCHeapHardLimit 设置了堆的最大大小,超过这个限制后分配会立即触发 Gen 2 GC。

13.2 代码层面的优化

除了环境配置,代码层面的优化同样重要:

  1. 对象池化 :对于高频分配的对象,使用 ArrayPool<T> 或 ObjectPool<T> 复用对象。
  2. 结构体替代类 :对于不需要虚方法、不需要作为接口实例的对象,使用 struct 而非 class。结构体在栈上分配(或内嵌在父对象中),不经过 GC 堆。
  3. Span 避免分配 :使用 Span<T> 和 Memory<T> 可以在已有内存上创建视图,避免额外的数组分配。
  4. 预分配数组 :对于已知大小的集合,在创建 List<T> 或数组时指定初始容量,避免扩容时的重复分配。

13.3 架构层面的考量

在架构层面,分配路径的性能影响需要考虑:

  1. 无状态设计:无状态服务通常有更高的分配速率(每个请求创建新的上下文对象),但生命周期短,GC 压力小。有状态服务分配速率低,但对象生命周期长,GC 压力集中在长命对象。
  2. 连接池与对象池:合理连接池和对象池可以显著降低分配速率,减少 GC 频率。
  3. 异步编程 :async/await 生成的状态机会创建临时对象。过度使用异步可能导致分配速率上升,需要权衡。

十四、结语

分配路径是 .NET Core GC 的基石,它的设计哲学可以用三个词概括:简单、快速、自适应。简单体现在 bump pointer 分配的直观性------只需一条加法指令和一条比较指令;快速体现在 99.9% 的分配在纳秒级完成;自适应体现在 budget 机制根据运行时数据自动调优 GC 频率。

理解分配路径不仅有助于写出更高效的 C# 代码,也为理解 GC 的其他组件(标记、清扫、压缩)奠定了基础。因为所有 GC 优化技术的最终目标都是同一个------让分配尽可能留在快速路径上,让 GC 的代价尽可能被摊薄到每次分配中。

在下一章中,我们将深入标记与扫描(07-03),看看 GC 如何在分配路径建立的对象关系图上,高效地找出哪些对象还"活着"。

相关推荐
在世修行1 小时前
智学文枢:页面切换与跨页数据预填
python·桌面应用·signal·1024程序员节·pyside·跨页跳转·状态预填
yyuuuzz2 小时前
跨境站点日志时区混乱的排查记录
运维·服务器·网络·云计算·aws·1024程序员节
西安同步电子2 小时前
GNSS信号模拟器在车规导航ISO26262证中的测试方案
1024程序员节
信誓旦旦的程序猿3 小时前
【Python量化实战宝典 #21】北向资金跟随手册:聪明钱择时信号(Python 可跑)
python·1024程序员节·股票数据api·股票数据api接口·股票api数据接口·股票量化数据api·股票量化数据接口
严谨的麻辣烫3 小时前
静态 ISP 代理请求超时怎么排查?Python 与 curl 实战
python·http·requests·1024程序员节·网络排障
下一站春风里3 小时前
位运算完整学习笔记:按位与(&)+ 异或(^)
开发语言·c++·算法
DRobot3 小时前
【机械制图与CAD实战(二十五)】螺纹的表示方法
算法·机械制图
吴可可1233 小时前
C#调用摄像头拍照的三种API方案
c#
用户老李3 小时前
卡尔曼滤波自学教程 10:无迹卡尔曼滤波(UKF)
算法