托管 GC 机制:垃圾回收如何影响数据结构选择
定位:从分配、生命周期、可达图和峰值出发,选择集合与缓存;GC 内部算法详见 01-07
CoreCLR 基线:C# 12、.NET 8、dotnet/runtime v8.0.0
Unity 基线:Unity 2022.3 LTS;Mono、IL2CPP、托管 GC 与平台原生资源分别讨论
一、GC 优化的对象不是new,而是整张存活图
托管对象能否回收取决于能否从 roots 到达,而不是谁创建了它或是否离开花括号。栈/寄存器引用、静态字段和 GC handle 等形成 roots;沿字段与数组元素得到存活图,不可达对象才可回收。
数据结构至少影响四项成本:
- 分配速率:单位时间创建多少托管字节和对象;
- 对象数量与边数:回收时要识别多少对象、扫描多少引用;
- 生命周期分布:垃圾是否很快死亡,还是恰好跨过若干次回收;
- 峰值存活量:扩容、双缓冲、池和缓存是否让新旧存储同时存活。
少new不保证更好:无上限缓存会永久保活对象图,池会保留历史峰值,大数组也可长期持有大量元素对象。
本篇不重复 01-07 的 GC 内部源码,只讨论对象图、所有权、峰值和验证。
二、先区分 allocation、lifetime 与 reachability
2.1 分配量不是存活量
下列循环累计分配可很高,但结果不逃逸时峰值存活量仍可较低:
for (int frame = 0; frame < frames; frame++) { byte[] scratch = new byte[4096]; Consume(scratch); }一次注册却可能借静态事件或字典永久保活对象。应同时看 allocated bytes、回收、存活量、堆大小和 root path,不能仅由分配推断泄漏。
2.2 源码作用域不是生命周期
JIT 优化可改变局部引用活跃范围。using表达资源释放,不保证对象立即 GC;设为null也不替代移除长寿引用。
2.3 容器是对象图的拥有者之一
List<T>.Clear()移除逻辑元素;含引用时需断开相应槽,但后备数组仍被 List 持有,容量不自动缩小。纯值类型没有同样的断引用需要。
容器所有权是领域约定。移出字典后对象仍可被事件、任务或 handle 引用;移除最后强引用也只表示可回收。
三、CoreCLR 的代际模型:重要,但不是 C# 语言规则
3.1 Gen 0、Gen 1、Gen 2 表达回收年龄
.NET 8 CoreCLR 使用分代 GC:新小对象通常年轻,存活后年龄提高,Gen 1 起缓冲作用,Gen 2 容纳长期对象。GC.GetGeneration只用于观察。
“晋升”是年龄/代际变化,不等同每次复制到独立固定堆。移动与否受 pinning 和策略影响;预算与阈值动态调整,不能用固定 KB 表预测。
对集合选择真正有用的结论是:
- 很快死亡的临时对象通常适合年轻代假设;
- 恰好跨过回收又马上死亡的中等寿命对象,会增加复制、扫描或后续代压力;
- 长寿容器反复写入年轻对象,会增加写屏障和 remembered-set/card 扫描工作;
- Gen 2 存活图很大时,即使每帧分配不高,完整回收仍可能昂贵。
这些是趋势,不是固定搬迁协议;CoreCLR 三代图也不能套到 Unity。
3.2 写屏障与 card 的方向不要说反
写屏障标记老对象写入年轻引用所在的 card/区域,使年轻代回收能检查相关老区域。
典型方向是老到新。长寿 Dictionary、池或全局 List 持续接收新对象会产生 barrier 工作,但有屏障不等于性能故障。
card table 是代际引用跟踪机制,不能据此声称它就是 Unity incremental GC 或所有并发 GC 的理论基础。增量、并发、分代是不同维度。
四、SOH、LOH 与 POH:分类取决于对象,不是变量名
4.1 SOH 与 LOH 的阈值边界
在.NET 8.0.0CoreCLR 默认配置中,总对象大小达到通常约 85,000 字节的阈值会进入 LOH;配置、对象头、元素大小和对齐都影响判断:
byte[] a = new byte[84_900]; int[] b = new int[22_000];不能拿“元素个数 85,000”判断;应在固定配置下验证总对象。
LOH 通常随 Gen 2 回收,但不等于普通 Gen 2 布局;通常不随每次回收压缩。不同尺寸大数组增加峰值/碎片风险,复用又可能保留峰值。
4.2 POH 不会自动接收所有被 pin 的对象
.NET 5 起的 Pinned Object Heap 为明确按 pinned 方式分配的对象提供专用区域,例如:
byte[] pinned = GC.AllocateUninitializedArray<byte>(4096, pinned: true);POH 对象不移动。普通对象后来被fixed、pinned handle 或互操作固定,不会自动迁入 POH。
POH 仍受 GC 管理,并非永生;长期 pin 和 native 持有期仍会增加压力。Unity 也不能推定具有 CoreCLR POH。
五、数组与 List:一个连续后备存储怎样形成峰值
5.1 数组的优势是少对象和连续槽位
T[]是一个对象。纯值元素内联连续存放;引用元素数组只连续保存引用,元素对象仍分别存在。
Particle[],Particle 为纯值类型 [一个数组对象: P0 | P1 | P2 | ...] Enemy[],Enemy 为 class [数组: ref | ref | ref] → Enemy → 组件对象“数组连续”只描述元素槽。大 struct 复制成本高,含引用 struct 仍需扫描,装箱还会创建对象。
5.2 List 扩容时新旧数组可同时存活
List<T>容量不足时分配更大数组并复制元素;增长策略是实现细节。复制期间新旧数组同时存在,引用元素只复制引用。
已知合理上限时,构造容量或EnsureCapacity可减少扩容次数:
var visible = new List<Entity>(expectedVisibleCount);历史最大值作永久容量也会浪费。Clear复用容量;TrimExcess/调整 Capacity 会分配复制,不应每帧缩容制造抖动。
5.3 场景循环揭示峰值,而非只测一次
场景循环中,全局 List 可在首次高峰扩容后长期保留。应同时记录 Count、Capacity、堆和 root path,再决定在稳定边界缩容或保留。
六、Dictionary:Entry 数组,不是每键一个链表节点
.NET 8Dictionary<TKey,TValue>使用 buckets 与 Entry 数组;Entry 内联 hash/next/key/value 等,冲突链由索引连接,不为每键创建 LinkedListNode。
buckets: [2] [0] [-] │ entries: [hash,next,key,value] [hash,next,key,value] [hash,next,key,value]key/value 若为引用,其目标仍独立存在;含引用 struct 的引用字段内联 Entry,GC 仍需扫描。
扩容创建新数组并重建桶,形成峰值;过度预估又放大容量。Clear通常保留容量;TrimExcess应用于低频稳定边界,旧 Unity profile 未必相同。
比较器若每次创建规范化字符串或闭包也会分配;应复用语义正确的 comparer,并用真实键测量。
七、LinkedList 与节点图:修改便宜不等于系统便宜
LinkedList<T>的每个元素通常对应一个LinkedListNode<T>对象,节点含前后引用、所属列表和元素。与List<T>相比,它具有:
- 更多对象头和引用边;
- 节点分散,遍历局部性较弱;
- 大量插入时产生大量节点分配;
- 若已持有节点,局部插入/删除无需移动后续元素。
最后一项只有在算法确实拥有节点位置时才构成优势。若每次先线性查找目标,O(1) 删除不能挽回 O(n) 定位成本。节点被外部缓存时还可能让已从主结构移除的数据继续可达。
链表适合稳定节点身份、频繁拼接或已知节点的场景,不适合仅因为“删除不复制”就替代遍历密集的 List。队列、双端队列或自由列表也应比较环形数组、分块结构与节点池的峰值,不要只比单次操作复杂度。
八、不可变集合:结构共享改变分配形状,不消灭分配
不可变集合通过路径复制和结构共享保留旧版本。一次更新不必复制整棵树,但会创建从根到修改位置的一组新节点;只要旧版本仍可达,旧根与其独有分支就不能回收。
这对并发快照、撤销、配置版本和函数式状态很有价值,却也有两类风险:
- 高频逐元素构建产生许多中间版本;应使用 builder 或批量 API,再冻结结果;
- 历史队列、闭包或订阅者保留旧根,结构共享使大部分旧图继续存活。
评估不可变集合时要记录“同时活跃多少版本”,而不仅是最终元素数。它减少锁与全量复制的价值可能远大于 GC 成本,也可能在每帧状态更新中形成节点洪峰;只能由场景决定。
九、foreach 与 LINQ:看静态类型和具体操作,不下“一律分配”结论
对数组和List<T>的直接foreach,枚举器通常是 struct;在常见优化构建中可以不产生每次枚举器堆分配。通过接口枚举、发生装箱、闭包捕获或某些异步迭代时,形状可能不同。
LINQ 也不是一个统一分配开关。许多System.Linq运算会创建迭代器对象,捕获 lambda 可能创建闭包,ToList/ToArray必然物化存储;但无捕获委托可能复用,运行时与库的新优化也会改变具体分配。以下写法需测量其整条链,而不能仅凭出现foreach或Where定罪:
int total = enemies .Where(static e => e.IsAlive) .Sum(static e => e.Score);热路径若确有分配压力,可以改为直接循环、复用缓冲或专用查询;但需要性质测试确保过滤顺序、溢出、异常和延迟执行语义未被改坏。Unity 的 Burst、Jobs、NativeArray 又属于不同编译与内存体系,不能由普通托管 LINQ 结论推断。
十、池化与缓存:所有权比 API 名称更重要
10.1 ArrayPool 不是启动时预分配好的固定数组仓库
ArrayPool<T>.Shared.Rent(minimumLength)返回长度至少为请求值的数组,可能复用,也可能在池中无合适数组时新分配;长度、桶策略和保留数量是实现细节。它不是“预先固定分配,因此永不 new”。
byte[] rented = ArrayPool<byte>.Shared.Rent(required); try { Use(rented.AsSpan(0, required)); } finally { ArrayPool<byte>.Shared.Return(rented, clearArray: true); }租用者必须只使用需要的切片,不能把数组实际长度当业务数据长度;归还后不得继续访问,也不能归还两次。含秘密或引用的数据需制定清理策略,clearArray: true有清零成本。若把 rented 数组交给异步任务、native API 或 GPU,归还时机必须晚于所有消费者完成。
池可能丢弃归还数组,也可能保留它;应用不能依赖池永久保存或固定上限。极端尺寸进入共享池还可能扩大进程峰值,专用有界池有时更可控。
10.2 对象池需要重置协议
对象池降低创建频率,却把对象生命周期从一次请求延长为池的生命周期。归还前需断开事件、Task、父子对象和大型缓冲引用,重置状态并防止重复归还。泄漏一个租约会逐步耗尽池;过早归还会让两个调用者同时修改同一实例。
缓存则有意保持对象可达,必须同时定义容量、过期、淘汰、并发和失败策略。弱引用缓存只表示对象不被该弱引用强制保活,不保证命中,也不替代容量治理。选择池/缓存前先写清所有权状态机:谁租、谁可转移、谁归还、何时清理、异常和取消由谁兜底。
十一、pin、finalizer 与 weak reference 的集合影响
11.1 pinning 是地址稳定契约
pinning 常用于本机调用期间固定托管缓冲。短期、局部 pin 通常比长期把大量普通堆对象钉住更容易管理;后者会限制压缩并制造空洞。频繁 I/O 可评估 pinned buffer、POH 或 native memory,但要比较常驻量和跨边界复制,不能只追求“零拷贝”。
11.2 finalizer 延长回收路径
拥有本机资源的类型应实现可靠的 dispose 模式,常用SafeHandle管理句柄。带 finalizer 的不可达对象需要进入终结流程,资源释放时机不确定;若容器大量囤积此类对象,峰值可能在终结线程追赶期间扩大。GC.Collect与WaitForPendingFinalizers不应成为普通帧循环的清理代码。
11.3 WeakReference 不是自动缓存算法
弱引用允许目标在没有其他强引用时被回收。读取后必须立即取得强引用并处理目标已消失的情况;GC 压力变化会改变命中率。ConditionalWeakTable用于键生命周期关联,也不是通用 LRU。缓存命中有业务要求时,应采用明确容量和淘汰策略。
十二、Unity 2022.3:后端与回收器是两条轴
12.1 不从 CoreCLR 代际模型外推 Unity
Unity 2022.3 文档中的托管 GC 以 Unity 集成的 Boehm-Demers-Weiser 系谱实现及增量模式为重要基线,通常按非分代、非移动模型理解和验证。但这是 Unity 版本/平台的事实边界,不是 C# 规则,也不是“Mono 天生只能使用 Boehm”。上游 Mono 历史上和现代版本存在不同 GC、解释器、JIT/AOT 配置。
不能凭空推断 Unity 当初“为何选择 Boehm”是因为某个单一性能或兼容动机;除非引用相应设计记录,只描述可验证行为及其后果:CoreCLR 的 Gen 0/1/2、LOH、POH 与晋升建议不能原样套入 Unity Player。
12.2 IL2CPP 不等于一种固定 GC 分类
IL2CPP 负责把 IL 转换为 C++ 并结合平台工具链生成 native code;垃圾回收由 Unity 的运行时集成和目标平台配置协作。不能从“使用 IL2CPP”独自推出一个跨所有 Unity 版本与平台永久固定的 collector 分类。
Mono Scripting Backend 与 IL2CPP 后端可能在相同 Unity 基线下呈现类似托管 GC 约束,也可能因代码生成、平台和配置而出现不同分配与暂停形状。应固定 Editor 完整版本、目标 Player、Scripting Backend、incremental GC、架构和脚本编译选项分别测量。
12.3 incremental GC 解决切片,不消灭工作
增量回收把部分工作分摊到多个时间片,以降低单次暂停,但总工作量和对象图仍存在。写屏障、时间预算与 fallback 条件会影响帧分布;如果分配速率长期超过回收进度,仍会出现堆增长或更重回收。目标是稳定帧时间和受控峰值,而不是看到“Incremental”勾选就停止分析。
十三、Unity 的 managed、native 与 GPU 内存边界
Unity 对象经常横跨多种资源域:一个 C#Texture2D引用是托管 wrapper,背后可有引擎 native 对象和 GPU 资源。托管 wrapper 不可达,不保证 native/GPU 资源在期望帧立即释放;具体资源应按 Unity 生命周期 API、场景卸载、Addressables/AssetBundle 引用计数和平台规则管理。
UnityEngine.Object还有自定义空值语义:native 对象销毁后,托管 wrapper 可能暂时存在。于是“Profiler 看到 managed 较小”不能证明纹理、网格或 RenderTexture 已释放;反之,系统进程内存上升也不能全归咎于托管 GC。
NativeArray<T>、Jobs/Burst 分配器、UnsafeUtility、原生插件内存和 GPU buffer 不由普通托管 GC 自动释放。它们通常要求显式Dispose或对应引擎 API。数据结构决策应分别记录:
- managed heap 与 managed allocation;
- Unity native object 与 native allocation;
- graphics driver/GPU residency;
- 池、AssetBundle、Addressables 和静态缓存的所有权。
只有这样,场景卸载后“内存没降”才有可定位的含义。
十四、用同一个场景循环比较数据结构
不要用一次插入微基准替代生命周期实验。建立可重复场景:
预热 → 加载 N 个实体 → 更新 M 帧 → 删除 80% → Clear/归还池 → 卸载场景 → 空闲若干帧 → 再执行 5~20 轮为数组/List、Dictionary、LinkedList、不可变集合和池化版本保持相同数据与语义,记录:
- 每轮 allocated bytes 与分配对象数;
- 峰值 managed heap、collection 次数和暂停分布;
- 卸载稳定点的存活量与容器 Capacity;
- CPU 遍历/更新时间和缓存行为;
- Unity 下另记 native/GPU、Player 帧时间与目标设备内存。
比较必须包含冷启动与稳态、正常峰值与异常峰值。池化版本首轮可能分配更多、后续较少;若只截取稳态会隐藏暖池成本,若只测首轮又会否定复用收益。
十五、.NET 8 的 EventPipe、SOS 与堆证据
15.1 EventPipe 先回答“何时发生”
使用与目标 SDK 匹配的dotnet-counters观察分配率、堆大小和 collection 趋势,用dotnet-trace收集 GC 事件时间线。具体 counter/provider 名称以工具版本帮助为准:
dotnet-counters monitor --process-id <PID> dotnet-trace collect --process-id <PID>给场景循环加入EventSource、日志或 trace marker,才能把峰值与“加载、Clear、Trim、卸载”对齐。一次 Gen 2 事件不能单独证明某个 List 是根因。
15.2 SOS/堆转储回答“谁还活着”
在可接受停顿的诊断环境获取 dump,用 SOS/调试器查看堆统计、对象实例和 root path。调查顺序是:哪种类型占用大、哪些实例来自目标场景、谁把它们保持可达、容器后备数组容量多大。不要在生产高峰随意抓全堆转储,也不要把 dump 中的对象数等同一段时间内的累计分配数。
需要比较前后快照时,确保在同一场景稳定点、同一构建与相近 GC 状态采样。显式GC.Collect可以作为受控诊断实验的一组变量,但不应偷换为生产解决方案。
十六、Unity Profiler 的目标 Player 实验
在 Unity 2022.3 建立 Development Player,固定目标平台和 Mono/IL2CPP 后端,使用 Profiler 的 CPU/GC Alloc 与 Memory 模块观察,再按需获取 Memory Profiler 快照。Editor 本身、编辑器插件和域重载会引入噪声,最终结论必须来自 Player。
建议做四组受控差分:
List<T>默认增长与合理预容量;List<T>.Clear复用与每轮新建,以及只在场景边界缩容;- 数组/Dictionary 与节点结构在相同查询负载下的 managed 对象图;
- 普通分配与有界池,包含暖池、异常取消、归还和场景卸载。
对 IL2CPP build 保存 stripping、C++ 编译与 Player 日志;对资源场景同时记录 Unity native 和 graphics 指标。Profiler 中单帧GC.Alloc为零只说明该采样范围没有观测到对应托管分配,不代表没有 native 分配、没有存活图扫描,也不保证未来帧不回收。
十七、从需求到结构的决策树
元素数量是否有可信上限? ├─ 有:数组或预容量 List;检查总对象大小、峰值与是否含引用 └─ 无:采用可增长结构,同时设业务上限并观察扩容峰值 是否需要按 key 高频查询? ├─ 是:Dictionary;预估容量,检查 comparer、键生命周期与 Clear/Trim └─ 否:优先比较数组/List 的遍历与内存局部性 是否已持有节点且需要频繁局部拼接? ├─ 是:评估 LinkedList/节点结构,并测对象数和池所有权 └─ 否:不要只为 O(1) 删除牺牲定位与遍历 是否需要并发快照或保留旧版本? ├─ 是:评估不可变集合、builder 与同时活跃版本数 └─ 否:可变连续结构通常对象图更简单 临时缓冲是否显著影响 profile? ├─ 是:评估 ArrayPool/专用池,先定义租约、清理、上限与异步归还 └─ 否:保持简单分配,避免池把短生命周期变成长生命周期 是否跨 native/GPU 边界? ├─ 是:单独设计 Dispose/引擎释放、pin 与完成信号 └─ 否:仍检查事件、静态缓存和任务是否保活决策树给出提问顺序,不替代 profile。复杂度、局部性、线程安全和业务语义仍与 GC 指标同等重要。
十八、代码审查清单
- 容器持有什么:内联值、引用槽,还是每元素节点对象?
- 是否同时测量分配率、对象数、存活量、引用边和峰值?
- 生命周期来自实际 root path,还是仅凭源码作用域猜测?
- 是否把 CoreCLR 代际、SOH/LOH/POH 结论误套到 Unity?
- LOH 判断是否基于总对象大小与目标配置,而不是数组元素数?
- 是否误以为普通 pinned 对象自动搬到 POH,或所有存活对象都固定物理搬代?
- List/Dictionary 扩容时是否考虑新旧数组并存,Clear 后是否仍保留 Capacity?
- 是否正确说明 Dictionary 使用 Entry 数组,而不是每键一个节点对象?
- 不可变集合是否保留过多旧根,构建过程是否使用 builder/批量 API?
- 是否依据实际静态类型、闭包和操作测量 foreach/LINQ,而非声称一律分配?
- ArrayPool 是否允许新分配和更大数组,租约是否覆盖异步/native/GPU 使用期?
- 对象池和缓存是否有上限、清理、淘汰、异常取消与重复归还保护?
- finalizer、weak reference 和 pinning 是否承担了它们真正适合的职责?
- Unity 是否固定 2022.3 完整版本、目标 Player、后端、增量 GC 和平台?
- managed、Unity native、插件 native 与 GPU 内存是否分别采集?
- 实验是否覆盖多轮场景循环、暖池、异常峰值和卸载稳定点?
结语
GC 对数据结构选择的影响,最终归结为对象图与时间:创建多少对象,谁引用谁,它们共同存活多久,扩容与缓存把峰值推到哪里。数组和 List 用连续存储换取较少对象,Dictionary 用 buckets 与 Entry 数组换取键查询,LinkedList 以节点对象换取已知位置的局部修改,不可变集合用结构共享换取版本语义,池则用更长生命周期换取更低重复分配。
在 .NET 8 CoreCLR 中,可以借助代际、LOH、POH 与 card 模型解释证据;在 Unity 2022.3 中,必须重新固定 Player、后端、增量 GC 和资源域,不能照搬 CoreCLR 图。先写清所有权和场景循环,再用 EventPipe/SOS 或 Unity Profiler 验证,数据结构选择才会从“少 new 的经验法则”升级为可复现的工程决策。
延伸阅读:01-07《GC 深度剖析:内存分配、回收与结构选择》