在 .NET 中区分 GC 等待与内存泄漏 —— 观测、比较、证明内存增长的实务步骤
· 小村 豪 · .NET, CSharp, GC, 内存泄漏, 诊断, dotnet-counters, dotnet-dump, 运维, 现有资产利用
1. 首先要掌握的要点
运行 .NET 应用程序时,会遇到内存使用量慢慢增加的情况。
打开任务管理器或 top 一看,进程的内存在增加。
容器的内存使用量也在增加。
监控系统里 Working Set 或 RSS 的曲线呈右上扬趋势。
看到这种情况,很容易马上联想到「是不是内存泄漏」。但在 .NET 中,进程内存增加和发生内存泄漏并不是同一件事。
.NET 具备垃圾回收(GC)机制。对象变得不再需要的那一瞬间,内存并不会立刻归还给操作系统。GC 会综合考虑分配情况、堆的阈值、内存压力、代(generation)以及工作负载状况来运作。
因此,会出现下面这些状态:
- 已经不需要的对象,还没有被 GC 回收
- GC 已经完成,但进程的 Working Set 没有立刻下降
- 首次访问、JIT、缓存、连接池只在最初增加一次,之后趋于稳定
- 受管堆(managed heap)保持稳定,但原生内存、线程、套接字、图像处理库那一侧在增加
- 真的存在本应不再需要的对象,却持续被某处引用
本文将讨论如何辨别最后一种情况——「真正发生了泄漏」。需要关注的不是单纯的内存使用量,而是下面这三点。
- GC 之后仍然存活的内存是否在增加
- 增加的是哪种类型
- 这些对象被谁引用着
.NET 的内存泄漏排查,不能止步于「内存在增加」,而要把结论推进到「这个类型的对象在增加,并且一直被这条根从引用着」的程度。
另外,本文中出现的代码已作为可构建、可运行的完整示例(典型泄漏模式的库、观测 GC 等待与存活差异的演示、用 WeakReference 验证持有与回收的单元测试)发布在 GitHub 上。
dotnet-gc-or-memory-leak - komurasoft-blog-samples (GitHub)
2. 首先统一「内存泄漏」的定义
.NET 中所说的内存泄漏,并不只是像 C 或 C++ 那样「忘记释放已分配的内存」这一种形式。
在受管代码(managed code)中,由 GC 负责回收对象。GC 能否回收,取决于「是否还存在能到达该对象的引用」。
也就是说,.NET 中典型的内存泄漏是这样的:
业务上已经不再需要,却依然被 static 字段、缓存、事件、Timer、集合、DI 的生命周期、异步上下文等持续引用,导致在 GC 看来仍处于「使用中」的状态。
GC 很聪明,但它无法判断业务上是否已经不需要。 只要还被引用,它就会判定为存活。
因此,在 .NET 中,与其说是「泄漏」,不如理解为「意料之外的持有」,会更容易把握。
另一方面,下面这些状态还不能立刻断定为内存泄漏。
| 状态 | 未必是泄漏的原因 |
|---|---|
| Working Set / RSS 增加 | 这是操作系统分配给进程的内存,与受管堆中存活对象的数量并不一致 |
| Total Allocated 增加 | 这是启动后累计分配的总量,应用只要在运行,基本上就会持续增加 |
| GC Heap 瞬时增加 | 可能只是在下一次 GC 到来之前,尚未回收的对象仍然存在 |
| 启动后不久就增加 | JIT、类型加载、初始缓存、连接池、模板展开等常见于此 |
| LOH 较大 | 可能是大型数组或缓冲区的重用、碎片化、或对象池策略造成的影响 |
| 内存不下降 | 即便 GC 完成回收,进程也未必会立刻把内存归还给操作系统 |
反过来,下面这些状态出现得越多,内存泄漏的嫌疑就越大。
| 观测结果 | 含义 |
|---|---|
| 每次重复同样的操作,GC 之后的堆都在增加 | 存活对象在增加 |
| Gen 2 或 LOH 的大小持续增加 | 存在长期存活的对象,或者是较大的对象一直没有释放 |
| 多次转储中同一类型的 Count / Size 持续增加 | 可以锁定不断增加的类型 |
用 gcroot 能看到来自 static、事件、缓存、长生命周期服务的引用 |
可以解释 GC 无法回收的原因 |
| 停止负载后,经过足够长的时间或验证性 GC 仍不恢复 | 很可能不只是临时性的分配 |
3. 区分「看的是哪种内存」
排查内存问题时,最先让人混乱的是各种内存指标混在一起。即便都叫「内存」,含义却各不相同。
| 指标 | 观察的内容 | 解读方式 |
|---|---|---|
| Working Set / RSS | 常驻在物理内存上的进程页面 | 操作系统视角的内存,并非 GC 堆本身 |
| Private Bytes / Commit | 进程私有的已提交内存 | 也包含原生内存、栈、JIT 代码、GC 段等 |
| GC Heap Size | 受管堆上的对象总量 | 观察 .NET 中 GC 管理内存的入口 |
| Total Allocated | 启动后累计分配的总量 | 基本上只会增加,不能单独用它来判断是否泄漏 |
| Gen 0 / Gen 1 / Gen 2 | 按代划分的堆 | 留在 Gen 2 中的对象属于长期存活 |
| LOH | 存放 85,000 字节以上大对象的堆 | 大型数组、字符串、缓冲区容易导致其增大 |
| POH | 用于固定(pinned)对象的堆 | 是观察原生互操作或固定化影响的线索 |
| Finalization Queue | 等待终结(finalize)的对象 | 是 Dispose 遗漏、终结器堵塞的线索 |
不需要一开始就把所有指标都看得很细。首先把问题拆解成下面的顺序。
进程内存在增加
↓
受管堆是否也在增加?
↓
GC 之后仍然存活的量是否在增加?
↓
是哪种类型在增加?
↓
是谁在引用它?
按这个顺序推进,就不容易把「表面上的内存增加」和「真正的泄漏」混为一谈。
4. 判断流程
在实务中,按下面的流程来排查会比较顺利。
1. 确定重现条件
- 是哪个 API、页面、任务、批处理会导致增加
- 执行多少次会增加
- 停止负载后会怎样
2. 用 dotnet-counters 观察趋势
- Working Set
- GC Heap
- Gen 2 / LOH
- Total Allocated
- GC 次数
3. 按时间差进行比较
- 启动后不久
- 预热之后
- 负载中
- 停止负载后
- 重复同样操作 N 次之后
4. 取两次以上的转储
- before
- after
- 条件允许的话,停止负载后也取一次
5. 寻找增加的类型
- dumpheap -stat
- gcdump report
- Visual Studio / PerfView
6. 确认引用来源
- gcroot
- gchandles
- finalizequeue
7. 得出判断
- GC 等待
- 正常的缓存增加
- 受管内存泄漏
- 原生内存问题
- LOH 碎片化或临时性大容量分配
重要的是不要根据单次数值就下判断。内存泄漏是一种「持续增加的趋势」,因此不能只看一个时间点的数值,而要在相同条件下做时间差比较。
5. 使用的工具
本文主要使用下面这些工具。
| 工具 | 使用场景 |
|---|---|
dotnet-counters |
观察运行中进程的 GC 与 Working Set 变化趋势 |
dotnet-gcdump |
以较轻的方式获取存活受管对象的统计信息 |
dotnet-dump |
详细查看堆,用 dumpheap 和 gcroot 一直追踪到引用来源 |
| Visual Studio Memory Usage | 想在 Windows 上用 GUI 方式比较时使用 |
| PerfView | 想在 Windows 上深入查看 GC / 堆 / 跟踪信息时使用 |
dotnet-trace |
想按时间序列追踪分配或 GC 事件时使用 |
首先安装 CLI 工具。
dotnet tool install --global dotnet-counters
dotnet tool install --global dotnet-dump
dotnet tool install --global dotnet-gcdump
dotnet tool install --global dotnet-trace
如果已经安装,则进行更新。
dotnet tool update --global dotnet-counters
dotnet tool update --global dotnet-dump
dotnet tool update --global dotnet-gcdump
dotnet tool update --global dotnet-trace
查找要排查的目标进程。
dotnet-counters ps
在后续示例中,用 <PID> 表示目标进程 ID。
在 Linux、macOS 或容器环境中,诊断工具需要与目标进程以同一用户身份运行。另外,根据环境不同,还可能受到 TMPDIR、诊断端口、容器 PID 命名空间等因素的影响。
在生产环境执行时,不要一开始就直接取转储,应先在验证环境中确认负载与影响。
6. 首先用 dotnet-counters 观察趋势
最开始要看的不是详细的转储,而是变化趋势。
dotnet-counters monitor \
--process-id <PID> \
--refresh-interval 3 \
--counters System.Runtime
输出内容会因 .NET 版本而略有不同。
.NET 9 及以后版本会以 System.Runtime 的 Meter 名称显示,.NET 8 及更早版本有时会以传统的 EventCounter 名称显示。
主要关注下面这些项目。
| 观察项目 | 观察内容 |
|---|---|
dotnet.process.memory.working_set |
操作系统视角下的进程常驻内存 |
dotnet.gc.last_collection.heap.size |
最近一次 GC 后按代划分的堆大小 |
dotnet.gc.last_collection.memory.committed_size |
GC 已提交的内存量 |
dotnet.gc.heap.total_allocated |
启动后累计分配的总量 |
dotnet.gc.collections |
按代统计的 GC 次数 |
dotnet.gc.pause.time |
GC 停顿时间的累计值 |
也可以先缩小范围,只监控指定的指标。
dotnet-counters monitor \
--process-id <PID> \
--refresh-interval 3 \
--counters System.Runtime[dotnet.process.memory.working_set,dotnet.gc.last_collection.heap.size,dotnet.gc.last_collection.memory.committed_size,dotnet.gc.heap.total_allocated,dotnet.gc.collections]
如果想留存以后查看,可以保存为 CSV。
dotnet-counters collect \
--process-id <PID> \
--refresh-interval 5 \
--format csv \
--output counters.csv \
--counters System.Runtime
此时想要观察的是下面几种不同的表现。
6.1 只有 Total Allocated 在增加
dotnet.gc.heap.total_allocated 是一个累计值。应用程序只要处理请求就会分配对象,即使分配出来的对象很快就不再需要并被 GC 回收,累计分配量依然会增加。
因此,仅凭 Total Allocated 增加并不能说是内存泄漏。真正该看的是分配之后是否还残留着。
Total Allocated:持续增加
GC Heap Size: 在一定范围内上下波动并保持稳定
Gen 2 / LOH: 不持续增加
这种情况与其说是泄漏,不如说是一个分配量较大的应用程序。
应对措施不是修复泄漏,而是减少分配、复用缓冲区、重新审视 LINQ 的过度使用、减少字符串生成、重新审视序列化处理等。
6.2 Working Set 增加,但 GC Heap 保持稳定
有时会出现 Working Set 或 RSS 在增加,但 GC Heap 保持稳定的情况。这种情况未必是受管对象的泄漏。
可能的原因大致有以下几种。
- JIT 编译后的代码
- 已加载的程序集
- 线程栈
- 原生库使用的内存
Marshal.AllocHGlobal等非受管内存- 图像处理、压缩、加密、数据库驱动等原生侧缓冲区
- 套接字、文件句柄、SSL、HTTP/2、gRPC 等内部缓冲区
- 操作系统只是还没有立刻从进程回收物理页面
在这种状态下,无论怎么看 dumpheap,有时也找不到真正的元凶。
判断的大致标准如下。
Working Set / RSS:增加
GC Heap Size: 稳定
Gen 2 / LOH: 稳定
这种情况下,应怀疑的不是 .NET 的受管堆泄漏,而是原生内存、句柄、线程数量、套接字、外部库。
不要只依赖 dotnet-counters,还要查看操作系统工具、容器指标、句柄数量、线程数量、原生堆以及外部库的指标。
6.3 GC Heap 增加,但停止负载后会恢复
负载期间 GC Heap 增加是很自然的现象。
请求量大。 临时对象多。 处理较大的 JSON。 临时创建列表或数组。
在这类情况下,堆会一直增长到下一次 GC 到来之前。停止负载后,GC 会执行,堆可能会恢复。
负载中: GC Heap 增加
停止负载后: GC Heap 下降,或恢复到某个固定值
反复多次后: 基线不会持续上升
这种情况可以判断为「只是还没被 GC 回收」或「临时分配较多」。
不过,如果负载期间的临时分配过多,会导致 GC 次数和停顿时间增加,从而演变成性能问题。 即使不是泄漏,也仍然是性能改进的对象。
6.4 GC 之后 Gen 2 / LOH 持续增加
需要特别留意的是下面这种模式。
重复同样的操作
↓
Gen 2 增加
↓
LOH 增加
↓
停止负载后也不恢复
↓
下一次测量时还在继续增加
Gen 2 是存放长期存活对象的代。 LOH 则是容易存放大型数组或字符串等对象的堆。
如果这部分持续增加,就应该怀疑存在泄漏、无限制缓存、持有巨大缓冲区、事件取消订阅遗漏、static 集合,以及长生命周期服务导致的持有。
到了这个阶段,就可以进入下一步。
7. 确认是否属于「GC 等待」的思路
要判断「是不是只是还没被 GC 回收」,需要观察经过充分的 GC 机会之后的状态。
但是,不应该轻易在生产代码中加入 GC.Collect()。
GC.Collect() 会强制执行 GC。尤其是全代的阻塞式 GC 会造成应用程序停顿。通常运行时应该基本交给 GC 自行处理。
尽管如此,在排查问题时,有时会在受控的验证环境中查看「强制 GC 之后是否还残留」。
如果是用于验证的控制台应用或重现环境,可以用下面这样的代码来查看完整 GC 之后的状态。
static void ForceFullGcForDiagnosticsOnly()
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
关键在于不要把它当作解决方案来用,它只是用于排查问题。
想要确认的是下面这个流程。
操作前
↓
重复操作 N 次
↓
停止负载
↓
充分等待,或在验证环境中诱发一次完整 GC
↓
GC 之后的堆是否恢复到接近操作前的水平
如果恢复了,那么很可能是 GC 等待或临时分配。如果没有恢复,并且每次重复同样的操作,基线都在上升,那就说明有什么东西在存活下来。接下来要用转储去查找这个「什么东西」。
8. 用 dotnet-gcdump 做轻量级比较
最初的比较,用 dotnet-gcdump 会比较方便。
dotnet-gcdump 可以从运行中的 .NET 进程获取 GC dump,用来查看堆上各类型的统计信息。
dotnet-gcdump collect --process-id <PID> --output before.gcdump
施加负载之后,再取一次。
dotnet-gcdump collect --process-id <PID> --output after.gcdump
也可以用 CLI 查看简单的报告。
dotnet-gcdump report before.gcdump > before-heap.txt
dotnet-gcdump report after.gcdump > after-heap.txt
要看的是各类型的 Count 和 Size。
例如,如果在 after 中某些类型大幅增加,就会成为排查对象。
Size (Bytes) Count Type
============ ===== ====
180,000,000 2,000,000 System.String
120,000,000 1,000,000 MyApp.Models.Customer
90,000,000 25,000 System.Byte[]
重要的不是「体量大的类型」,而是「增加了的类型」。
System.String 和 System.Byte[] 在很多应用里都会排在前面。
仅仅排在前面并不代表它就是元凶。
比较时的着眼点如下。
| before → after | 解读方式 |
|---|---|
| Count 几乎不变 | 该类型很可能不是主要元凶 |
| Count 和 Size 都增加 | 成为候选对象 |
MyApp.* 的类型在增加 |
容易怀疑是业务逻辑上的持有 |
System.Byte[] 增加 |
怀疑是缓冲区、序列化、图像、压缩、HTTP、数据库等 |
System.String 增加 |
怀疑是缓存、日志、JSON、字典键、重复字符串 |
Task、Timer、CancellationTokenSource 增加 |
怀疑是异步处理、Timer、取消令牌未释放 |
dotnet-gcdump 作为比较的入口很好用,但在获取时会诱发一次 Gen 2 GC。在堆较大或对延迟要求严格的环境中,要注意停顿时间和额外的内存消耗。
在 Windows 上,可以用 Visual Studio 或 PerfView 打开 .gcdump 文件进行比较。
在非 Windows 环境中,比较实际的做法是先用 CLI 的 report 查看类型统计,再用 dotnet-dump 深入追踪引用来源。
9. 用 dotnet-dump 查看堆与引用来源
一旦看清楚了「增加的类型」,接下来就要查看「为什么没有被回收」。
为此,需要用 dotnet-dump 取得转储,并用 SOS 命令进行分析。
dotnet-dump collect \
--process-id <PID> \
--type Heap \
--output myapp-1.dmp
过一段时间后,再取一次。
dotnet-dump collect \
--process-id <PID> \
--type Heap \
--output myapp-2.dmp
获取转储是一项很重的操作。 尤其是 Full / Heap 转储体积较大,会给进程或容器带来负担。在生产环境获取时,要注意时间段、磁盘容量、容器的内存限制,以及是否混入了个人信息或机密信息。
对获取到的转储进行分析。
dotnet-dump analyze myapp-2.dmp
首先查看整个堆的统计信息。
> dumpheap -stat
输出的是每个类型的数量和大小。
MT Count TotalSize Class Name
00007f... 120000 3840000 MyApp.Models.Order
00007f... 250000 8000000 System.String
00007f... 10000 40000000 System.Byte[]
缩小到特定类型。
> dumpheap -stat -type MyApp.Models.Order
或者用特定的 MethodTable 缩小范围。
> dumpheap -mt <MT>
知道实例的地址之后,就可以查它的引用来源。
> gcroot <OBJECT_ADDRESS>
这是最关键的一步。用 gcroot 确认这个对象为什么还活着。
例如,假设看到了下面这样的引用路径。
static MyApp.CustomerCache._items
-> System.Collections.Concurrent.ConcurrentDictionary<string, Customer>
-> MyApp.Models.Customer
-> System.String
在这种情况下,GC 不回收的原因就很明确了。Customer 被一个 static 缓存引用着,在 GC 看来它仍处于使用中。
到了这一步,才能做出下面这些判断。
- 这个缓存真的有必要吗
- 有没有设置上限
- 有没有设置有效期
- 键的设计是否会持续增长
- 是否以租户、用户、日期、请求 ID 等作为键,导致无限增长
排查内存泄漏时重要的是,不能止步于 dumpheap -stat。
dumpheap -stat 告诉你的是「什么东西数量多」,而 gcroot 告诉你的是「为什么还残留着」。真正能引出修复方案的是后者。
10. 判断方法速查表
整理实务中常见的模式。
| 观测 | 可能性 | 接下来要看的内容 |
|---|---|---|
| 只有 Total Allocated 在增加 | 正常分配,或分配量过大 | Allocation Rate、GC 次数、CPU、dotnet-trace |
| Working Set 增加但 GC Heap 稳定 | 原生内存、JIT、栈、操作系统侧的持有 | 线程数量、句柄数量、原生工具、外部库 |
| GC Heap 只在负载期间增加,停止后恢复 | GC 等待、临时分配 | 停止负载后的 Gen 2 / LOH、GC 次数 |
| GC 后的 Gen 2 持续增加 | 长生命周期对象的持有 | dumpheap -stat、gcroot |
| LOH 持续增加 | 大型数组、缓冲区、碎片化、巨大字符串 | System.Byte[]、System.Char[]、LOH、Free 区域 |
System.String 体量大 |
字符串缓存、JSON、日志、字典键 | 寻找持有这些字符串的自有类型 |
System.Byte[] 体量大 |
缓冲区、序列化、图像、压缩、通信 | 持有它的类型、ArrayPool 未归还、原生互操作 |
Task 增加 |
未完成的异步处理、等待队列 | async 的等待、取消、通道、队列 |
Timer 增加 |
Timer 未释放 | Dispose、取消注册、长生命周期服务 |
CancellationTokenSource 增加 |
CTS 未释放、链接的 token 过多 | Dispose、解除链接、超时生成的位置 |
EventHandler 或 delegate 残留 |
事件取消订阅遗漏 | publisher / subscriber 的生命周期差异 |
| Finalization Queue 增加 | Dispose 遗漏、终结器堵塞 |
finalizequeue、终结器线程 |
| Pinned handle 较多 | 固定的缓冲区、原生互操作 | gchandles、POH、固定化的位置 |
11. 常见的泄漏形态
11.1 static 集合
这是最容易理解的一种形态。
public static class CustomerStore
{
private static readonly List<Customer> Customers = new();
public static void Add(Customer customer)
{
Customers.Add(customer);
}
}
在这段代码中,添加到 Customers 里的 Customer,只要进程还活着,就会一直残留下去。即便本意只是临时保存,只要还被 static 引用,GC 就不会回收它。
修复方向会因用途而不同。
- 设置上限
- 设置有效期
- 使用
MemoryCache之类的缓存机制 - 显式删除
- 放弃 static,改用具有合适生命周期的服务
- 如果目的是持久化,改用数据库或外部存储
重要的不是「static 就是坏东西」,而是要理解放在 static 中的东西会变成长生命周期这一特性,并据此来使用它。
11.2 无限制缓存
缓存本来就是有意地使用内存,如果增长是按设计发生的,那就不是泄漏。但是,没有上限或期限的缓存,实质上就等同于内存泄漏。
public sealed class ReportCache
{
private readonly Dictionary<string, Report> _cache = new();
public Report GetOrCreate(string userId, DateTime date)
{
var key = $"{userId}:{date:O}";
if (_cache.TryGetValue(key, out var report))
{
return report;
}
report = BuildReport(userId, date);
_cache[key] = report;
return report;
}
}
在这个例子中,只要 userId 和 date 的组合不断增加,缓存也会持续增大。
尤其危险的是,键中包含下面这类值的情况。
- 请求 ID
- 当前时间
- GUID
- 会话 ID
- 未经规范化直接使用的用户输入字符串
- 直接把 SQL 或检索条件字符串化的结果
应提前为缓存设定好下面这些条件。
| 条件 | 示例 |
|---|---|
| 最大条数 | 最多 10,000 条 |
| 最大容量 | 最多 256MB |
| 有效期 | 距最后一次访问 30 分钟 |
| 绝对期限 | 创建后 6 小时 |
| 销毁条件 | 租户删除、用户删除、设置变更 |
| 监控项目 | 条数、预估大小、命中率、淘汰次数 |
不能满足于「反正是缓存所以可以增加」,而必须明确「最多能增加到什么程度」。
11.3 事件取消订阅遗漏
如果生命周期长的 publisher 一直引用着生命周期短的 subscriber,事件就会造成泄漏。
public sealed class OrderViewModel
{
private readonly OrderService _service;
public OrderViewModel(OrderService service)
{
_service = service;
_service.OrderChanged += OnOrderChanged;
}
private void OnOrderChanged(object? sender, OrderChangedEventArgs e)
{
// update view model
}
}
如果 OrderService 是 singleton,而 OrderViewModel 是按每个界面创建的,那么 OrderService 的事件就会一直引用着 OrderViewModel。即使关闭了界面,只要不取消订阅,ViewModel 就会一直残留。
下面是修复示例。
public sealed class OrderViewModel : IDisposable
{
private readonly OrderService _service;
public OrderViewModel(OrderService service)
{
_service = service;
_service.OrderChanged += OnOrderChanged;
}
public void Dispose()
{
_service.OrderChanged -= OnOrderChanged;
}
private void OnOrderChanged(object? sender, OrderChangedEventArgs e)
{
// update view model
}
}
在 gcroot 中,有时会看到通过 delegate 或 event handler 的引用形式。
这种模式常见于 WPF、WinForms、长生命周期服务、消息代理、事件聚合器中。
11.4 Timer 未释放
System.Threading.Timer、PeriodicTimer,以及 Reactive Extensions 的 subscription 等,如果不释放,也会一直残留。
public sealed class PollingWorker
{
private readonly Timer _timer;
public PollingWorker()
{
_timer = new Timer(_ => Poll(), null, TimeSpan.Zero, TimeSpan.FromSeconds(10));
}
private void Poll()
{
// polling
}
}
如果这个 PollingWorker 本意是临时对象,就需要设计成能释放 Timer。
public sealed class PollingWorker : IDisposable
{
private readonly Timer _timer;
public PollingWorker()
{
_timer = new Timer(_ => Poll(), null, TimeSpan.Zero, TimeSpan.FromSeconds(10));
}
public void Dispose()
{
_timer.Dispose();
}
private void Poll()
{
// polling
}
}
Timer 持有回调的 delegate,有时会通过它一直连接到目标对象的引用。
11.5 IDisposable 未释放
IDisposable 未释放,未必总会表现为受管堆上的泄漏。
有时会以文件、套接字、数据库连接、原生句柄、缓冲区等资源问题的形式出现。
public async Task<string> ReadAsync(string path)
{
var stream = File.OpenRead(path);
using var reader = new StreamReader(stream);
return await reader.ReadToEndAsync();
}
这个例子中 StreamReader 会关闭 stream,所以通常不会造成大问题,但所有权不明确的代码就容易发生泄漏。
基本原则是用 using / await using 明确所有权。
public async Task<string> ReadAsync(string path)
{
await using var stream = File.OpenRead(path);
using var reader = new StreamReader(stream);
return await reader.ReadToEndAsync();
}
Dispose 遗漏,会表现为下面这些症状。
- 句柄数量增加
- 套接字数量增加
- 文件无法关闭
- 原生内存增加
- Finalization Queue 增加
- GC Heap 稳定,但进程内存却在增加
这种情况下,只看 dumpheap 是不够的。
还需要查看操作系统的句柄、套接字,以及外部库的状态。
11.6 AsyncLocal 与上下文持有
AsyncLocal<T> 很方便,但如果放入的内容较大,有时会长时间残留。
如果放入的是日志关联 ID 之类较小的值,通常不会有问题。 但如果放入用户信息、请求体、大型 DTO、数据库上下文之类的内容,就会导致意料之外的持有。
public static class RequestContext
{
public static readonly AsyncLocal<RequestInfo?> Current = new();
}
由于 AsyncLocal 是随异步流程传递的,有时会比普通的 static 字段更难被发现。
应考虑让放入的内容尽量小而明确,并在不再需要时将其重置为 null。
11.7 DI 生命周期用错
在 ASP.NET Core 等框架的 DI 中,singleton、scoped、transient 的生命周期各不相同。
如果长生命周期的 singleton 持有了按请求划分的数据,即使请求结束,对象也可能仍然残留。
public sealed class AuditBuffer
{
private readonly List<RequestAudit> _items = new();
public void Add(RequestAudit item)
{
_items.Add(item);
}
}
如果这是 singleton,那么 _items 的生命周期就和整个应用相同。
如果设计上确实需要做缓冲,就需要有上限、发送、删除、背压等机制。 如果只是「以后可能会看一下」这种程度,就应该把它输出到日志或外部存储中。
12. LOH 特别容易被误解
LOH 是 Large Object Heap(大对象堆)的缩写。 在 .NET 中,大对象会被放在与普通小对象不同的独立堆上。 最典型的例子就是大型数组。
var buffer = new byte[1024 * 1024 * 10]; // 10MB
LOH 中常见的问题有以下三种。
- 频繁创建大对象
- 长期持有大对象
- 大对象的创建与销毁导致碎片化
LOH 在增加,也未必立刻就是泄漏。如果设计上是在重用大型缓冲区,堆可能增长到某个大小之后就趋于稳定;而且即便 GC 已经回收,Working Set 也未必会立刻下降。
不过,出现下面这些状态时应该产生怀疑。
System.Byte[]随每次操作不断增加System.Char[]或巨大的String在增加- 图像、PDF、Excel、ZIP、加密、压缩处理之后未恢复
- 用
ArrayPool<T>.Rent借出的数组没有归还 - 把巨大的响应整体加载到内存中
- 大量使用
MemoryStream.ToArray()
使用 ArrayPool<T> 时,必须记得归还。
var pool = ArrayPool<byte>.Shared;
var buffer = pool.Rent(1024 * 1024);
try
{
// use buffer
}
finally
{
pool.Return(buffer);
}
不过,归还给对象池并不意味着进程内存会立刻下降。 对象池有时会为了重用而继续持有内存。
这里同样需要关注的是「是否持续增加」「是否有上限」「是否被重用」。
13. gcroot 的解读方法
gcroot 会显示某个对象正被谁引用着。
下面把典型的引用根整理成表格。
| 根(Root) | 含义 |
|---|---|
| static field | 被类型的 static 字段引用 |
| local variable / stack | 被正在执行的线程栈引用 |
| GC handle | 被 GCHandle、pin、delegate、互操作等引用 |
| finalization queue | 由于等待终结而被持有 |
| thread / async state machine | 被正在执行或等待中的异步处理持有 |
排查时最该关注的点,是生命周期的差异。
长生命周期对象
-> 本应是短生命周期的对象
如果出现这种形态,就是泄漏候选。
例如,下面这种情况值得怀疑。
SingletonService
-> List<RequestContext>
-> RequestContext
-> LargeDto
SingletonService 存活于整个应用程序的生命周期中。
如果其中不断积累着按请求划分的 RequestContext,就需要重新审视设计。
另一方面,根据时机不同,下面这种引用根是正常的。
Thread stack
-> Controller action local variable
-> RequestDto
如果正处于请求处理过程中,局部变量还残留着是理所当然的。
因此,取转储的时机非常重要。
不要只在负载期间取,负载停止后、队列清空后、静置一段时间之后也取一次转储,会更容易做出判断。
14. 「强制 GC 之后下降了就算解决」是错误的
排查过程中调用了 GC.Collect(),内存下降了。这时如果因此认为「那就定期调用 GC.Collect() 好了」,是很危险的想法。
强制 GC 并没有消除根本原因,只是当场回收了原本尚未回收的对象而已。
如果问题在于分配率过高,强制 GC 会增加停顿时间,让性能变差。 如果是真正的泄漏,仍被引用的对象即使强制 GC 也不会被回收。
排查时应该关注的是下面这些差异。
| 强制 GC 后 | 判断 |
|---|---|
| 大幅下降,之后基线保持稳定 | 主要原因是 GC 等待,或临时分配 |
| 略有下降,但每次重复后底线都在上升 | 有一部分存活下来了,属于泄漏候选 |
| 几乎不下降 | 仍被持续引用,或主要原因不在 GC 堆上 |
| GC Heap 下降但 Working Set 不下降 | 可能是操作系统 / GC 段 / 原生侧的持有 |
在生产环境中定期执行 GC.Collect() 之前,务必先确定「到底是什么在增加」。
15. 实务中使用的排查步骤
接下来,把实际排查时的步骤整理出来。
15.1 固定重现场景
首先,固定排查条件。
对象: /api/report/export
操作: 以相同条件执行 100 次
测量间隔: 5 秒
观测时间: 预热 5 分钟 + 负载 10 分钟 + 空闲 5 分钟
环境: staging / Release build / 与生产环境相当的配置
在排查内存问题时,如果每次都做不同的操作,就无法做出判断。要固定「做了什么才导致增加」。
15.2 获取基线
不要用刚启动时,而要以预热之后的状态作为基线。
原因是刚启动时会有下面这些只发生一次的增长。
- JIT
- DI 容器构建
- 加载配置
- 首次数据库连接
- 首次 TLS / HTTP 连接
- JSON 序列化器的元数据生成
- Razor / 模板的初始化
- 日志记录器与指标系统的初始化
顺序如下。
1. 启动应用
2. 调用几次健康检查或代表性的 API
3. 等待约 1~5 分钟
4. 取一份 counters 和 dump,作为基线
15.3 在负载期间获取 counters
dotnet-counters collect \
--process-id <PID> \
--refresh-interval 5 \
--format csv \
--output report-export-counters.csv \
--counters System.Runtime
同时进行重现操作。要看的是曲线的形状。
接近正常的形态:
负载期间增加
随 GC 上下波动
停止负载后恢复
基线不会持续上升
可疑的形态:
与操作次数成比例地增加
Gen 2 / LOH 的谷底不断抬高
停止负载后也不恢复
下一次负载时谷底进一步抬高
15.4 取两次转储
在负载前后各取一次。
dotnet-dump collect --process-id <PID> --type Heap --output before.dmp
# 施加负载
dotnet-dump collect --process-id <PID> --type Heap --output after.dmp
如果条件允许,停止负载后也取一次。
# 停止负载、队列清空,等待一段时间之后
dotnet-dump collect --process-id <PID> --type Heap --output idle-after.dmp
比较时,不仅要看 before 和 after,idle-after 也很重要。
即使负载期间有所增加,如果空闲之后恢复了,就有可能不是泄漏。
15.5 查看增加的类型
dotnet-dump analyze after.dmp
> dumpheap -stat
对 before 一侧也做同样的查看。
手动比较也可以,首先比较排名靠前的类型。
列举一下要看的角度。
- 自家命名空间下的类型是否在增加
System.String背后是否隐藏着自家类型System.Byte[]是被谁持有的List<T>或Dictionary<TKey,TValue>是否在增加Task或 async state machine 是否在增加Timer或CancellationTokenSource是否在增加
15.6 查看引用来源
取出候选对象的地址,对其执行 gcroot。
> dumpheap -type MyApp.Models.ReportResult
> gcroot <OBJECT_ADDRESS>
根据 gcroot 的结果,找出持有它的上级对象。
MyApp.Services.ReportCache
-> Dictionary<string, ReportResult>
-> ReportResult
到了这一步,代码审查的重点对象就浮现出来了。
ReportCache是不是 singleton- 有没有上限
- 会不会被删除
- 键是否会持续增加
- ReportResult 是否过大
- 是否应该转移到数据库或文件,而不是放在缓存里
16. 使用 dotnet-trace 的场景
dotnet-dump 提供的是某一瞬间的快照,适合用来查看「结果上还残留着什么」。而如果想知道「什么时候、在哪里进行了大量分配」,就应该使用 dotnet-trace。
例如,可以连同 GC 相关事件一起进行跟踪。
dotnet-trace collect \
--process-id <PID> \
--duration 00:00:01:00 \
--clrevents gc+gchandle \
--clreventlevel informational \
--output gc-trace.nettrace
如果还想看到分配采样,由于事件量会增加,应先在验证环境中从短时间开始尝试。
dotnet-trace collect \
--process-id <PID> \
--duration 00:00:00:30 \
--clrevents gc+gcsampledobjectallocationhigh \
--clreventlevel informational \
--output allocation-trace.nettrace
跟踪信息能从与转储不同的角度提供帮助。
| 想看的内容 | 适合的工具 |
|---|---|
| 残留着什么 | dump / gcdump |
| 谁在引用 | dump + gcroot |
| 什么时候进行了大量分配 | trace |
| GC 发生的时间点 | counters / trace |
| 停顿时间是否成为问题 | counters / trace |
在排查泄漏时,先用 dump 查看「残留下来的东西」,如有必要再用 trace 查看「制造它的地方」,这样效率更高。
17. 在代码中输出用于确认的指标
正式的诊断应该用外部工具进行,但在应用侧预先加入一些简单的诊断日志也会很有用。
例如,可以通过面向管理员的端点或定期日志来输出 GC 信息。
public static class GcDiagnostics
{
public static object Snapshot()
{
var info = GC.GetGCMemoryInfo();
return new
{
TotalMemory = GC.GetTotalMemory(forceFullCollection: false),
HeapSizeBytes = info.HeapSizeBytes,
FragmentedBytes = info.FragmentedBytes,
MemoryLoadBytes = info.MemoryLoadBytes,
HighMemoryLoadThresholdBytes = info.HighMemoryLoadThresholdBytes,
Gen0Collections = GC.CollectionCount(0),
Gen1Collections = GC.CollectionCount(1),
Gen2Collections = GC.CollectionCount(2)
};
}
}
仅凭这些信息无法判定是否泄漏。不过,出故障时会更容易做出下面这些判断。
- Gen 2 是否在急剧增加
- HeapSize 是否在增加
- FragmentedBytes 是否在增加
- TotalMemory 与进程内存之间的差值是否很大
- 部署之后趋势是否发生了变化
加入应用日志时,要注意不要输出过多。 如果高频率地执行较重的诊断,其本身也会造成负担。
18. 断定为「内存泄漏」的判断标准
排查的最后,应该能用下面这种形式来说明。
现象:
执行 100 次 /api/report/export 后,停止负载后 GC Heap 仍然增加了 300MB 并未恢复。
观测:
用 dotnet-counters 观察到 Gen 2 的 heap size 与操作次数成比例增加。
不仅 Working Set,GC Heap 也在增加。
比较:
对比 before.dmp 与 after.dmp,发现 MyApp.Models.ReportResult 增加了 12,000 个。
引用来源:
用 gcroot 确认到是被 MyApp.Services.ReportCache._items 引用着。
原因:
ReportCache 是 singleton,以用户 ID + 当前时间作为键,没有设置删除、期限、上限。
对策:
替换为 MemoryCache,设置了容量上限和有效期。
把缓存条数纳入了指标监控。
能够说明到这个程度,就不再只是一句「内存在增加」,而是一份把重现条件、观测数值、增加的类型、引用来源、原因、对策全部串联起来的完整报告。
19. 排查时的注意事项
19.1 用 Release 构建来查看
Debug 构建会因为优化方式、局部变量的生命周期、调试信息的影响,而与实际运行时的表现不同。
在与生产环境相当的排查中,应该用 Release 构建、接近实际运行的配置、接近实际的数据量来确认。
19.2 不要只凭刚启动时的状态下判断
刚启动时,各种初始化都会导致内存增加。
应先取得预热之后的基线,再观察之后是否有增加。
19.3 不要仅凭一次转储就断定元凶
排在堆顶部的类型,未必就是元凶。
System.String 和 System.Byte[] 在很多应用中看起来都很庞大。
重要的是它是否随时间推移而增加,以及是被谁持有着。
19.4 转储中会包含机密信息
内存转储中可能包含请求内容、认证信息、连接字符串、个人信息、业务数据。
应事先确定好保存位置、外发、共享、删除等相关规则。
19.5 在容器中获取转储存在风险
如果容器的内存限制较严格,获取转储所产生的额外内存占用或页面换入,有可能触发 OOM Kill。
在生产容器中获取之前,应先在 staging 环境中试验,确认内存限制值、磁盘容量、权限、PID 命名空间。
19.6 也存在 GC Heap 之外的泄漏
即便是在排查 .NET 的问题,也不是所有问题都会体现在 GC 堆上。
在下面这些问题中,即使 GC Heap 保持稳定,进程内存也可能在增加。
- 原生库
- P/Invoke
- COM
- 图像处理
- 压缩库
- 加密处理
- 数据库驱动
- 套接字
Marshal.AllocHGlobalNativeMemory.Alloc- 线程过多
这种情况下,仅靠 dotnet-dump 的 dumpheap 是不够的。
需要查看操作系统侧的诊断信息、外部库的指标、句柄、线程、原生内存。
20. 修复后的确认
修复看起来存在泄漏的地方之后,要用同样的步骤重新测量。
修复前:
执行 100 次后,Gen 2 增加了 +300MB
ReportResult 增加了 +12,000 个
修复后:
执行 100 次后,Gen 2 稳定在 +20MB 以内
ReportResult 在停止负载后恢复到基线
缓存条数稳定在上限 1,000 条
在确认修复效果时,必须使用相同的条件进行比较。
- 相同的数据量
- 相同的次数
- 相同的负载时长
- 相同的预热方式
- 相同的观测间隔
- 相同的工具
内存排查如果缺乏修复前后的有力比较,说服力就会不足。
21. 总结
当 .NET 中出现内存增加时,不要立刻断定为泄漏,而应按下面的顺序逐步排查。
- 不要只凭 Working Set / RSS 就下判断
- 用
dotnet-counters观察 GC Heap、Gen 2、LOH、GC 次数 - 在负载中、停止负载后,按时间差进行比较
- 用
dotnet-gcdump或dotnet-dump查看增加的类型 - 用
gcroot查看引用来源 - 检查 static、缓存、事件、Timer、DI 生命周期、异步上下文
- 如果 GC Heap 保持稳定,也要怀疑原生内存或操作系统侧的问题
「只是还没被 GC 回收」和「真正发生了内存泄漏」之间的区别,最终取决于引用关系。
如果不再需要的对象没有被引用,就会在 GC 时机到来时被回收。 如果本应不再需要却持续被引用,GC 就无法回收它。
也就是说,排查的目标是这样的。
什么在增加?
哪些在每次 GC 之后仍然残留?
是谁在引用它?
这种引用在设计上是否必要?
弄清楚到这一步,就不会再被内存曲线牵着走,而能把问题落实到代码上的具体修改点。
参考资料
- 本文的示例代码全集(库、演示、单元测试) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/dotnet-gc-or-memory-leak
- .NET: Fundamentals of garbage collection
- .NET: Debug a memory leak
- .NET CLI: dotnet-counters diagnostic tool
- .NET CLI: dotnet-dump diagnostic tool
- .NET CLI: dotnet-gcdump diagnostic tool
- .NET CLI: dotnet-trace diagnostic tool
- .NET: Induced collections
- .NET: Large object heap
- .NET: Workstation and server garbage collection
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
PDB(程序数据库)是什么 ── 理解调试信息、符号与 Source Link
整理 PDB(Program Database / 程序数据库)到底是什么、里面有什么、没有什么,以及它与 Debug / Release、Portable PDB、Source Link、符号服务器、Dump 分析之间的关系,供实务参考。
TCP 中「发送单位=接收单位」的误区 ── 用字节流思维设计接收端,避免拆包与粘包
在 TCP 通信中,如果误以为调用 Send / Write 的单位可以直接对应到接收端的单位,就会出现拆包、粘包、乱码、协议损坏等问题。本文整理把 TCP 当作字节流、在应用层自行进行消息分帧(framing)的思路,并给出 .NET / C# 的实现示例。
正确处理 Windows 的模拟令牌 ── 线程级权限借用与安全的还原方式
本文围绕 Windows 的模拟令牌,梳理访问令牌、主令牌、线程令牌、模拟级别、RevertToSelf,直至 .NET 的 WindowsIdentity.RunImpersonated,整理在实务中安全处理它们的思路。
用 PerfView 与 dotnet-trace 定位“变慢”的原因 ── .NET 性能排查实务入门
当业务应用出现“变慢”“CPU 占满”“偶尔卡死”时,该用哪个工具看什么?本文梳理 PerfView 与 dotnet-trace 的分工、CPU 采样的解读方法(inclusive/exclusive)、用 ThreadTime 排查阻塞时间,以及与 EventSourc...
Windows 事件日志・ETW 入门 ── 让业务应用程序的日志接入操作系统标准机制
Windows 业务应用程序的日志,是不是只靠文件日志来解决?事件日志与 ETW,是运维人员和操作系统标准工具所能看到的另一层记录。本文整理三种手段的分工方式、.NET 中的写入方法、通过 EventSource 进行的 ETW 埋点、收集与排查的实务做法,以及来源未注册、...
常见问题
汇总了咨询这一主题时常见的问题。
- .NET 应用程序的内存使用量增加,就是内存泄漏吗?
- 进程内存增加,和发生内存泄漏并不是同一件事。.NET 的 GC 会根据分配情况和堆的阈值来运作,因此可能只是不再使用的对象还没被 GC 回收,也可能是 GC 之后操作系统没有立刻回收内存。真正该看的是三点:GC 之后仍然存活的内存是否在增加、增加的是哪种类型、以及这些对象被谁引用着。
- 要排查 .NET 的内存泄漏,应该用哪些工具?
- 首先用 dotnet-counters 观察 Working Set、GC Heap、Gen 2/LOH、GC 次数的变化趋势。接着用 dotnet-gcdump 在施加负载前后分别取得 GC dump,比较各类型的 Count 与 Size 找出增加的类型。最后用 dotnet-dump 取堆转储,通过 dumpheap -stat 和 gcroot 一直追踪到「为什么没有被回收」的引用来源。重要的不是单次数值,而是在相同条件下做时间差比较。
- .NET 中常见的内存泄漏模式有哪些?
- 典型例子包括:一直往 static 集合中添加而不清理、没有上限或有效期的无限制缓存、对长生命周期 publisher 忘记取消订阅事件、Timer 忘记释放、IDisposable 忘记释放、singleton 保存了本应按请求划分生命周期的数据(DI 生命周期用错)等。与其说 .NET 的泄漏是「忘记释放」,不如理解成「已经不需要却仍被引用」的意外持有,会更容易把握。
- 定期执行 GC.Collect() 能解决内存问题吗?
- 不能解决。强制 GC 只是当场回收本来就该回收的对象,并没有消除根本原因。如果问题是分配速率过高,强制 GC 反而会增加停顿时间,让性能变差;如果是真正的内存泄漏,仍被引用的对象即使强制 GC 也不会被回收。在受控的验证环境中,为了调查而查看「强制 GC 后是否还残留」是可以的,但在把它作为解决方案放入生产环境之前,必须先确定到底是什么在增加。
作者简介
本文作者的个人简介页面。
Go Komura
小村软件有限公司 代表
以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。