ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

02-02-原理篇-分代GC

02-02-原理篇-分代GC 分代 GCGeneration GC篇章02-原理篇阅读时间约 35 分钟前置知识了解 Mark-Sweep 算法一、引言在上一章中我们分析了 Mark-Sweep、Mark-Compact 和 Copying 三种基础 GC 算法。一个关键结论是Copying 算法在存活率低时效率极高而 Mark-Compact 在存活率高时更合适。那么问题来了——能否让不同区域的对象使用不同的算法各取所长分代 GCGenerational GC正是对这个问题的回答。它基于一个经验观察——代际假说Generational Hypothesis——将堆划分为多个代对每一代使用最适合的回收算法从而大幅提升整体 GC 效率。.NET 的 GC 从一开始就是分代设计而 Unity 的 Boehm GC 则不分代这也是两者性能差异的核心来源之一。二、代际假说Generational Hypothesis代际假说是分代 GC 的理论基础由 David Ungar 在 1984 年提出。它包含两个子假说2.1 弱代际假说Weak Generational Hypothesis绝大多数对象都是朝生夕灭的——它们在创建后很快就会变为垃圾。大量实证研究支持这一假说在 Java 应用中约 80%-98% 的新对象在一次 Minor GC 中就被回收在 .NET 应用中Gen0 的回收频率远高于 Gen2在函数式编程中中间结果对象的存活时间通常仅限于一个表达式2.2 强代际假说Strong Generational Hypothesis越老的对象越不容易死亡——存活过多次 GC 的对象很可能会继续存活。这意味着老年代对象的回收频率远低于新生代对老年代进行全量回收Full GC的代价高且收益低2.3 实证数据以下是一组典型的 .NET 应用对象存活率数据对象年龄分布典型 Web 应用 Gen0第 0 次回收存活率 ~5% ↓ 晋升 Gen1第 1 次回收存活率 ~30% ↓ 晋升 Gen2第 2 次回收存活率 ~80% ↓ 长期驻留// 验证代际假说的实验代码 using System; using System.Diagnostics; class GenerationalHypothesisDemo { static void Main() { var longLived new byte[1024]; // 长期存活对象 var stopwatch Stopwatch.StartNew(); // 模拟大量短生命周期对象 for (int i 0; i 1_000_000; i) { var temp new byte[64]; // 朝生夕灭 // temp 在循环结束后立即变为垃圾 } stopwatch.Stop(); Console.WriteLine($分配 100 万临时对象耗时: {stopwatch.ElapsedMilliseconds} ms); Console.WriteLine($Gen0 回收次数: {GC.CollectionCount(0)}); Console.WriteLine($Gen1 回收次数: {GC.CollectionCount(1)}); Console.WriteLine($Gen2 回收次数: {GC.CollectionCount(2)}); Console.WriteLine($longLived 所在代: {GC.GetGeneration(longLived)}); // 结果Gen0 回收次数远多于 Gen1/Gen2 // longLived 可能已晋升到 Gen1 或 Gen2 } }运行结果通常类似分配 100 万临时对象耗时: 15 ms Gen0 回收次数: 42 Gen1 回收次数: 3 Gen2 回收次数: 0 longLived 所在代: 1可以看到Gen0 回收了 42 次而 Gen2 一次都没回收——这就是代际假说的直接体现。三、新生代与老年代3.1 .NET 的分代结构.NET 的托管堆分为 3 代 LOH┌─────────────────────────────────────────────────────────┐ │ 托管堆 (Managed Heap) │ ├──────────┬──────────┬──────────────────┬───────────────┤ │ Gen 0 │ Gen 1 │ Gen 2 │ LOH │ │ (新生代) │ (Survivor)│ (老年代) │ (大对象堆) │ │ Copying │ Copying │ Mark-Sweep/Compact│ Mark-Sweep │ │ ~几MB │ ~几MB │ 无上限 │ 无上限 │ └──────────┴──────────┴──────────────────┴───────────────┘ ← 分配方向新对象从这里开始3.2 各代的特征特征Gen 0Gen 1Gen 2LOH角色新生代缓冲区老年代大对象算法CopyingCopyingMark-Sweep 可选 CompactMark-Sweep 可选 Compact大小小几 MB小几 MB大无上限大无上限回收频率高中低极低STW 时间极短短长可并发长对象大小 85000 bytes 85000 bytes 85000 bytes≥ 85000 bytes晋升条件存活过 Gen0 GC存活过 Gen1 GC——3.3 分配流程新对象在 .NET 中的分配流程如下分配新对象 (size bytes): if size 85000: → 分配到 LOH else: → 分配到 Gen0 if Gen0 空间不足: → 触发 Gen0 GC → 存活对象晋升到 Gen1 → if Gen1 空间不足: → 触发 Gen1 GC同时回收 Gen0 → 存活对象晋升到 Gen2 → if Gen2 空间不足: → 触发 Full GCGen0 Gen1 Gen2 LOH// 观察对象分配和晋升过程 using System; class AllocationFlowDemo { static void Main() { Console.WriteLine( 初始状态 ); PrintGCStats(); // 分配小对象 → Gen0 var obj1 new byte[1000]; Console.WriteLine(\n 分配 1KB 小对象 ); Console.WriteLine($obj1 所在代: {GC.GetGeneration(obj1)}); // 0 PrintGCStats(); // 大量分配触发 Gen0 GC for (int i 0; i 10000; i) { var temp new byte[1000]; } Console.WriteLine(\n 大量分配后 ); Console.WriteLine($obj1 所在代: {GC.GetGeneration(obj1)}); // 可能晋升到 1 PrintGCStats(); // 分配大对象 → LOH var obj2 new byte[85000]; Console.WriteLine(\n 分配 85KB 大对象 ); Console.WriteLine($obj2 所在代: {GC.GetGeneration(obj2)}); // 2LOH 算作 Gen2 PrintGCStats(); } static void PrintGCStats() { Console.WriteLine($ Gen0 回收: {GC.CollectionCount(0)}); Console.WriteLine($ Gen1 回收: {GC.CollectionCount(1)}); Console.WriteLine($ Gen2 回收: {GC.CollectionCount(2)}); Console.WriteLine($ 堆大小: {GC.GetTotalMemory(false) / 1024} KB); } }四、晋升策略Promotion4.1 晋升机制当一次 GC 回收完成后存活的对象会被晋升到更高的一代Gen0 GC: Gen0 存活对象 → 复制到 Gen1 Gen1 GC包含 Gen0: Gen0 存活对象 → 复制到 Gen1 Gen1 存活对象 → 复制到 Gen2 Gen2 GCFull GC: Gen0 存活对象 → 复制到 Gen1 Gen1 存活对象 → 复制到 Gen2 Gen2 存活对象 → 原地保留Mark-Sweep/Compact4.2 晋升的代价晋升是一把双刃剑好处减少高频 GC 的扫描范围提升回收效率代价老年代不断膨胀Full GC 频率增加// 晋升代价演示 using System; using System.Collections.Generic; class PromotionCostDemo { // 长期存活的对象会不断晋升最终进入 Gen2 static Listbyte[] cache new Listbyte[](); static void Main() { Console.WriteLine( 模拟对象晋升 ); // 持续分配并保留引用迫使对象晋升 for (int i 0; i 5; i) { cache.Add(new byte[4096]); // 保留引用 GC.Collect(0); // 只回收 Gen0 Console.WriteLine($第 {i1} 次 Gen0 GC 后:); Console.WriteLine($ cache[0] 所在代: {GC.GetGeneration(cache[0])}); Console.WriteLine($ Gen0 回收: {GC.CollectionCount(0)}); Console.WriteLine($ Gen1 回收: {GC.CollectionCount(1)}); Console.WriteLine($ Gen2 回收: {GC.CollectionCount(2)}); } // cache[0] 会从 Gen0 → Gen1 → Gen2 逐步晋升 } }4.3 .NET 的特殊晋升规则除了存活过 GC 就晋升的基本规则外.NET 还有一些特殊策略规则说明Gen0 预算动态调整Gen0 的大小根据分配速率动态调整不是固定的Gen1 作为缓冲Gen1 不会频繁 GC避免对象过快晋升到 Gen2大对象直接进 LOH≥ 85000 bytes 的对象直接分配到 LOHLOH 算作 Gen2固定对象不移动Pinned 对象不会被 Copying 算法移动可能影响 Gen0 效率五、Write Barrier写屏障5.1 跨代引用问题分代 GC 面临一个核心挑战跨代引用Cross-Generation Reference。问题场景 Gen2 对象 A 引用 Gen0 对象 B 如果只回收 Gen0GC 从 Gen0 的根开始遍历 → B 没有被 Gen0 内的根引用 → B 被误判为垃圾并回收 → 但 AGen2仍然引用 B → 悬挂指针如果不解决这个问题分代 GC 就无法安全地只回收新生代。5.2 写屏障机制写屏障Write Barrier是解决跨代引用的标准方案。每当发生引用写入操作时GC 运行时会执行额外的记录操作写屏障伪代码 function WriteBarrier(obj, field, newValue): obj.field newValue // 正常写入 // 如果是老年代引用新生代记录这个引用 if obj.generation newValue.generation: rememberSet.add(obj) // 将 obj 加入记忆集记忆集Remembered Set记录了所有包含跨代引用的老年代对象。在回收新生代时GC 不仅从常规根出发遍历还从记忆集中的对象出发遍历确保不会漏掉跨代引用的新生代对象。5.3 .NET 的写屏障实现.NET 使用Card Table卡表来实现写屏障Card Table 结构 托管堆 [对象A] [对象B] [对象C] [对象D] [对象E] ... ↓ ↓ ↓ ↓ ↓ 卡表 [ 0 ] [ 1 ] [ 0 ] [ 1 ] [ 0 ] ... ↑ ↑ 有跨代引用 有跨代引用 每个 Card 对应堆上一个固定大小的区域通常 2KB 当写屏障检测到跨代引用时将对应 Card 标记为 1 Gen0 GC 时扫描所有标记为 1 的 Card 中的对象// 写屏障开销演示 using System; using System.Diagnostics; class WriteBarrierDemo { // Gen2 对象长期存活 class OldGenHolder { public object Reference; // 这个字段写入时会触发写屏障 } static void Main() { // 创建一个 Gen2 对象 var holder new OldGenHolder(); GC.Collect(); GC.Collect(); GC.Collect(); Console.WriteLine($holder 所在代: {GC.GetGeneration(holder)}); // 2 // 测量写屏障开销 var sw Stopwatch.StartNew(); for (int i 0; i 10_000_000; i) { // 每次写入都触发写屏障Gen2 → Gen0 引用 holder.Reference new byte[32]; } sw.Stop(); Console.WriteLine($带写屏障的写入 (Gen2→Gen0): {sw.ElapsedMilliseconds} ms); // 对比不触发写屏障的写入 var localRef new object(); sw.Restart(); for (int i 0; i 10_000_000; i) { localRef new byte[32]; // 栈变量不触发写屏障 } sw.Stop(); Console.WriteLine($无写屏障的写入 (栈变量): {sw.ElapsedMilliseconds} ms); } }5.4 写屏障的性能影响写屏障的代价在于每次引用写入都增加了一次额外操作。在 .NET 中写屏障被编译为极短的 JIT 检查序列通常 3-5 条指令开销很小但不可忽略操作无写屏障有写屏障额外开销字段写入~2 ns~5 ns~3 ns数组写入~3 ns~6 ns~3 ns对于引用密集型代码如链表、树结构写屏障开销会累积。这也是为什么 Unity 的 Boehm GC 不使用分代——它没有写屏障开销但也没有分代带来的效率提升。六、分代 GC 的优势与局限6.1 优势优势说明减少 STW 时间大多数 GC 只回收 Gen0暂停时间极短提升吞吐量新生代用 Copying 算法回收效率高局部性优化Copying 算法重新排列存活对象缓存友好可扩展性堆可以很大但日常 GC 只扫描新生代6.2 局限局限说明写屏障开销每次引用写入都有额外成本Full GC 代价高Gen2 满时触发 Full GC暂停时间长晋升风险短期存活对象如果意外晋升会污染老年代内存占用需要额外的卡表、记忆集等数据结构不适合所有场景对象存活模式不符合代际假说时效率下降6.3 不适合分代的场景// 反代际假说场景所有对象存活时间相近 using System; using System.Collections.Generic; class AntiGenerationalPattern { static void Main() { // 所有对象都长期存活 → 全部晋升到 Gen2 // Gen0 GC 几乎回收不到任何对象 → 分代失去意义 var allObjects new Listbyte[](); for (int i 0; i 10000; i) { allObjects.Add(new byte[1024]); // 全部保留 } GC.Collect(); Console.WriteLine($Gen0 回收: {GC.CollectionCount(0)}); Console.WriteLine($Gen2 回收: {GC.CollectionCount(2)}); // 此时大部分对象在 Gen2Gen0 GC 无效 // 每次都要 Full GC → 分代优势丧失 } }七、不同 GC 的分代设计对比7.1 .NET vs Java vs Unity特性.NET GCJava HotSpot (G1)Unity Boehm分代3 代 LOH多 Region 模拟分代不分代新生代算法CopyingCopying—老年代算法Mark-Sweep/CompactMark-CompactMark-Sweep写屏障Card TableCard Table SATB无并发回收Background GC (Gen2)并发标记 并发回收部分并发标记晋升策略存活即晋升存活次数阈值—大对象处理LOH≥85KB直接进 Old Region无特殊处理7.2 .NET 的 GC 模式.NET 提供三种 GC 模式适用于不同场景// 配置 GC 模式通过配置文件或代码 using System; using System.Runtime; class GCModeDemo { static void Main() { // 查看当前 GC 模式 Console.WriteLine($GC 服务器模式: {GCSettings.IsServerGC}); Console.WriteLine($GC 延迟模式: {GCSettings.LatencyMode}); // 临时切换为低延迟模式适合游戏循环 var oldMode GCSettings.LatencyMode; GCSettings.LatencyMode GCLatencyMode.LowLatency; try { // 在此期间 GC 会尽量避免 Gen2 回收 // 适合游戏帧循环中的关键路径 DoGameFrame(); } finally { GCSettings.LatencyMode oldMode; } } static void DoGameFrame() { /* 游戏帧逻辑 */ } }模式配置适用场景特点Workstation GC默认桌面应用单处理器Gen2 并发标记Server GCgcServertrue服务器应用每核一个堆和 GC 线程吞吐量优先Background GCgcConcurrenttrue交互式应用Gen2 在后台并发回收STW 极短7.3 Unity 中的 GC 对比// Unity 中检测 GC 类型的代码 #if UNITY_EDITOR using UnityEngine; using System; public class GCDetection : MonoBehaviour { void Start() { // Unity 默认使用 Mono Boehm GC不分代 // IL2CPP 也使用 Boehm GC // 检测是否支持分代 GC bool isGenerational false; try { // .NET 分代 GC 支持 GC.GetGeneration var test new object(); int gen GC.GetGeneration(test); isGenerational true; } catch { // Boehm GC 可能不支持 GetGeneration isGenerational false; } Debug.Log($分代 GC 支持: {isGenerational}); Debug.Log($堆大小: {GC.GetTotalMemory(false) / 1024 / 1024} MB); } } #endif八、总结分代 GC 是现代 GC 最核心的设计思想它基于代际假说将堆划分为多个区域对每个区域使用最适合的回收算法代际假说绝大多数对象朝生夕灭弱假说越老的对象越不容易死亡强假说。这是分代设计的理论基础。新生代用 Copying存活率低复制开销小分配速度快。.NET 的 Gen0/Gen1 使用此算法。老年代用 Mark-Sweep/Compact存活率高不适合复制但需要处理碎片化。.NET 的 Gen2 使用此算法。写屏障解决跨代引用通过 Card Table 记录老年代到新生代的引用确保新生代 GC 的正确性。晋升机制存活过 GC 的对象晋升到更高代减少高频 GC 的扫描范围但可能导致老年代膨胀。Unity vs .NET 的关键差异.NET 使用分代 GC日常只回收 Gen0STW 极短Unity 的 Boehm GC 不分代每次 GC 都是 Full GCSTW 时间长这就是 Unity 游戏 GC 卡顿的根本原因——没有分代无法只回收新生代理解分代 GC 是优化 .NET 和 Unity 内存管理的关键。在后续章节中我们将深入探讨大对象堆LOH和 GC Roots 遍历等更具体的主题。
返回列表