第 4 章:垃圾回收(GC)深入剖析

jerry北京市2026年4月10日C# 13 次阅读 约 17 分钟
第 4 章:垃圾回收(GC)深入剖析

深入理解 .NET GC 的分代回收机制、GC Root、终结器队列、IDisposable 模式、大对象堆和 GC 调优。


4.1 分代回收

.NET GC 将堆分为三代:

┌─────────────────────────────────────────────────┐
│                  托管堆                          │
│                                                 │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐     │
│  │  Gen 0   │  │  Gen 1   │  │  Gen 2   │     │
│  │ 新对象    │  │ 存活过1次 │  │ 长期存活  │     │
│  │ ~256KB   │  │ ~2MB     │  │ 无上限    │     │
│  │ 回收最频繁│  │ 中等频率  │  │ 回收最少  │     │
│  └──────────┘  └──────────┘  └──────────┘     │
│                                                 │
│  ┌──────────────────────────────────────┐      │
│  │  LOH (Large Object Heap)             │      │
│  │  ≥ 85,000 bytes 的对象               │      │
│  │  等同于 Gen 2 回收                    │      │
│  └──────────────────────────────────────┘      │
│                                                 │
│  ┌──────────────────────────────────────┐      │
│  │  POH (Pinned Object Heap) .NET 5+    │      │
│  │  固定的对象                           │      │
│  └──────────────────────────────────────┘      │
└─────────────────────────────────────────────────┘

分代假设

GC 基于"分代假设"(Generational Hypothesis):

  • 大多数对象生命周期很短(临时变量、方法内的对象)
  • 存活越久的对象,越可能继续存活

回收过程

Gen 0 回收(最频繁):
1. 暂停所有线程(STW - Stop The World)
2. 从 GC Root 开始标记 Gen 0 中可达的对象
3. 存活的对象提升到 Gen 1
4. Gen 0 空间被回收

Gen 1 回收:
- 同时回收 Gen 0 和 Gen 1
- 存活的 Gen 1 对象提升到 Gen 2

Gen 2 回收(Full GC,最慢):
- 回收所有代 + LOH
- 尽量避免触发

4.2 GC Root

GC 从 Root 开始遍历,能到达的对象是"存活"的,不能到达的是"垃圾":

// GC Root 包括:
// 1. 栈上的局部变量和方法参数
void Method()
{
    var obj = new MyClass();  // obj 是 GC Root
    // obj 在 Method 执行期间不会被回收
}

// 2. 静态字段
static MyClass _instance = new MyClass();  // 永远是 Root

// 3. GC Handle(GCHandle.Alloc)
// 4. 终结器队列中的对象
// 5. CPU 寄存器中的引用

常见的内存泄漏

// 1. 静态集合持有引用
static List<object> _cache = new();
void Process()
{
    _cache.Add(new LargeObject());  // 永远不会被回收
}

// 2. 事件未取消订阅
class Publisher
{
    public event EventHandler MyEvent;
}
class Subscriber
{
    public Subscriber(Publisher pub)
    {
        pub.MyEvent += OnEvent;  // Publisher 持有 Subscriber 的引用
        // 如果 Publisher 生命周期比 Subscriber 长,Subscriber 不会被回收
    }
    // 应该在不需要时取消订阅:pub.MyEvent -= OnEvent;
}

// 3. 闭包捕获
void Method()
{
    var largeData = new byte[1024 * 1024];
    Action action = () => Console.WriteLine(largeData.Length);
    // action 持有 largeData 的引用
    // 只要 action 存活,largeData 就不会被回收
}

4.3 终结器(Finalizer)

class ResourceHolder
{
    ~ResourceHolder()  // 终结器(析构函数语法)
    {
        // 释放非托管资源
        CloseHandle(_handle);
    }
}

终结器的执行流程:

1. 对象不可达时,不会立即回收
2. 被放入终结器队列(Finalization Queue)
3. 终结器线程执行终结器方法
4. 下一次 GC 时才真正回收

结果:有终结器的对象至少存活两次 GC,提升到更高的代

IDisposable 模式(推荐)

class ManagedResource : IDisposable
{
    private IntPtr _handle;           // 非托管资源
    private Stream _stream;           // 托管资源
    private bool _disposed = false;

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);    // 告诉 GC 不需要调用终结器了
    }

    protected virtual void Dispose(bool disposing)
    {
        if (_disposed) return;

        if (disposing)
        {
            // 释放托管资源
            _stream?.Dispose();
        }

        // 释放非托管资源
        if (_handle != IntPtr.Zero)
        {
            CloseHandle(_handle);
            _handle = IntPtr.Zero;
        }

        _disposed = true;
    }

    ~ManagedResource()
    {
        Dispose(false);  // 终结器只释放非托管资源
    }
}

// 使用 using 语句自动调用 Dispose
using var resource = new ManagedResource();
// 离开作用域时自动调用 Dispose()

大多数情况下,如果只有托管资源,简化版就够了:

class SimpleResource : IDisposable
{
    private Stream? _stream;

    public void Dispose()
    {
        _stream?.Dispose();
        _stream = null;
    }
}

4.4 大对象堆(LOH)

≥ 85,000 字节的对象分配在 LOH 上:

// 这个数组会分配在 LOH 上
var largeArray = new byte[85000];  // ≥ 85000 bytes → LOH

// double[] 的阈值更低
var doubles = new double[1000];    // 1000 * 8 = 8000 bytes → SOH
var doubles2 = new double[10625]; // 10625 * 8 = 85000 bytes → LOH

LOH 的特点:

  • 等同于 Gen 2 回收(Full GC 才回收)
  • 默认不压缩(可能产生碎片)
  • .NET 4.5.1+ 可以手动请求压缩
// 请求 LOH 压缩(下次 Full GC 时执行)
GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();

避免 LOH 碎片的方法:

  • 使用 ArrayPool<T>.Shared 复用大数组
  • 避免频繁分配和释放大对象

4.5 GC 模式

// 工作站 GC(Workstation GC)
// - 默认用于桌面应用
// - 单线程 GC
// - 更低的延迟

// 服务器 GC(Server GC)
// - 用于服务端应用
// - 每个 CPU 核心一个 GC 线程
// - 更高的吞吐量
<!-- .csproj 配置 -->
<PropertyGroup>
    <ServerGarbageCollection>true</ServerGarbageCollection>
    <ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
</PropertyGroup>
// runtimeconfig.json
{
    "runtimeOptions": {
        "configProperties": {
            "System.GC.Server": true,
            "System.GC.Concurrent": true
        }
    }
}

4.6 GC 调优

// 手动触发 GC(通常不推荐)
GC.Collect();                    // 回收所有代
GC.Collect(0);                   // 只回收 Gen 0
GC.Collect(2, GCCollectionMode.Optimized);  // 让 GC 决定是否需要回收

// 查看 GC 信息
GC.GetGCMemoryInfo();            // 详细的 GC 内存信息
GC.GetTotalMemory(false);        // 当前托管堆大小
GC.CollectionCount(0);           // Gen 0 回收次数

// 注册 GC 通知
GC.RegisterForFullGCNotification(10, 10);

// .NET 6+ 控制 GC 暂停时间
GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency;

减少 GC 压力的方法

// 1. 使用对象池
var pool = ArrayPool<byte>.Shared;
byte[] buffer = pool.Rent(1024);
try { /* 使用 buffer */ }
finally { pool.Return(buffer); }

// 2. 使用 ValueTask 代替 Task(减少堆分配)
async ValueTask<int> GetValueAsync() { ... }

// 3. 使用 Span<T> 避免数组分配
Span<int> span = stackalloc int[100];  // 栈上分配

// 4. 使用 struct 代替 class(小对象)
readonly record struct Point(int X, int Y);

// 5. 缓存和复用对象
private static readonly StringBuilder _sb = new();

4.7 面试要点

  1. .NET GC 的分代机制?

    • Gen 0(新对象)、Gen 1(缓冲区)、Gen 2(长期存活)
    • 基于分代假设:大多数对象生命周期很短
  2. 什么是 GC Root?

    • 栈上的局部变量、静态字段、GC Handle、终结器队列
  3. 终结器和 Dispose 的区别?

    • 终结器由 GC 调用,时机不确定,对象至少多存活一次 GC
    • Dispose 由开发者调用,确定性释放资源
  4. 大对象堆(LOH)的特点?

    • ≥ 85KB 的对象,等同于 Gen 2 回收,默认不压缩
  5. 如何减少 GC 压力?

    • 对象池、ValueTask、Span<T>、struct、减少分配

练习

  1. 使用 GC.GetGCMemoryInfo() 观察 GC 行为
  2. 创建大量临时对象,观察 Gen 0/1/2 的回收次数
  3. 实现完整的 IDisposable 模式
  4. 使用 ArrayPool<T> 优化大数组的分配

← 上一章:集合类型的底层数据结构 | 下一章:async/await 的本质 →

评论

登录 后发表评论

暂无评论