尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

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

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