举个栗子
C/C++:自己在家做饭:买菜,洗菜,做饭,开吃,吃完了洗碗刷锅倒垃圾; C#:在饭店吃饭,坐下点菜,等菜上了开吃,某个菜盘子空了服务员上来收走,碟子满了服务员来换新的,吃饱喝足拍拍屁股走人; 前者全程操心很累以至于有时做好了都不大想吃了不过干净安全省钱,后者省心省力享受别人的服务不过干不干净安不安全依赖外部且钱包消耗大。
在 C/C++ 时代,内存管理完全交给开发者:malloc/free,new/delete。这带来两类经典问题:
垃圾回收(Garbage Collection,GC)的核心思想是:开发者只管"分配","回收"交给运行时自动完成。CLR(Common Language Runtime)会在后台跟踪哪些对象还"活着"(可达),哪些已经"死了"(不可达),并自动回收死对象占用的内存。
好处很直接:
代价也很明确:GC 需要消耗 CPU 时间去扫描、标记、移动对象,这在高性能/低延迟场景下需要被认真对待——这也是本文后半部分要讲的内容。
.NET 的 GC 是一个**分代(Generational)**的回收器。它会根据回收的代际、GC 模式和堆区域,组合使用标记、清理、压缩、并发标记等阶段;因此不能把每次 GC 都简单理解为完整执行一次 Mark-Sweep-Compact。它建立在两个关键假设之上:
基于这两个假设,.NET 把堆划分为三代:
fixed 对象都会自动进入”的特殊代际。分代 GC 的一个关键优化是跨代引用跟踪。如果老年代对象引用了年轻代对象,写屏障会记录这类修改,GC 可以借助 card table 等结构找到相关区域,而不必每次 Gen 0 回收都完整扫描 Gen 2。
GC Roots 包括静态字段、活动线程栈、运行时句柄、JIT 保持的临时引用等。GC 从这些根出发判断对象是否可达。
带有终结器的对象有一个额外的生命周期:当对象已经没有普通强引用,但仍需要执行终结器时,GC 会把它放入 Finalization Queue,由 Finalizer Thread 异步调用终结器。终结器执行完成后,对象才有机会在后续 GC 中真正回收。因此终结器会延长对象生命周期,并增加 GC 和运行时开销。
Finalization Queue 是终结器机制的一部分,不应简单当成普通 GC Root。优先使用 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 等框架类型已经使用了类似机制。
GCSettings.IsServerGC 确认。两者都支持 后台 GC(Background GC):Gen 2 的并发标记阶段可以和应用线程同时进行,减少"stop-the-world"的暂停时间。
下图展示了一次典型的 Gen 0/1/2 触发到完成的全过程:
流程要点(教学化的典型流程,不代表每次 GC 都完整执行所有阶段):
这是理解 .NET 内存模型最关键的一组概念。
new 关键字创建的引用类型实例(class 实例、数组、委托等)都是托管对象;Marshal.AllocHGlobal 分配)、GDI+ 句柄等。FileStream、SqlConnection、Socket 本身仍是托管对象,只是内部可能持有非托管资源或 SafeHandle。IDisposable 接口的 Dispose() 方法,或使用终结器(Finalizer,~ClassName())作为兜底;using 语句是释放非托管资源的推荐方式。| 维度 | Managed Object | Unmanaged Object |
|---|---|---|
| 分配位置 | 托管堆 | 堆外内存 / 操作系统资源 |
| 生命周期管理 | GC 自动管理 | 开发者手动管理 |
| 是否会被 GC 感知 | 是 | 外部资源本身不会;托管包装器会被 GC 感知 |
| 释放方式 | 自动回收 | Dispose() / using / 终结器 |
| 典型例子 | class 实例、List<T>、string | FileStream 底层句柄、SqlConnection、Bitmap、Socket |
需要特别注意:像 FileStream、SqlConnection 这样的类,本身是托管对象(在托管堆上),但它们内部可能包裹着非托管句柄或连接资源。应通过 Dispose() / using 及时释放;GC 不是确定性资源管理器,不能把它当成关闭文件、连接或套接字的替代品。
GC 判断一个对象是否可以被回收的唯一标准是:从根(Roots)出发是否可达(Reachable)。会被回收的对象包括:
不会被 GC 回收的情况(开发者常踩的坑):
Dispose、SafeHandle 或对应 API 释放)。GC.Collect() 触发(生产环境一般不建议手动调用)。concurrent/non-concurrent):单堆,适合客户端;concurrent/non-concurrent):多堆并行,适合高吞吐服务端;可通过 runtimeconfig.json 或 .csproj 配置:<PropertyGroup>
<ServerGarbageCollection>true</ServerGarbageCollection>
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
</PropertyGroup>
GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce 手动触发一次压缩;GCLatencyMode 配置,尽量减少某些阻塞回收,但可能增加内存占用,必须结合压测和延迟目标使用。GC.TryStartNoGCRegion 可以在预先知道分配上限的极少数低延迟场景中暂时禁止 GC,但不是常规优化手段,超出预算可能失败。void ProcessOrder()
{
var order = new Order(); // 托管对象,分配在 Gen 0
order.Calculate();
} // 方法结束后 order 不可达,等待下次 GC 回收
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(),即使发生异常也会执行
var cache = new WeakReference<byte[]>(new byte[1024 * 1024 * 10]); // 10MB
if (cache.TryGetTarget(out byte[] data))
{
Console.WriteLine("对象还活着,可以使用");
}
else
{
Console.WriteLine("对象已被 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 次数猜测。
对实现了 IDisposable 的对象,永远用 using 或显式 Dispose()。FileStream、SqlConnection、HttpClient(长连接场景例外)、Bitmap 等都属于这一类,忘记释放是"资源泄漏"的头号原因。
警惕事件订阅导致的隐性内存泄漏。如果对象 A 订阅了对象 B 的事件(B.SomeEvent += A.Handler),只要 B 还活着,A 就永远不会被回收,即使业务上 A 早该"死"了。解决办法:显式 -= 取消订阅,或使用弱事件模式(Weak Event Pattern)。
避免频繁分配大对象,警惕 LOH 碎片化。大于等于 85000 字节的对象直接进 LOH,且默认不压缩,长期运行的服务如果频繁分配又释放大数组(例如反复 new byte[100_000]),容易导致内存碎片和 OOM——考虑使用 ArrayPool<T> 复用缓冲区。
不要滥用 GC.Collect()。手动强制 Full GC 通常弊大于利,会打断 GC 自身的优化节奏,引发不必要的长时间暂停。只有在非常明确的场景(如大批量数据处理后主动释放)才考虑使用,并配合 GC.WaitForPendingFinalizers()。
终结器(Finalizer)要谨慎使用。带终结器的对象至少要经历两次 GC 才能被彻底回收(第一次进入终结队列,第二次才真正释放内存),这会延长对象生命周期,增大 Gen 2 压力。优先用 IDisposable + SafeHandle,只把终结器当兜底。
理解值类型 vs 引用类型对 GC 压力的影响。值类型没有独立的对象头和引用语义;它的存储位置取决于上下文,可能位于栈、托管对象内部或数组中。值类型本身通常不会产生独立的 GC 分配,但装箱、闭包、异步状态机以及包含引用字段的值类型仍可能增加 GC 压力。高性能场景可考虑用合适的值类型、Span<T>、对象池等减少分配,但也要注意过大值类型的复制成本。
根据应用类型确认 GC 模式。普通 .NET 应用默认通常是 Workstation GC;ASP.NET Core 通常默认使用 Server GC。Worker Service、控制台程序或特殊宿主应根据吞吐量、延迟和内存预算,通过 .csproj 或运行时配置显式设置并用 GCSettings.IsServerGC 验证。
善用诊断工具而非猜测。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
No comments yet. Be the first!