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) 触发阈值很低
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 友好原则可以总结为:

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

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

相关推荐
DAVIED93 小时前
llama.cpp 本地部署完全指南
windows·llama·wsl·llamacpp
2602_959960924 小时前
电商大厂Java面试:从Spring Boot、JPA、微服务到Redis、Kafka、Spring Security与监控,谢飞机的爆笑三轮问答
java·jvm·spring boot·redis·面试题
优梦创客4 小时前
游戏开发架构选型:第1篇|什么是架构?从进球事件看 MVC 的局限
unity·架构·游戏引擎·游戏开发
一水4 小时前
AI 时代审查思维:审查第一篇
java·jvm·数据库·spring
云上飞476369625 小时前
Windows WSL2 + Docker 环境下NVIDIA PhysicsNeMo 安装指南
windows·docker·physicsai·physicsnemo
weixin_6685 小时前
取消Windows 11 默认精简的鼠标右键文件夹菜单
windows·计算机外设
love530love5 小时前
彻底清理 Windows 右键“打开方式“中的重复/失效程序项(PyCharm 多版本残留实战 + 自动化脚本)
运维·人工智能·windows·pycharm·jetbrains·toolbox
それども12 小时前
JVM MetaspaceSize 参数作用
jvm
王维同学17 小时前
进程模块枚举、映像身份与线程启动地址关联
c++·windows·安全