.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 纳秒。
关键优化点分析:
-
分支预测友好 :99.9% 的情况下分支
new_ptr <= alloc_limit为真,CPU 的分支预测器可以轻松预测。即使偶尔预测失败,惩罚也仅为 10-20 个时钟周期。 -
缓存局部性 :
alloc_context只有不到 48 字节,通常与线程栈指针一起缓存在 L1 中。每次分配访问的是同一个缓存行。 -
无内存屏障 :因为每个线程只修改自己的
alloc_context,不存在跨线程共享数据,所以不需要mfence或lock前缀指令。 -
指令级并行 :
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
注意几个重要细节:
-
方法表指针的设置 :在 .NET 中,方法表指针的设置通常由 JIT 内联到
newobj指令中,而不是在Alloc函数内完成。这使得方法表指针的设置与分配合并在同一条热路径上,减少了函数调用开销。 -
零初始化保证 :.NET 规范保证新分配的对象内存被零填充。这个保证由 GC 在分配 segment 时通过
VirtualAlloc/mmap的零语义自动满足------操作系统分配新页时已经将其清零。因此 GC 不需要额外执行memset。 -
同步块索引 :每个对象有一个同步块(sync block),用于支持
Monitor.Enter/Exit等同步原语。同步块索引初始化为 0,表示未锁定。 -
对齐 :.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 式窗口)协同工作,形成了两级流量控制:
- 第一级(线程级):每个线程在自己的 alloc_context 中 bump 分配,完全无锁。这个级别不检查 budget。
- 第二级(堆级) :当线程的
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(小对象堆)完全不同,原因如下:
-
对象大小差异巨大:SOH 中的对象通常在几字节到几十字节之间,而 LOH 中的对象可能从 85KB 到几 GB。bump pointer 分配在对象大小差异如此大的场景下效率极低------大对象会迅速耗尽 segment,导致频繁的段分配。
-
碎片问题:LOH 上的对象生命周期通常较长,频繁回收会导致严重的内存碎片。如果像 SOH 一样使用 bump pointer,LOH 的碎片会迅速累积,最终导致内存浪费甚至 OOM。
-
压缩策略不同: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 慢得多,主要原因有三:
-
空闲列表搜索:需要遍历链表找到合适的块,时间复杂度 O(n)。虽然实际中链表不会太长(LOH 的空闲块数量有限),但相比 bump pointer 的 O(1),差距是数量级的。
-
锁竞争:LOH 是共享的,所有线程的 LOH 分配都需要获取同一个锁。在高并发场景下,这会成为明显的瓶颈。
-
内存碎片: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 更严格。它通常在以下情况触发:
- 显式调用 :
GC.Collect(2)或GC.Collect()(默认收集 Gen 2)。 - 内存压力 :操作系统通知运行时内存紧张(Windows 的
SetMemoryLimit或MemoryLoad通知)。 - 段分配失败:当无法分配新的 segment 时,运行时被迫触发 Gen 2 GC 尝试回收。
- LOH 分配失败:LOH 找不到足够大的空闲块时,触发 Gen 2 GC。
Gen 2 GC 是"全堆回收",需要扫描所有代(Gen 0、Gen 1、Gen 2、LOH),因此代价最高,暂停时间也最长。运行时尽量避免自动触发 Gen 2 GC,而是依赖 budget 机制让 Gen 0 GC 承担主要的回收工作。
6.4 慢速路径的性能影响
虽然慢速路径只在快速路径失败时执行,但它的影响不容忽视:
-
延迟尖峰:一次 AllocSlow 调用可能触发 Gen 0 GC,导致分配延迟从纳秒级飙升到毫秒级。对于延迟敏感的应用(如游戏、高频交易),这种尖峰是不可接受的。
-
线程阻塞:Gen 0 GC 通常是单线程的(STW),触发 GC 的线程会被阻塞直到 GC 完成。在高并发场景下,多个线程可能同时触发 GC,导致短暂的线程阻塞潮。
-
缓存失效: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 有重要影响:
- 碎片隔离:POH 的碎片不会影响 SOH 和 LOH,因为它们是独立的段。
- 内存泄漏风险:POH 不回收,如果大量对象被 pin 住且长期不释放,POH 会持续增长,最终导致内存浪费。
- GC 根扫描:POH 上的对象在 GC 标记阶段需要被扫描,因为它们可能是可达对象的根。
八、分配路径的并发协作
8.1 分配与 GC 的并发
在 .NET Core 的并发 GC 模型中,分配路径和 GC 标记可以并行执行。这是通过以下机制实现的:
-
分配上下文隔离:每个线程有自己的 alloc_context,GC 不会修改其他线程的上下文。
-
GC 标记阶段的并发性:Gen 0 和 Gen 1 的标记可以在应用线程继续分配的同时进行。只有 Gen 2 的标记和清扫阶段需要暂停应用线程。
-
段级锁: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 对分配路径的影响是间接的但重要的:
- 减少 Gen 2 暂停:后台 GC 将 Gen 2 标记工作分摊到应用线程的分配间隙,显著减少了 Gen 2 GC 的暂停时间。
- 分配不受影响:后台 GC 期间,应用线程可以继续走快速路径分配,完全不受影响。
- 资源竞争:后台 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
关键分析点:
- AllocStacks 报告:找出分配最频繁的调用栈,定位"分配热点"。
- GC 事件时间线:观察 GC 触发频率和暂停时间,判断 budget 是否合理。
- Gen 晋升率:如果 Gen 0 → Gen 1 的晋升率过高,说明短命对象过多,可能需要调整对象生命周期。
9.3 常见分配问题模式
-
LOH 碎片 :频繁分配/释放 LOH 对象导致内存碎片,表现为
Heap Size持续增长但可用内存减少。解决方案:复用 LOH 对象、使用对象池。 -
Gen 晋升风暴:大量对象本应是短命的却晋升到了 Gen 1/2,导致 GC 效率下降。解决方案:检查对象生命周期,避免在短命对象中引用长命对象。
-
分配速率突增:某个代码路径突然开始大量分配对象,导致 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 系统中最关键的代码路径,它的设计体现了"快慢分离"的思想:
- 快速路径(99.9%):bump pointer 分配,只需指针加法 + 边界检查,延迟低于 1 纳秒。
- 慢速路径(0.1%):处理预算超限、段耗尽、LOH 分配等场景,可能触发 GC。
- 自适应预算:根据存活率动态调整 Gen 0 预算,无需人工调参。
- LOH 独立路径:空闲列表管理,避免 bump pointer 在大对象场景下的低效。
- POH 隔离:持久对象使用独立堆,避免碎片扩散。
11.2 实践建议
基于对分配路径的理解,以下是针对 C# 开发者的实践建议:
-
避免频繁分配 LOH 对象:LOH 分配比 SOH 慢 3-4 个数量级。如果对象大小接近 85KB 阈值,考虑调整对象布局使其低于阈值。
-
复用对象而非频繁 new :对于高频分配的场景(如游戏循环、网络包处理),使用对象池(
ObjectPool<T>)或数组预分配。 -
关注 Gen 晋升率 :高晋升率意味着短命对象占用了更多代空间,增加了 GC 负担。使用
GC.GetGeneration(obj)检查对象代际。 -
理解 budget 自适应 :不要手动调用
GC.Collect()。.NET 的 budget 自适应机制比手动调优更可靠。 -
监控分配速率 :使用
GC.GetTotalMemory(false)和性能计数器监控分配速率,发现异常突增。 -
注意 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 的内存模型保证了分配路径的正确性,同时避免了不必要的内存屏障。关键原则是:
- alloc_context 的线程局部性 :每个线程只访问自己的
alloc_context,不存在跨线程共享,因此不需要内存屏障。 - GC 对分配的可见性:当 GC 扫描堆时,它保证能看到所有已完成的分配。这是通过 GC 根扫描和安全点机制保证的------在安全点,所有活跃的对象引用都是可见的。
- 对象初始化的可见性:对象字段在分配后由托管代码写入,JIT 和运行时保证这些写入在 GC 标记之前对其他线程可见。
具体来说,.NET 的内存模型遵循 C++11 的内存模型,使用 acquire-release 语义。分配操作本身是 release 语义(确保对象字写的写入在分配之前完成),GC 根扫描是 acquire 语义(确保能看到所有已写入的对象字段)。
12.3 对齐与填充
.NET 对象的内存对齐有严格的要求:
- 对象对齐:所有托管对象必须按 8 字节对齐(64 位平台)或 4 字节对齐(32 位平台)。这是由 GC 的指针压缩和扫描算法决定的。
- 字段对齐 :结构体字段按其自然对齐要求排列。例如,
long字段必须按 8 字节对齐,int字段按 4 字节对齐。 - 对象填充 :对象的大小会自动向上取整到 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 代码层面的优化
除了环境配置,代码层面的优化同样重要:
- 对象池化 :对于高频分配的对象,使用
ArrayPool<T>或ObjectPool<T>复用对象。 - 结构体替代类 :对于不需要虚方法、不需要作为接口实例的对象,使用
struct而非class。结构体在栈上分配(或内嵌在父对象中),不经过 GC 堆。 - Span 避免分配 :使用
Span<T>和Memory<T>可以在已有内存上创建视图,避免额外的数组分配。 - 预分配数组 :对于已知大小的集合,在创建
List<T>或数组时指定初始容量,避免扩容时的重复分配。
13.3 架构层面的考量
在架构层面,分配路径的性能影响需要考虑:
- 无状态设计:无状态服务通常有更高的分配速率(每个请求创建新的上下文对象),但生命周期短,GC 压力小。有状态服务分配速率低,但对象生命周期长,GC 压力集中在长命对象。
- 连接池与对象池:合理连接池和对象池可以显著降低分配速率,减少 GC 频率。
- 异步编程 :
async/await生成的状态机会创建临时对象。过度使用异步可能导致分配速率上升,需要权衡。
十四、结语
分配路径是 .NET Core GC 的基石,它的设计哲学可以用三个词概括:简单、快速、自适应。简单体现在 bump pointer 分配的直观性------只需一条加法指令和一条比较指令;快速体现在 99.9% 的分配在纳秒级完成;自适应体现在 budget 机制根据运行时数据自动调优 GC 频率。
理解分配路径不仅有助于写出更高效的 C# 代码,也为理解 GC 的其他组件(标记、清扫、压缩)奠定了基础。因为所有 GC 优化技术的最终目标都是同一个------让分配尽可能留在快速路径上,让 GC 的代价尽可能被摊薄到每次分配中。
在下一章中,我们将深入标记与扫描(07-03),看看 GC 如何在分配路径建立的对象关系图上,高效地找出哪些对象还"活着"。