Voocii博客
首页博客AI 热榜作品集读书友链工具关于

© 2026 Voocii. Built with Next.js & tRPC.

GitHubXEmailRSS


介绍 .NET 垃圾回收(GC)

dotnetrick-hayekrick-hayek2024年3月16日

举个栗子

C/C++:自己在家做饭:买菜,洗菜,做饭,开吃,吃完了洗碗刷锅倒垃圾; C#:在饭店吃饭,坐下点菜,等菜上了开吃,某个菜盘子空了服务员上来收走,碟子满了服务员来换新的,吃饱喝足拍拍屁股走人; 前者全程操心很累以至于有时做好了都不大想吃了不过干净安全省钱,后者省心省力享受别人的服务不过干不干净安不安全依赖外部且钱包消耗大。

一、什么是垃圾回收?为什么需要它?

在 C/C++ 时代,内存管理完全交给开发者:malloc/free,new/delete。这带来两类经典问题:

  • 内存泄漏:忘记释放,内存越用越多,程序最终耗尽资源崩溃。
  • 悬空指针 / 野指针:提前释放了还在使用的内存,之后再访问就是未定义行为,轻则崩溃,重则安全漏洞。

垃圾回收(Garbage Collection,GC)的核心思想是:开发者只管"分配","回收"交给运行时自动完成。CLR(Common Language Runtime)会在后台跟踪哪些对象还"活着"(可达),哪些已经"死了"(不可达),并自动回收死对象占用的内存。

好处很直接:

  1. 消除了手动释放托管内存时的大量错误,但不能消除错误引用、无限增长缓存、事件订阅或非托管资源导致的所有泄漏;
  2. 开发者可以专注业务逻辑,而不是手动管理每一块内存;
  3. 提供了内存安全的基础保障。

代价也很明确:GC 需要消耗 CPU 时间去扫描、标记、移动对象,这在高性能/低延迟场景下需要被认真对待——这也是本文后半部分要讲的内容。

二、技术原理和实现方案

.NET 的 GC 是一个**分代(Generational)**的回收器。它会根据回收的代际、GC 模式和堆区域,组合使用标记、清理、压缩、并发标记等阶段;因此不能把每次 GC 都简单理解为完整执行一次 Mark-Sweep-Compact。它建立在两个关键假设之上:

  • 假设一(弱代假说):大多数对象"英年早逝"——刚分配不久就变得不可达(比如方法内的局部临时对象)。
  • 假设二:老对象往往活得更久;同时,运行时会通过写屏障等机制记录重要的跨代引用,避免每次年轻代回收都扫描整个堆。

基于这两个假设,.NET 把堆划分为三代:

  • Gen 0:新分配的小对象。回收最频繁,速度最快(通常几毫秒甚至更短)。
  • Gen 1:Gen 0 中存活下来的对象,作为缓冲代。
  • Gen 2:长期存活的对象。回收频率低,但一次全量扫描代价较大。
  • LOH(大对象堆,Large Object Heap):通常大于等于 85,000 字节的对象直接进入 LOH;它是物理堆区域,逻辑上随 Gen 2 参与回收,默认通常不压缩。
  • POH(固定对象堆,Pinned Object Heap):.NET 5 引入的堆区域,用于承载某些长期固定对象,降低固定对象阻碍普通堆压缩的影响。POH 不是“所有 fixed 对象都会自动进入”的特殊代际。

GC 的典型阶段:标记、清理与压缩

  1. 标记(Mark):从 GC Roots 出发遍历引用图,识别可达对象。后台 GC 可以将部分标记工作与应用线程并发执行。
  2. 清理(Sweep/Cleanup):不可达对象占用的空间被回收,或加入空闲块结构供后续分配使用。
  3. 压缩(Compact):在适用的堆区域搬迁存活对象、修正引用并消除碎片;LOH 默认通常不压缩,POH 也有其独立的布局和回收策略。

分代 GC 的一个关键优化是跨代引用跟踪。如果老年代对象引用了年轻代对象,写屏障会记录这类修改,GC 可以借助 card table 等结构找到相关区域,而不必每次 Gen 0 回收都完整扫描 Gen 2。

GC Roots、终结器队列与终结器线程

GC Roots 包括静态字段、活动线程栈、运行时句柄、JIT 保持的临时引用等。GC 从这些根出发判断对象是否可达。

带有终结器的对象有一个额外的生命周期:当对象已经没有普通强引用,但仍需要执行终结器时,GC 会把它放入 Finalization Queue,由 Finalizer Thread 异步调用终结器。终结器执行完成后,对象才有机会在后续 GC 中真正回收。因此终结器会延长对象生命周期,并增加 GC 和运行时开销。

Finalization Queue 是终结器机制的一部分,不应简单当成普通 GC Root。优先使用 SafeHandle 封装非托管句柄,只有类型直接拥有非托管资源且确实需要兜底释放时才考虑自定义终结器。

SafeHandle:比手写终结器更安全

如果类型直接持有 Windows Handle、Unix file descriptor 或 native pointer,应优先使用 SafeHandle:

public sealed class NativeResource : IDisposable
{
    private readonly SafeHandle _handle;

    public NativeResource(SafeHandle handle)
    {
        _handle = handle;
    }

    public void Dispose()
    {
        _handle.Dispose();
        GC.SuppressFinalize(this);
    }
}

SafeHandle 自身负责在显式释放和最终化路径中确保句柄只释放一次,应用类型通常不需要再手写终结器。FileStream、SafeFileHandle 等框架类型已经使用了类似机制。

Server GC vs Workstation GC

  • Workstation GC:面向客户端和交互式应用,通常使用较少的资源,强调应用响应性。
  • Server GC:使用多个堆和专用 GC 线程并行工作,通常更偏向吞吐量,适合服务端高并发场景。普通 .NET 应用默认是 Workstation GC;ASP.NET Core 应用通常默认使用 Server GC,但应通过运行时配置或 GCSettings.IsServerGC 确认。

两者都支持 后台 GC(Background GC):Gen 2 的并发标记阶段可以和应用线程同时进行,减少"stop-the-world"的暂停时间。

三、垃圾回收流程图

下图展示了一次典型的 Gen 0/1/2 触发到完成的全过程:

GC 流程图

流程要点(教学化的典型流程,不代表每次 GC 都完整执行所有阶段):

  1. 应用程序尝试分配对象 → Gen 0 空间不足触发 GC;
  2. 在需要安全点、重新定位对象或完成回收阶段时暂停相关应用线程;后台 GC 可以将部分工作与应用线程并发执行,但仍可能产生短暂停顿;
  3. 从根遍历标记可达对象;
  4. 清理不可达对象,并在适用的堆区域压缩存活对象;
  5. 存活下来的 Gen 0 对象通常晋升到 Gen 1,存活的 Gen 1 对象通常晋升到 Gen 2;LOH 对象不经过 Gen 0/1,逻辑上随 Gen 2 回收;
  6. 恢复应用程序线程继续执行。

四、Managed Object 与 Unmanaged Object

这是理解 .NET 内存模型最关键的一组概念。

Managed Object(托管对象)

  • 定义:由 CLR 分配在托管堆上、生命周期由 GC 自动管理的对象。
  • 特点:
    • 通过 new 关键字创建的引用类型实例(class 实例、数组、委托等)都是托管对象;
    • 不需要手动释放,GC 会在其不可达时自动回收;
    • 内存地址在 GC 压缩阶段可能会变化(因为对象会被搬移)。

Unmanaged Object(非托管对象/资源)

  • 定义:不受 CLR 对象生命周期管理的外部资源,包括操作系统句柄、文件描述符、非托管内存块(如通过 Marshal.AllocHGlobal 分配)、GDI+ 句柄等。FileStream、SqlConnection、Socket 本身仍是托管对象,只是内部可能持有非托管资源或 SafeHandle。
  • 特点:
    • GC 完全不知道这些资源的存在,也不会主动释放它们;
    • 必须由开发者显式释放,通常通过实现 IDisposable 接口的 Dispose() 方法,或使用终结器(Finalizer,~ClassName())作为兜底;
    • using 语句是释放非托管资源的推荐方式。

两者的核心区别

维度Managed ObjectUnmanaged Object
分配位置托管堆堆外内存 / 操作系统资源
生命周期管理GC 自动管理开发者手动管理
是否会被 GC 感知是外部资源本身不会;托管包装器会被 GC 感知
释放方式自动回收Dispose() / using / 终结器
典型例子class 实例、List<T>、stringFileStream 底层句柄、SqlConnection、Bitmap、Socket

需要特别注意:像 FileStream、SqlConnection 这样的类,本身是托管对象(在托管堆上),但它们内部可能包裹着非托管句柄或连接资源。应通过 Dispose() / using 及时释放;GC 不是确定性资源管理器,不能把它当成关闭文件、连接或套接字的替代品。

五、GC 会回收哪些对象?

GC 判断一个对象是否可以被回收的唯一标准是:从根(Roots)出发是否可达(Reachable)。会被回收的对象包括:

  1. 不再被任何变量引用的对象:方法执行完毕后,局部变量指向的对象如果没有被其他地方引用,就变得不可达;
  2. 循环引用但整体不可达的对象组:比如 A 引用 B,B 引用 A,但没有任何外部根引用它们俩——GC 通过可达性分析(而非引用计数)能正确识别并回收这类"孤岛";
  3. 事件订阅方已失效但未取消订阅的对象(前提是发布者本身也不可达,否则会造成"事件内存泄漏",这恰恰是不会被回收的反例);
  4. 弱引用(WeakReference)包裹的对象:一旦没有强引用指向它,GC 可以随时回收,即使弱引用还"挂"着。

不会被 GC 回收的情况(开发者常踩的坑):

  • 静态字段持有的对象引用(只要程序运行,静态引用就是根,永远可达);
  • 未取消订阅的事件处理器(发布者持有订阅者的引用,导致订阅者对象"假死不僵");
  • 长生命周期集合(如缓存 Dictionary)中残留的对象引用;
  • 非托管资源本身(GC 只管理托管包装器,外部资源需要 Dispose、SafeHandle 或对应 API 释放)。

六、垃圾回收有哪些方式

按触发时机分

  • Gen 0/1 GC(Ephemeral GC):频繁、快速,通常几毫秒;
  • Gen 2 GC(Full GC):全堆扫描,耗时较长,可能引发明显的暂停(STW,Stop-The-World);
  • 诱导式 GC:代码显式调用 GC.Collect() 触发(生产环境一般不建议手动调用)。

按并发模式分

  • 非并发(阻塞)GC:GC 期间完全挂起应用线程;
  • 后台 GC(Background GC):Gen 2 的部分工作可以与应用线程并发执行,但仍会在若干阶段暂停应用线程;它是降低暂停时间的机制,不是完全没有暂停。

按堆布局与线程模型分

  • Workstation GC(concurrent/non-concurrent):单堆,适合客户端;
  • Server GC(concurrent/non-concurrent):多堆并行,适合高吞吐服务端;可通过 runtimeconfig.json 或 .csproj 配置:
<PropertyGroup>
  <ServerGarbageCollection>true</ServerGarbageCollection>
  <ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
</PropertyGroup>

特殊机制

  • LOH 压缩:默认不压缩大对象堆,但可通过 GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce 手动触发一次压缩;
  • 可持续低延迟模式(Sustained Low Latency):通过 GCLatencyMode 配置,尽量减少某些阻塞回收,但可能增加内存占用,必须结合压测和延迟目标使用。
  • No GC Region:GC.TryStartNoGCRegion 可以在预先知道分配上限的极少数低延迟场景中暂时禁止 GC,但不是常规优化手段,超出预算可能失败。

七、示例代码

1. 托管对象自动回收(无需干预)

void ProcessOrder()
{
    var order = new Order(); // 托管对象,分配在 Gen 0
    order.Calculate();
} // 方法结束后 order 不可达,等待下次 GC 回收

2. 正确释放非托管资源:IDisposable + using

public sealed class FileLogger : IDisposable
{
    private readonly FileStream _stream;
    private bool _disposed;

    public FileLogger(string path)
    {
        _stream = new FileStream(path, FileMode.Append);
    }

    public void Write(string message)
    {
        ObjectDisposedException.ThrowIf(_disposed, this);

        var bytes = System.Text.Encoding.UTF8.GetBytes(message + "\n");
        _stream.Write(bytes, 0, bytes.Length);
    }

    public void Dispose()
    {
        if (_disposed) return;

        _stream.Dispose(); // FileStream 内部负责释放其 SafeHandle
        _disposed = true;
        // FileLogger 本身没有终结器;不能依赖 GC 及时关闭文件
    }
}

// 使用方式
using (var logger = new FileLogger("app.log"))
{
    logger.Write("Hello GC");
} // 自动调用 Dispose(),即使发生异常也会执行

3. 弱引用示例:允许对象在内存紧张时被回收

var cache = new WeakReference<byte[]>(new byte[1024 * 1024 * 10]); // 10MB

if (cache.TryGetTarget(out byte[] data))
{
    Console.WriteLine("对象还活着,可以使用");
}
else
{
    Console.WriteLine("对象已被 GC 回收,需要重新加载");
}

4. 查看 GC 信息(诊断用途)

Console.WriteLine($"Gen 0 回收次数: {GC.CollectionCount(0)}");
Console.WriteLine($"Gen 1 回收次数: {GC.CollectionCount(1)}");
Console.WriteLine($"Gen 2 回收次数: {GC.CollectionCount(2)}");
Console.WriteLine($"当前托管内存占用: {GC.GetTotalMemory(false) / 1024} KB");

需要注意:GC.GetTotalMemory() 只用于估计托管堆相关的内存,不等于进程的 Working Set、Private Bytes 或容器实际内存占用。进程内存还可能包括线程栈、JIT 代码、运行时结构、Native memory、Memory-mapped file 和第三方库分配等。

常用诊断指标

指标用途
Gen 0/1/2 Collection Count判断各代 GC 的频率
Allocation Rate判断应用分配速度和短命对象压力
GC Pause Time判断 GC 对延迟的影响
Managed Heap Size判断托管堆是否持续增长
LOH / POH Size判断大对象和固定对象压力
Working Set / Private Bytes判断进程整体内存,而不只是托管堆

可使用 dotnet-counters 观察实时指标,使用 dotnet-gcdump 分析托管对象图,使用 Visual Studio Diagnostic Tools、PerfView 或其他 profiler 分析分配热点和引用链。排查内存问题时,应先区分“托管堆增长”和“进程整体内存增长”,再决定是否需要分析 GC。

一个典型排查案例

现象:服务 Working Set 持续增长,Gen 2 GC 次数并不高。

1. 使用 dotnet-counters 查看 Managed Heap Size 和 Allocation Rate;
2. 如果托管堆没有同步增长,检查 Native memory、线程栈和映射文件;
3. 如果托管堆持续增长,使用 dotnet-gcdump 查看对象数量和引用关系;
4. 发现静态 Dictionary 持有大量历史缓存;
5. 增加过期策略、容量上限或改用有界缓存;
6. 重新观察堆大小、Gen 2 次数和 Working Set。

另一个常见场景是频繁 Full GC 和 LOH 碎片:优先检查大数组分配、重复拷贝和序列化缓冲区,结合 ArrayPool<T>、流式处理和合理的缓冲区生命周期进行优化。性能优化应以实际指标和压测为依据,而不是单独根据 GC 次数猜测。

八、开发人员需要注意的地方

  1. 对实现了 IDisposable 的对象,永远用 using 或显式 Dispose()。FileStream、SqlConnection、HttpClient(长连接场景例外)、Bitmap 等都属于这一类,忘记释放是"资源泄漏"的头号原因。

  2. 警惕事件订阅导致的隐性内存泄漏。如果对象 A 订阅了对象 B 的事件(B.SomeEvent += A.Handler),只要 B 还活着,A 就永远不会被回收,即使业务上 A 早该"死"了。解决办法:显式 -= 取消订阅,或使用弱事件模式(Weak Event Pattern)。

  3. 避免频繁分配大对象,警惕 LOH 碎片化。大于等于 85000 字节的对象直接进 LOH,且默认不压缩,长期运行的服务如果频繁分配又释放大数组(例如反复 new byte[100_000]),容易导致内存碎片和 OOM——考虑使用 ArrayPool<T> 复用缓冲区。

  4. 不要滥用 GC.Collect()。手动强制 Full GC 通常弊大于利,会打断 GC 自身的优化节奏,引发不必要的长时间暂停。只有在非常明确的场景(如大批量数据处理后主动释放)才考虑使用,并配合 GC.WaitForPendingFinalizers()。

  5. 终结器(Finalizer)要谨慎使用。带终结器的对象至少要经历两次 GC 才能被彻底回收(第一次进入终结队列,第二次才真正释放内存),这会延长对象生命周期,增大 Gen 2 压力。优先用 IDisposable + SafeHandle,只把终结器当兜底。

  6. 理解值类型 vs 引用类型对 GC 压力的影响。值类型没有独立的对象头和引用语义;它的存储位置取决于上下文,可能位于栈、托管对象内部或数组中。值类型本身通常不会产生独立的 GC 分配,但装箱、闭包、异步状态机以及包含引用字段的值类型仍可能增加 GC 压力。高性能场景可考虑用合适的值类型、Span<T>、对象池等减少分配,但也要注意过大值类型的复制成本。

  7. 根据应用类型确认 GC 模式。普通 .NET 应用默认通常是 Workstation GC;ASP.NET Core 通常默认使用 Server GC。Worker Service、控制台程序或特殊宿主应根据吞吐量、延迟和内存预算,通过 .csproj 或运行时配置显式设置并用 GCSettings.IsServerGC 验证。

  8. 善用诊断工具而非猜测。dotnet-counters、dotnet-gcdump、Visual Studio 的诊断工具、PerfView 都可以帮你看到真实的 GC 频率、代际分布和内存快照,遇到内存问题时应该先测量,而不是凭经验乱调参数。


小结:.NET 的 GC 通过分代假说、可组合的标记/清理/压缩阶段和多种并发/并行策略,在自动化和性能之间找到了平衡。理解 Managed/Unmanaged 对象的边界、正确使用 IDisposable 和 SafeHandle,并避免常见的隐性引用泄漏,是每个 .NET 开发者都应该掌握的基本功。


References https://learn.microsoft.com/en-us/dotnet/core/runtime-config/garbage-collector https://learn.microsoft.com/en-us/dotnet/standard/garbage-collection/large-object-heap https://learn.microsoft.com/en-us/dotnet/standard/garbage-collection/implementing-dispose

评论 (0)

暂无评论,快来抢沙发吧!

目录
  • 一、什么是垃圾回收?为什么需要它?
  • 二、技术原理和实现方案
  • 三、垃圾回收流程图
  • 四、Managed Object 与 Unmanaged Object
  • 五、GC 会回收哪些对象?
  • 六、垃圾回收有哪些方式
  • 七、示例代码
  • 八、开发人员需要注意的地方