02-02-原理篇-分代GC

分代 GC(Generation GC)

篇章 :02-原理篇

阅读时间 :约 35 分钟

前置知识:了解 Mark-Sweep 算法


一、引言

在上一章中,我们分析了 Mark-Sweep、Mark-Compact 和 Copying 三种基础 GC 算法。一个关键结论是:Copying 算法在存活率低时效率极高,而 Mark-Compact 在存活率高时更合适。 那么问题来了------能否让不同区域的对象使用不同的算法,各取所长?

分代 GC(Generational 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 0 Gen 1 Gen 2 LOH
角色 新生代 缓冲区 老年代 大对象
算法 Copying Copying Mark-Sweep + 可选 Compact Mark-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 GC(Gen0 + 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)}"); // 2(LOH 算作 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");
    }
}

四、晋升策略(Promotion)

4.1 晋升机制

当一次 GC 回收完成后,存活的对象会被"晋升"到更高的一代:

复制代码
Gen0 GC:
  Gen0 存活对象 → 复制到 Gen1
  
Gen1 GC(包含 Gen0):
  Gen0 存活对象 → 复制到 Gen1
  Gen1 存活对象 → 复制到 Gen2

Gen2 GC(Full GC):
  Gen0 存活对象 → 复制到 Gen1
  Gen1 存活对象 → 复制到 Gen2
  Gen2 存活对象 → 原地保留(Mark-Sweep/Compact)

4.2 晋升的代价

晋升是一把双刃剑:

  • 好处:减少高频 GC 的扫描范围,提升回收效率

  • 代价:老年代不断膨胀,Full GC 频率增加

    // 晋升代价演示
    using System;
    using System.Collections.Generic;

    class PromotionCostDemo
    {
    // 长期存活的对象会不断晋升,最终进入 Gen2
    static List<byte[]> cache = new List<byte[]>();

    复制代码
      static void Main()
      {
          Console.WriteLine("=== 模拟对象晋升 ===");
          
          // 持续分配并保留引用,迫使对象晋升
          for (int i = 0; i < 5; i++)
          {
              cache.Add(new byte[4096]); // 保留引用
              GC.Collect(0); // 只回收 Gen0
              Console.WriteLine($"第 {i+1} 次 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 的对象直接分配到 LOH,LOH 算作 Gen2
固定对象不移动 Pinned 对象不会被 Copying 算法移动,可能影响 Gen0 效率

五、Write Barrier(写屏障)

5.1 跨代引用问题

分代 GC 面临一个核心挑战:跨代引用(Cross-Generation Reference)。

复制代码
问题场景:

Gen2 对象 A 引用 Gen0 对象 B

如果只回收 Gen0,GC 从 Gen0 的根开始遍历:
  → B 没有被 Gen0 内的根引用
  → B 被误判为垃圾并回收!
  → 但 A(Gen2)仍然引用 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 List<byte[]>();
        
        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)}");
        
        // 此时大部分对象在 Gen2,Gen0 GC 无效
        // 每次都要 Full GC → 分代优势丧失
    }
}

七、不同 GC 的分代设计对比

7.1 .NET vs Java vs Unity

特性 .NET GC Java HotSpot (G1) Unity Boehm
分代 3 代 + LOH 多 Region 模拟分代 不分代
新生代算法 Copying Copying ---
老年代算法 Mark-Sweep/Compact Mark-Compact Mark-Sweep
写屏障 Card Table Card 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 GC gcServer=true 服务器应用 每核一个堆和 GC 线程,吞吐量优先
Background GC gcConcurrent=true 交互式应用 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 最核心的设计思想,它基于代际假说将堆划分为多个区域,对每个区域使用最适合的回收算法:

  1. 代际假说:绝大多数对象朝生夕灭(弱假说),越老的对象越不容易死亡(强假说)。这是分代设计的理论基础。

  2. 新生代用 Copying:存活率低,复制开销小,分配速度快。.NET 的 Gen0/Gen1 使用此算法。

  3. 老年代用 Mark-Sweep/Compact:存活率高,不适合复制,但需要处理碎片化。.NET 的 Gen2 使用此算法。

  4. 写屏障解决跨代引用:通过 Card Table 记录老年代到新生代的引用,确保新生代 GC 的正确性。

  5. 晋升机制:存活过 GC 的对象晋升到更高代,减少高频 GC 的扫描范围,但可能导致老年代膨胀。

Unity vs .NET 的关键差异:

  • .NET 使用分代 GC,日常只回收 Gen0,STW 极短
  • Unity 的 Boehm GC 不分代,每次 GC 都是 Full GC,STW 时间长
  • 这就是 Unity 游戏 GC 卡顿的根本原因------没有分代,无法只回收新生代

理解分代 GC 是优化 .NET 和 Unity 内存管理的关键。在后续章节中,我们将深入探讨大对象堆(LOH)和 GC Roots 遍历等更具体的主题。

相关推荐
BD_Marathon1 小时前
多轮对话聊天机器人
开发语言·机器人·c#
czhc11400756632 小时前
设备标定页防呆为何失效:草稿与真值的错位
c#
无忧.芙桃2 小时前
C++语言原理与实践(十):vector类的底层实现
开发语言·c++·算法
乐迪信息2 小时前
AI防爆摄像机,监测港口船舶航行偏航隐患
大数据·人工智能·深度学习·算法·计算机视觉
0+1113 小时前
算法 --模拟
c++·算法·leetcode
青山是哪个青山3 小时前
LeetCode 300:最长递增子序列
算法
hetao17338373 小时前
2026-10-01~02 hetao1733837 的刷题记录
c++·算法
小虎牙^O^3 小时前
模糊数学综合评价:从模糊数学到建模评价类模型
人工智能·算法
随意起个昵称4 小时前
【单调队列优化DP】U301134 绿色通道
算法·动态规划