如何系统调查 .NET 进程的内存占用:从 Working Set、GC Heap 到内存泄漏定位
docker stats 显示一个 ASP.NET Core 容器占用了 450 MiB。这个数字值得调查,但它不是泄漏证据。工作集里既有托管对象,也有 GC 预留空间、JIT 代码、线程栈、程序集、原生库和内存映射页。只盯着容器总量,很容易把正常的运行时成本当成业务对象没有释放。
调查应该沿着一条证据链前进:先确认内存是否持续增长,再判断增长是否发生在托管堆;如果托管堆确实增长,找出不断增加的对象类型和引用链;只有托管对象解释不了工作集时,才转向线程栈、JIT 和原生内存。下面用一个可运行的 ASP.NET Core 示例走完整个过程。
Working Set、GC Heap 与 GC Committed 分别是什么
这些指标经常同时出现在 dotnet-counters 中,却回答不同问题。Microsoft 在 System.Runtime EventCounters 列表中给出了 .NET 6 等版本使用的计数器定义;.NET 9 以后还可以参考新的 .NET Runtime Metrics 名称。
- Working Set 是映射到当前进程上下文的物理内存。它覆盖整个进程,不只包含 GC 管理的对象,所以不能用“Working Set 减少了多少”判断一次 GC 回收了多少对象。
- GC Heap Size 是运行时估算的已分配托管内存。
gc-heap-sizeEventCounter 基于GC.GetTotalMemory(false),不包含线程栈、JIT 代码和原生库。 - GC Committed Bytes 是 GC 已提交并有后备存储的托管堆内存。它包括存放现有对象的空间,也可能包括为后续分配准备的空间。对象被回收后,这个值不一定马上下降。
- Allocation Rate 是每个采样区间分配到托管堆的字节数。它描述“创建得有多快”,不描述“最终留下多少”。分配率很高但 GC Heap 能反复回落,通常是分配压力。
- Gen 0、Gen 1 和 Gen 2 GC Count 记录各代发生了多少次回收。.NET GC 基础文档说明,Gen 2 会同时回收更年轻的代,因此也称完整 GC。
- Large Object Heap 保存大对象,默认阈值是 85,000 字节。LOH 与 Gen 2 一起回收,频繁分配大型数组会增加完整 GC 压力,并可能产生碎片。
观察范围可以简化成下面这张图:
Working Set
├── GC 管理的内存
│ ├── 当前托管对象与堆内碎片
│ └── GC 已提交、等待复用的空间
└── JIT、线程栈、程序集、原生库、映射页等Working Set 很高、GC Heap 很低,调查方向在托管堆之外或 GC 已提交空间;GC Heap 在完整 GC 后仍持续增长,才有理由怀疑托管对象被长期持有。
建立一个能重复观察的 ASP.NET Core 示例
创建一个空项目:
dotnet new web -n MemoryLab用下面的代码替换 MemoryLab/Program.cs:
using System.Collections.Concurrent;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var retainedBatches = new ConcurrentDictionary<Guid, byte[][]>();
app.MapPost("/allocate/temporary/{megabytes:int}", (int megabytes) =>
{
if (megabytes is < 1 or > 512)
return Results.BadRequest("megabytes must be between 1 and 512");
var buffers = Allocate(megabytes);
return Results.Ok(new { allocatedMiB = megabytes, retained = false });
});
app.MapPost("/allocate/retained/{megabytes:int}", (int megabytes) =>
{
if (megabytes is < 1 or > 512)
return Results.BadRequest("megabytes must be between 1 and 512");
var id = Guid.NewGuid();
retainedBatches[id] = Allocate(megabytes);
return Results.Ok(new { id, allocatedMiB = megabytes, retained = true });
});
app.MapDelete("/allocate/retained", () =>
{
var count = retainedBatches.Count;
retainedBatches.Clear();
return Results.Ok(new { clearedBatches = count });
});
app.MapGet("/memory", () =>
{
var gc = GC.GetGCMemoryInfo();
return Results.Ok(new
{
workingSetMiB = ToMiB(Environment.WorkingSet),
gcHeapMiB = ToMiB(GC.GetTotalMemory(false)),
gcCommittedMiB = ToMiB(gc.TotalCommittedBytes),
gen0Collections = GC.CollectionCount(0),
gen1Collections = GC.CollectionCount(1),
gen2Collections = GC.CollectionCount(2),
retainedBatches = retainedBatches.Count
});
});
app.Run();
static byte[][] Allocate(int megabytes)
{
var buffers = new byte[megabytes][];
for (var index = 0; index < buffers.Length; index++)
{
buffers[index] = new byte[1024 * 1024];
buffers[index].AsSpan().Fill(0x5A);
}
return buffers;
}
static double ToMiB(long bytes)
{
return Math.Round(bytes / 1024d / 1024d, 2);
}每个数组是 1 MiB,会进入 LOH。temporary 接口只在当前请求中保留数组,请求结束后 GC 可以回收;retained 接口把数组放进应用生命周期内的 ConcurrentDictionary,数组会一直存活,直到调用 DELETE 接口。代码把单次分配限制在 512 MiB,实际测试仍应从 20 或 50 MiB 开始,避免小内存容器直接 OOM。
GC.GetGCMemoryInfo() 返回最近一次 GC 观察到的信息。如果进程还没有发生过 GC,部分字段可能是 0;执行分配请求并发生回收后再读取,才适合与计数器对照。
启动程序并制造两种内存变化:
dotnet run --project MemoryLab -- --urls http://127.0.0.1:5080curl http://127.0.0.1:5080/memory
curl -X POST http://127.0.0.1:5080/allocate/temporary/100
curl -X POST http://127.0.0.1:5080/allocate/retained/100
curl -X POST http://127.0.0.1:5080/allocate/retained/100
curl http://127.0.0.1:5080/memory最后一次查询中,retainedBatches 应为 2。两次请求一共保留了约 200 MiB 数组,GC Heap 会略高,因为还有数组对象、字典节点和应用自身对象。
用 dotnet-counters 判断是波动还是持续增长
先按照 dotnet-counters 官方文档把诊断工具记录到项目 Tool Manifest。仓库已经有 .config/dotnet-tools.json 时跳过第一条,新环境执行 dotnet tool restore 即可:
dotnet new tool-manifest
dotnet tool install dotnet-counters
dotnet tool install dotnet-gcdump
dotnet tool install dotnet-dump找到进程并开始监视。假设 PID 是 12345:
dotnet tool run dotnet-counters ps
dotnet tool run dotnet-counters monitor --process-id 12345 --refresh-interval 1 --counters System.Runtime调用 temporary/100 时,Allocation Rate 会在请求期间上升,GC Heap 和 Working Set 也可能变大;后续发生 GC 时,GC Heap 应当回落。Working Set 和 GC Committed Bytes 可能保持在较高位置,因为运行时可以留下已经提交的段供下一轮分配复用。
连续调用几次 retained/100 后,要观察的不是峰值,而是负载周期结束后的低点。如果 GC Heap 每轮增加约 100 MiB,Gen 2 GC 后也降不下来,同时 retainedBatches 持续增加,那么对象确实被应用保留。在这个示例中,这些数组已经没有业务用途,所以属于故意制造的泄漏。
需要留下证据时,采集一分钟 CSV:
dotnet tool run dotnet-counters collect --process-id 12345 --refresh-interval 1 --duration 00:01:00 --format csv --output dotnet-memory.csv --counters System.Runtime同一份 CSV 至少比较 Working Set、GC Heap Size、GC Committed Bytes、Allocation Rate、Gen 2 GC Count 和 LOH Size。一个时点只能描述状态,连续样本才能显示趋势。
用 gcdump 比较完整 GC 后存活的对象
dotnet-gcdump 会通过 EventPipe 触发一次 Gen 2 GC,并重建存活对象和 GC Root 图。官方文档列出的典型用途就是比较多个时间点的对象数量和堆统计。
在没有长期持有数组时采集第一份,执行两次 retained/100 后采集第二份:
dotnet tool run dotnet-gcdump collect --process-id 12345 --output memory-01.gcdump
dotnet tool run dotnet-gcdump report memory-01.gcdump
dotnet tool run dotnet-gcdump collect --process-id 12345 --output memory-02.gcdump
dotnet tool run dotnet-gcdump report memory-02.gcdump第二份报告中,System.Byte[] 的数量和总大小应明显增加。清空字典后再采集:
curl -X DELETE http://127.0.0.1:5080/allocate/retained
dotnet tool run dotnet-gcdump collect --process-id 12345 --output memory-03.gcdump
dotnet tool run dotnet-gcdump report memory-03.gcdump第三次采集触发完整 GC,大数组应被回收。GC Heap 的存活总量会下降,Working Set 却未必同步回到初始值:前者描述存活托管内存,后者覆盖整个进程,GC 也可能保留已经提交的段。
采集 gcdump 会触发完整 GC。Microsoft 文档还指出,事件缓冲区最多可能增长到 256 MiB,工具自身也需要内存。受限容器中要预留余量,避免诊断动作触发 OOM。
用 dotnet-dump 和 gcroot 找到持有者
类型统计只能证明 System.Byte[] 很多,不能证明是谁保留了它们。采集 Heap dump 并打开分析器:
dotnet tool run dotnet-dump collect --process-id 12345 --type Heap --output memory.dmp
dotnet tool run dotnet-dump analyze memory.dmp进入分析器后依次执行:
dumpheap -stat
dumpheap -type System.Byte[]
gcroot 0000000000000000第二条命令会列出数组实例。把第三条中的零地址替换为一个大小约 1 MiB 的实例地址。dotnet-dump 官方文档说明,gcroot 会显示从 GC Root 到目标对象的引用链。在这个示例中,引用链最终会指向保存批次的 ConcurrentDictionary;真实项目里的持有者常见于静态字段、单例缓存、未结束的 Task、事件订阅和没有边界的队列。
完整顺序是:dotnet-counters 证明低点持续抬高,gcdump 找到不断增加的类型,gcroot 找到让对象无法回收的引用。Dump 可能包含连接字符串、Token 和用户数据,也可能让操作系统临时调入大量页面,不能上传到公开位置。
当 GC Heap 解释不了 Working Set
假设一个进程的 Working Set 是 450 MiB,完整 GC 前 GC Heap 为 286 MiB,而 gcdump 触发完整 GC 后只剩 50.9 MiB 存活对象,Working Set 仍在 445 MiB 左右。这组数据不能证明进程永远没有泄漏,但已经排除了“有四百多 MiB 业务对象被长期持有”。剩余差额需要从 GC Committed Bytes、JIT、程序集、线程栈和原生内存继续解释。
如果 GC Committed 明显高于完整 GC 后的存活对象,运行时保留段占了一部分;如果 GC Committed 也不高,Working Set 却持续增长,调查范围要转向 P/Invoke、数据库或图像原生库、MemoryMappedFile、Socket/TLS 缓冲区和没有释放的非托管分配。
Linux 可以继续拆分进程内存映射:
cat /proc/12345/smaps_rollup
pmap -x 12345Windows 可以使用 Process Explorer、VMMap、Visual Studio 或 WinDbg。dotnet-dump 擅长 CLR 和托管堆,不是完整的原生内存分析器。
排除泄漏后再比较 GC 策略
GC 参数改变的是吞吐量、暂停和内存之间的取舍,不能修复被静态字典或事件订阅持有的对象。确认存活对象没有持续增长后,低并发、长时间空闲的服务可以对照测试 Workstation GC:
environment:
DOTNET_gcServer: "0"需要保留 Server GC 吞吐量时,可以测试较少的 heap;希望 GC 更积极节省内存时,可以测试 DOTNET_GCConserveMemory。具体参数、版本和取值范围应以 Microsoft 的 .NET GC 配置文档为准,每组实验只改变一个变量。
一组低并发服务的本地对照中,默认 Server GC 的空闲工作集约 440 MiB,Workstation GC 约 260 至 273 MiB,Server GC 限制为 4 个 heap 后约 360 至 379 MiB,Workstation GC 加 DOTNET_GCConserveMemory=5 后约 205 至 218 MiB。这些数字只适用于当时的 CPU 数量、Runtime、构建配置和请求模型。高并发 API 必须重新测量吞吐量、P95/P99 延迟、GC 暂停和峰值内存。
不要为了让 docker stats 好看就设置很小的 GC Heap Hard Limit。它缩短的是 GC 到 OOM 的距离,不会减少线程栈、JIT 或原生库占用。
把一次排查变成可重复实验
每次记录应用版本、Runtime、操作系统、CPU 逻辑核心数、容器限制、GC 模式、启动后等待时间和固定负载。采集结果至少包括 Working Set、GC Heap、GC Committed、Allocation Rate、Gen 2 次数、完整 GC 后的存活对象总量,以及 gcdump 中增长最多的类型。
- Working Set 和完整 GC 后的存活对象一起增长:继续用
gcdump与gcroot查引用; - Allocation Rate 很高,但 GC Heap 周期性回落:这是分配压力,先减少短命对象;
- GC Heap 已回落,GC Committed 仍高:GC 正在保留可复用段,结合容器压力评估;
- GC Heap 与 GC Committed 都不高,Working Set 仍增长:转向线程、JIT 和原生内存;
- 只有低并发环境中 Server GC 放大空闲占用:固定负载后比较 Workstation GC。
Microsoft 的 .NET 内存泄漏诊断教程也采用同样的路径:先用计数器确认增长,再采集 dump 并通过 gcroot 定位引用。内存分析的难点不在于命令多,而在于每个数字的范围不同。先证明增长发生在哪里,再决定下一件工具。
如果排查结果显示高工作集主要来自运行时策略,而不是长期存活的业务对象,可以再读我为什么越来越少在遗留业务中使用 .NET。那篇记录了这些指标如何影响一次实际技术取舍。