02-06-历史-托管GC机制-垃圾回收如何影响数据结构选择
托管 GC 机制垃圾回收如何影响数据结构选择定位从分配、生命周期、可达图和峰值出发选择集合与缓存GC 内部算法详见 01-07CoreCLR 基线C# 12、.NET 8、dotnet/runtime v8.0.0Unity 基线Unity 2022.3 LTSMono、IL2CPP、托管 GC 与平台原生资源分别讨论一、GC 优化的对象不是new而是整张存活图托管对象能否回收取决于能否从 roots 到达而不是谁创建了它或是否离开花括号。栈/寄存器引用、静态字段和 GC handle 等形成 roots沿字段与数组元素得到存活图不可达对象才可回收。数据结构至少影响四项成本分配速率单位时间创建多少托管字节和对象对象数量与边数回收时要识别多少对象、扫描多少引用生命周期分布垃圾是否很快死亡还是恰好跨过若干次回收峰值存活量扩容、双缓冲、池和缓存是否让新旧存储同时存活。少new不保证更好无上限缓存会永久保活对象图池会保留历史峰值大数组也可长期持有大量元素对象。本篇不重复 01-07 的 GC 内部源码只讨论对象图、所有权、峰值和验证。二、先区分 allocation、lifetime 与 reachability2.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 容器是对象图的拥有者之一ListT.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.AllocateUninitializedArraybyte(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 扩容时新旧数组可同时存活ListT容量不足时分配更大数组并复制元素增长策略是实现细节。复制期间新旧数组同时存在引用元素只复制引用。已知合理上限时构造容量或EnsureCapacity可减少扩容次数var visible new ListEntity(expectedVisibleCount);历史最大值作永久容量也会浪费。Clear复用容量TrimExcess/调整 Capacity 会分配复制不应每帧缩容制造抖动。5.3 场景循环揭示峰值而非只测一次场景循环中全局 List 可在首次高峰扩容后长期保留。应同时记录 Count、Capacity、堆和 root path再决定在稳定边界缩容或保留。六、DictionaryEntry 数组不是每键一个链表节点.NET 8DictionaryTKey,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 的引用字段内联 EntryGC 仍需扫描。扩容创建新数组并重建桶形成峰值过度预估又放大容量。Clear通常保留容量TrimExcess应用于低频稳定边界旧 Unity profile 未必相同。比较器若每次创建规范化字符串或闭包也会分配应复用语义正确的 comparer并用真实键测量。七、LinkedList 与节点图修改便宜不等于系统便宜LinkedListT的每个元素通常对应一个LinkedListNodeT对象节点含前后引用、所属列表和元素。与ListT相比它具有更多对象头和引用边节点分散遍历局部性较弱大量插入时产生大量节点分配若已持有节点局部插入/删除无需移动后续元素。最后一项只有在算法确实拥有节点位置时才构成优势。若每次先线性查找目标O(1) 删除不能挽回 O(n) 定位成本。节点被外部缓存时还可能让已从主结构移除的数据继续可达。链表适合稳定节点身份、频繁拼接或已知节点的场景不适合仅因为“删除不复制”就替代遍历密集的 List。队列、双端队列或自由列表也应比较环形数组、分块结构与节点池的峰值不要只比单次操作复杂度。八、不可变集合结构共享改变分配形状不消灭分配不可变集合通过路径复制和结构共享保留旧版本。一次更新不必复制整棵树但会创建从根到修改位置的一组新节点只要旧版本仍可达旧根与其独有分支就不能回收。这对并发快照、撤销、配置版本和函数式状态很有价值却也有两类风险高频逐元素构建产生许多中间版本应使用 builder 或批量 API再冻结结果历史队列、闭包或订阅者保留旧根结构共享使大部分旧图继续存活。评估不可变集合时要记录“同时活跃多少版本”而不仅是最终元素数。它减少锁与全量复制的价值可能远大于 GC 成本也可能在每帧状态更新中形成节点洪峰只能由场景决定。九、foreach 与 LINQ看静态类型和具体操作不下“一律分配”结论对数组和ListT的直接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 不是启动时预分配好的固定数组仓库ArrayPoolT.Shared.Rent(minimumLength)返回长度至少为请求值的数组可能复用也可能在池中无合适数组时新分配长度、桶策略和保留数量是实现细节。它不是“预先固定分配因此永不 new”。byte[] rented ArrayPoolbyte.Shared.Rent(required); try { Use(rented.AsSpan(0, required)); } finally { ArrayPoolbyte.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 代际模型外推 UnityUnity 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。NativeArrayT、Jobs/Burst 分配器、UnsafeUtility、原生插件内存和 GPU buffer 不由普通托管 GC 自动释放。它们通常要求显式Dispose或对应引擎 API。数据结构决策应分别记录managed heap 与 managed allocationUnity native object 与 native allocationgraphics driver/GPU residency池、AssetBundle、Addressables 和静态缓存的所有权。只有这样场景卸载后“内存没降”才有可定位的含义。十四、用同一个场景循环比较数据结构不要用一次插入微基准替代生命周期实验。建立可重复场景预热 → 加载 N 个实体 → 更新 M 帧 → 删除 80% → Clear/归还池 → 卸载场景 → 空闲若干帧 → 再执行 5~20 轮为数组/List、Dictionary、LinkedList、不可变集合和池化版本保持相同数据与语义记录每轮 allocated bytes 与分配对象数峰值 managed heap、collection 次数和暂停分布卸载稳定点的存活量与容器 CapacityCPU 遍历/更新时间和缓存行为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。建议做四组受控差分ListT默认增长与合理预容量ListT.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 结论误套到 UnityLOH 判断是否基于总对象大小与目标配置而不是数组元素数是否误以为普通 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 深度剖析内存分配、回收与结构选择》