01-07-运行时-GC深度剖析-内存分配回收与结构选择

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)

  1. 大多数对象是短命的(局部变量、临时计算结果)
  2. 少数对象是长命的(静态字段、缓存、单例)
  3. 老对象很少引用新对象

基于这个假说,GC 将对象分为三代:

对象特征回收频率回收成本回收方式
Gen0新建的小对象最频繁(~1ms)触发阈值很低
Gen1Gen0 幸存者中等缓冲区角色
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(大对象堆)
初始代Gen0Gen2
压缩自动压缩默认不压缩(.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 替代 classstruct Point 替代 class Point
对象池替代 newArrayPool<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 友好原则可以总结为:

  1. 减少分配:用 struct、对象池、stackalloc 替代堆分配
  2. 控制生命周期:极短命或极长命,避免中间寿命
  3. 预分配容量:消除扩容产生的垃圾
  4. 避免 LOH 碎片:大数组用 ArrayPool 复用
  5. 小心固定对象:及时释放 pinned handle
  6. Unity 特殊注意:Boehm GC 不分代,每次分配都沉重

下一篇虚方法分派与接口调用的底层实现

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

淡海水

感谢支持 共同进步 好运++

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值