分代 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 最核心的设计思想,它基于代际假说将堆划分为多个区域,对每个区域使用最适合的回收算法:
-
代际假说:绝大多数对象朝生夕灭(弱假说),越老的对象越不容易死亡(强假说)。这是分代设计的理论基础。
-
新生代用 Copying:存活率低,复制开销小,分配速度快。.NET 的 Gen0/Gen1 使用此算法。
-
老年代用 Mark-Sweep/Compact:存活率高,不适合复制,但需要处理碎片化。.NET 的 Gen2 使用此算法。
-
写屏障解决跨代引用:通过 Card Table 记录老年代到新生代的引用,确保新生代 GC 的正确性。
-
晋升机制:存活过 GC 的对象晋升到更高代,减少高频 GC 的扫描范围,但可能导致老年代膨胀。
Unity vs .NET 的关键差异:
- .NET 使用分代 GC,日常只回收 Gen0,STW 极短
- Unity 的 Boehm GC 不分代,每次 GC 都是 Full GC,STW 时间长
- 这就是 Unity 游戏 GC 卡顿的根本原因------没有分代,无法只回收新生代
理解分代 GC 是优化 .NET 和 Unity 内存管理的关键。在后续章节中,我们将深入探讨大对象堆(LOH)和 GC Roots 遍历等更具体的主题。