GC 深度剖析:内存分配、回收与你的数据结构选择
系列 :C#与常用数据结构源码剖析 · 运行时底层剖析
阅读时间 :约 50 分钟
前置知识:值类型与引用类型、托管堆概念
一、引言
GC 是 .NET 最成功的特性之一------它让程序员从手动内存管理中解放出来。但"自动"不等于"无代价"。每一次 new、每一次装箱、每一次委托创建,都在向 GC"欠债"。当 GC 运行时,它会暂停所有线程(Stop-The-World),逐代扫描堆上的对象,标记存活者,释放死亡者,压缩碎片。
数据结构的设计直接决定了 GC 的工作量。一个 List<int> 的内部数组扩容会产生垃圾(旧数组),一个 Dictionary<string, ...> 的每次查找都调用 GetHashCode(但不分配),一个 foreach 在 IL2CPP 下可能每次迭代都装箱(严重的 GC 陷阱)。
理解 GC 的三代回收机制 、LOH 碎片化 、写屏障的代价 、以及固定对象的破坏力,是设计 GC 友好数据结构的基础。
二、GC 的全局架构
2.1 堆段管理
.NET GC 管理的内存不是一整块,而是由多个**堆段(Heap Segment)**组成:
- Ephemeral Segment:容纳 Gen0、Gen1、和(可能的)Gen2 对象。大小约为 256MB(Server GC)
- Gen2 Segments:额外的段用于溢出的大 Gen2 区域
- LOH Segments:大对象堆的专用段
- POH Segments:固定对象堆的专用段(.NET 5+)
每个堆的 GC 在段内使用**指针碰撞(Bump Pointer)**方式分配对象------极其高效,相当于一个指针自增操作。
2.2 分配上下文
为了减少多线程竞争,每个线程拥有自己的分配上下文(Allocation Context)------一段 8KB 大小的预分配区域。线程在自己的上下文内进行指针碰撞分配,无需锁。上下文消耗完后,线程从 GC 申请新的上下文。
这个设计使得简单的对象创建(new MyClass())几乎无锁、接近零开销。
三、三代回收机制
3.1 为什么需要分代
弱分代假说(Weak Generational Hypothesis):
- 大多数对象是短命的(局部变量、临时计算结果)
- 少数对象是长命的(静态字段、缓存、单例)
- 老对象很少引用新对象
基于这个假说,GC 将对象分为三代:
| 代 | 对象特征 | 回收频率 | 回收成本 | 回收方式 |
|---|---|---|---|---|
| Gen0 | 新建的小对象 | 最频繁(~1ms) | 低 | 触发阈值很低 |
| Gen1 | Gen0 幸存者 | 中等 | 中 | 缓冲区角色 |
| Gen2 | 老对象 + LOH | 最少(~10-50ms) | 高 | 全堆扫描 |
3.2 晋升(Promotion)
当一个对象在 GC 回收中存活下来:
- Gen0 幸存者 → 晋升到 Gen1
- Gen1 幸存者 → 晋升到 Gen2
- Gen2 幸存者 → 留在 Gen2
晋升不是免费的------对象需要被物理移动到更老的堆空间(除非被固定)。但晋升有一个巨大的好处:Gen0 回收只需要扫描 Gen0 对象,Gen1 和 Gen2 完全不被触碰。
3.3 代际回收的数据结构启示
- 短命的临时对象(如
new List<int>()在方法局部)在 Gen0 回收中就被清理------代价低 - 长期缓存的对象(如静态
Dictionary)最终晋升到 Gen2------每次 Gen0 回收都不会触及它 - 频繁创建中等寿命的对象(如每帧创建的
List<T>在 Update 中)最危险------它们从 Gen0 晋升到 Gen2,占用 Gen2 空间,导致 Gen2 回收更频繁
设计原则 :要么让对象极短命 (局部作用域内),要么让它极长命(静态/缓存/池化)。中间寿命的对象是 GC 压力的主要来源。
四、LOH(大对象堆)
4.1 什么是大对象
阈值:85,000 字节(约 21,250 个 int,或 10,625 个 long)。超过此阈值的对象分配在 LOH。
典型 LOH 分配:
byte[100000]--- 100KB 数组string长度超过 ~42,500 字符List<T>扩容时内部数组 > 85000 字节
4.2 LOH 的特性
| 特性 | SOH(小对象堆) | LOH(大对象堆) |
|---|---|---|
| 初始代 | Gen0 | Gen2 |
| 压缩 | 自动压缩 | 默认不压缩(.NET 4.5.1+ 可选) |
| 碎片化 | 几乎无 | 严重(不压缩的后果) |
| 分配方式 | 指针碰撞 | 自由列表(类似 malloc) |
| GC 模式 | Gen0/1/2 background GC | 仅在 Gen2 回收时处理 |
4.3 LOH 碎片化的影响
LOH 不压缩意味着释放的大对象留下的"空洞"无法被填充(除非恰好有合适大小的新对象)。随着时间推移,LOH 可能产生大量碎片------空闲内存总量足够,但无法分配大数组。
解决方案:
- 周期性强制 LOH 压缩:
GCSettings.LargeObjectHeapCompactionMode - 避免频繁创建和释放大数组------用
ArrayPool<T>替代
五、写屏障(Write Barrier)
5.1 跨代引用的追踪挑战
Gen0 回收只需要扫描 Gen0 对象,但有一个问题:Gen2 对象可能引用 Gen0 对象。如果不扫描 Gen2,就无法发现这些引用。
GC 的解决方案是写屏障 ------当代码执行 obj.field = ref(引用字段赋值)时,JIT 在赋值指令后插入一小段代码:
mov [rdi+8], rsi ; 赋值
cmp [card_table], 0 ; 检查卡表
jne mark_card ; 标记卡片
这段代码将引用字段所在的内存页标记在卡表(Card Table)中。GC 回收 Gen0 时,只需扫描卡表中标记的内存页,而不是整个 Gen2。
5.2 写屏障的性能代价
写屏障的代价很低(1-2 个 CPU 周期),但当大量引用赋值发生时(如 List<object>.Add() 每次追加一个引用类型元素),累积的写屏障开销不可忽视。
这也是为什么值类型数组 (int[]、struct[])没有写屏障开销------值类型不包含引用。
六、固定对象(Pinned Object)
6.1 固定对象如何阻碍 GC
fixed 关键字和 GCHandle.Alloc(obj, GCHandleType.Pinned) 的作用是防止 GC 移动对象。这在与非托管代码交互(P/Invoke 传指针)时是必须的。
但固定对象是 GC 的噩梦:GC 在压缩阶段需要移动对象来消除碎片,但固定对象不能被移动------它就像一个钉子,把周围的对象也"钉住"了。
6.2 对数据结构的启示
- 避免长期持有固定对象------用
stackalloc+Span<T>替代短期的固定需求 - 大数组如果被固定,会导致 LOH 碎片化加剧
ArrayPool<T>返回的数组不应被固定(或固定后及时释放)
七、数据结构设计的 GC 友好原则
7.1 减少分配
| 替代方案 | 示例 |
|---|---|
| struct 替代 class | struct Point 替代 class Point |
| 对象池替代 new | ArrayPool<T>.Shared.Rent() 替代 new T[] |
| StringBuilder 替代 string 拼接 | 避免产生中间字符串 |
stackalloc + Span 替代数组 |
短期使用的临时数据 |
7.2 控制生命周期
- 局部作用域内创建 → Gen0 回收 → 代价低
- 静态字段引用 → 长期存活 → Gen2 → 不参与 Gen0 回收
- Update 中每帧
new List<T>→ 快速晋升 Gen2 → 最差模式!
7.3 预分配容量
// ❌ 每次 Add 都可能扩容(产生垃圾旧数组)
var list = new List<int>();
for (int i = 0; i < 10000; i++) list.Add(i);
// ✅ 一次分配,无扩容垃圾
var list = new List<int>(10000);
for (int i = 0; i < 10000; i++) list.Add(i);
7.4 使用 Span<T> 避免子数组
// ❌ 产生新数组和 GC 压力
int[] slice = new int[100];
Array.Copy(source, offset, slice, 0, 100);
// ✅ 零分配
Span<int> slice = source.AsSpan(offset, 100);
八、Unity 中的 GC 特殊考量
8.1 Boehm GC vs CoreCLR GC
Unity 编辑器默认使用Boehm GC(保守式):
- 不分代------每次回收扫描整个堆
- 不压缩------碎片化严重
- 保守式扫描------可能误判非指针为指针,不释放
这意味着在 Unity 编辑器中,GC 的行为比 .NET Core 差很多。IL2CPP 构建使用的是一个轻量级的分代 GC,行为接近 CoreCLR GC 但功能更少。
8.2 Unity 的 Incremental GC
Unity 2019+ 引入了增量 GC------将一次完整的 GC 拆分到多帧执行,每帧只做一小部分工作。这减少了单帧的 GC 暂停时间,但增加了总 GC 时长。
对于数据结构的意义:即使每次分配量不大,积累的 GC 工作量也会在增量模式下跨越多帧,拖累整体性能。
九、总结
GC 的自动内存管理是 .NET 的最大生产力优势,但也是性能的潜在瓶颈。数据结构设计的 GC 友好原则可以总结为:
- 减少分配:用 struct、对象池、stackalloc 替代堆分配
- 控制生命周期:极短命或极长命,避免中间寿命
- 预分配容量:消除扩容产生的垃圾
- 避免 LOH 碎片:大数组用 ArrayPool 复用
- 小心固定对象:及时释放 pinned handle
- Unity 特殊注意:Boehm GC 不分代,每次分配都沉重
下一篇 :虚方法分派与接口调用的底层实现