1. 什么时候需要 dump
一句话总结: 当内存持续增长不回落、GC 停顿变长、或容器因 OOM 被杀时,只有进程快照能给出「谁持有了这些对象」的确定答案。
监控指标能告诉你「有问题」,但不能告诉你「为什么」。GCHeapSize 一路向上、Gen2 回收后仍不下降、容器 RSS 逼近 limit 被杀——这些信号指向内存问题,但根因藏在对象图里。日志和计数器都看不到对象之间的引用关系,只有内存快照可以。
判断是否真的是泄漏,先看两个指标:GC 回收后的存活堆大小是否随时间单调上升,以及对象分配速率是否异常。前者涨说明有对象被长期持有,后者高说明分配压力大但不一定是泄漏。
| 现象 | 可能原因 | 首选手段 |
|---|---|---|
| 存活堆单调上升 | 引用泄漏、缓存无上限 | 两次 dump 对比 |
| GC 停顿变长 | 大对象堆增长、碎片 | gcdump + GC 统计 |
| 内存高但 GC 后回落 | 分配速率高、代际预算大 | dotnet-counters |
| 容器 OOM 被杀 | 非托管内存、RSS 超限 | dotnet-gcdump + 系统指标 |
| 启动后内存阶梯上升 | 缓存逐次累积 | 按操作前后对比 |
# 快速判断是否泄漏:连续观察 GC 后的存活堆
dotnet-counters monitor \
--process-id 12345 \
--counters System.Runtime
# 关注 gc-heap-size、gen-2-size、alloc-rate 三条曲线
避坑: 只看进程 RSS 判断泄漏会误判。.NET 的 GC 有「预留但未使用」的堆空间,RSS 高于存活堆是正常的,而且 .NET 不总是立刻把内存还给操作系统。判定泄漏要看 GC 回收后的存活堆是否单调上升,而不是 RSS 的绝对值。另外,非托管内存(P/Invoke 分配、原生库缓存)不计入 GC 堆,这类泄漏要靠系统级工具排查。
2. 采集工具与时机
一句话总结:
dotnet-dump采集完整快照用于深度分析,dotnet-gcdump采集轻量堆图用于对比趋势,选择取决于你要回答的问题。
.NET 的诊断工具有明确分工:dotnet-counters 看实时指标,dotnet-trace 看执行轨迹,dotnet-dump 抓完整进程快照,dotnet-gcdump 抓托管堆的对象图。生产环境优先用 gcdump,因为它体积小、采集停顿短(通常百毫秒级),而完整 dump 可能几个 GB、采集时会暂停进程数秒。
# 安装诊断工具(全局工具,装在排查机上)
dotnet tool install --global dotnet-dump
dotnet tool install --global dotnet-gcdump
dotnet tool install --global dotnet-counters
dotnet tool install --global dotnet-trace
# 采集完整 dump:用于分析对象字段、调用栈、线程状态
dotnet-dump collect --process-id 12345 --type Full --output /tmp/app.dump
# 采集 gcdump:只含托管堆的对象图,体积小,适合对比
dotnet-gcdump collect --process-id 12345 --output /tmp/app.gcdump
# 分析 gcdump(跨平台,可在开发机上打开)
dotnet-gcdump report /tmp/app.gcdump
// 进程内触发采集:配合健康检查端点,在内存超阈值时自动抓取
public sealed class DumpEndpoint
{
public static void Map(WebApplication app)
{
app.MapPost("/diag/dump", async (IHostEnvironment env) =>
{
if (!env.IsDevelopment() && !env.IsStaging())
return Results.NotFound();
var path = $"/tmp/app-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}.dump";
// 生产环境应通过 dotnet-dump 外部采集,避免进程内暂停
return Results.Ok(new { path });
});
}
}
| 工具 | 产物 | 体积 | 停顿 | 回答的问题 |
|---|---|---|---|---|
| dotnet-counters | 实时指标 | 无 | 无 | 现在健康吗 |
| dotnet-trace | 事件轨迹 | 中 | 低 | 时间花在哪 |
| dotnet-gcdump | 托管堆图 | 小 | 低 | 谁持有对象 |
| dotnet-dump | 完整快照 | 大 | 高 | 字段值、线程栈 |
| PerfView | 综合 | 大 | 中 | 分配与 GC 全貌 |
避坑: 在容器里采集 dump 有两个常见坑。第一,dump 文件默认写在容器文件系统里,可能把磁盘写满并触发驱逐,应该写到挂载卷或直接流式导出。第二,容器被 OOM Kill 时进程直接消失,来不及采集——应该配置
DOTNET_DbgMiniDumpType环境变量让运行时在崩溃时自动落盘,或依赖 K8s 的terminationGracePeriod配合 preStop 钩子抓取。
3. SOS 命令基础
一句话总结: SOS 是分析 dump 的核心命令集,
dumpheap看对象分布、gcroot找持有者、dumpobj看字段值,三者组合能回答绝大多数问题。
进入分析会话后,先用 clrthreads 与 eeheap 建立全局印象,再用 dumpheap -stat 看类型级别的对象分布——按总大小排序的头部类型通常就是问题所在。
# 加载 dump 并进入 SOS 分析会话
dotnet-dump analyze /tmp/app.dump
# 会话内:先看托管堆总体情况
> eeheap -gc # 各代堆大小与地址范围
> clrthreads # 所有托管线程与状态
> dumpheap -stat # 按类型统计对象数与总大小(关键命令)
# 典型输出(截取):
# MT Count TotalSize Class Name
# 00007f... 18234 312456789 System.Byte[]
# 00007f... 892341 42832368 System.String
# 00007f... 4211 18432000 Contoso.Cache.Entry
# 从统计到实例:找到大对象的具体地址
> dumpheap -mt 00007f9a1c002a30 -min 85000 # 只列大对象
> dumpobj 000001d4a2b3c4d0 # 查看单个对象内容
> dumpvc 000001d4a2b3c4d0 00007f9a1c002a30 # 查看值类型字段
> objsize 000001d4a2b3c4d0 # 单个对象占用字节数
> gcroot 000001d4a2b3c4d0 # 谁持有这个对象(关键)
# 线程栈:看是否有线程卡住导致对象长期存活
> clrstack -all # 所有线程的托管调用栈
> clrstack -p 00007f9a... # 带参数值
> syncblk # 锁竞争与阻塞情况
> dumpasync # 异步状态机与未完成的任务
| SOS 命令 | 作用 | 常用参数 |
|---|---|---|
| dumpheap -stat | 类型级统计 | 无 |
| dumpheap | 实例列表 | -mt 类型、-min 最小字节 |
| dumpobj | 对象内容 | 地址 |
| gcroot | 引用链 | 地址 |
| eeheap -gc | 堆结构 | 无 |
| clrstack | 托管调用栈 | -all、-p |
| syncblk | 锁状态 | 无 |
| dumpasync | 异步状态机 | 无 |
避坑:
dumpheap -stat里的System.Byte[]与System.String排在最前是正常现象,不能据此判断泄漏。真正要看的是业务类型的异常聚集,以及同一类型在两次 dump 之间是否显著增长。另外,Free对象占据大量空间说明堆碎片严重(见第 6 节),而不是对象多。分析时永远先做「两次快照对比」,再看单次快照的绝对值。
4. GC 根分析
一句话总结: 泄漏的本质是「有一个不该存在的根引用了对象」,
gcroot输出一条从根到对象的引用链,链上第一个可疑的静态字段或缓存就是凶手。
GC 只回收「不可达」的对象。对象不可达当且仅当从任何 GC 根出发都到不了它。GC 根包括:静态字段、线程栈上的局部变量、CPU 寄存器、GCHandle、finalizer 队列、以及正在运行的异步状态机。
# 完整流程:从可疑类型到引用链
> dumpheap -stat # 1. 找可疑类型
# MT Count TotalSize Class Name
# 00007f... 48210 98765432 Contoso.Cache.CachedProduct
> dumpheap -mt 00007f9a1c004100 -min 1000 # 2. 列出实例
> gcroot 000001d4a2b3c4d0 # 3. 追引用链
# 典型输出:
# Found 1 unique root(s).
# Thread 12:
# 00007ff... Contoso.Cache.ProductCache <- 静态字段
# _items (System.Collections.Generic.Dictionary`2)
# [1024] (Contoso.Cache.CachedProduct)
# ...
# 结论:静态字典无上限增长,且没有过期淘汰
// 对应的问题代码:静态字典只增不减
public sealed class ProductCache
{
// 静态字段是 GC 根:只要进程活着,字典里的对象永远不会被回收
private static readonly Dictionary<int, CachedProduct> _items = new();
public CachedProduct GetOrAdd(int id, Func<int, CachedProduct> factory)
{
if (!_items.TryGetValue(id, out var item))
{
item = factory(id);
_items[id] = item; // 没有上限,没有过期
}
return item;
}
}
| GC 根类型 | 是否正常 | 排查要点 |
|---|---|---|
| 静态字段 | 常见泄漏源 | 检查缓存是否有上限 |
| 线程局部存储 | 常见 | ThreadLocal 随线程存活 |
| 未完成的任务 | 常见 | 检查 dumpasync 输出 |
| 事件订阅 | 常见 | 发布者持有订阅者引用 |
| Timer 回调 | 常见 | 定时器未释放 |
| 终结器队列 | 少见 | 检查 Finalize 实现 |
| GCHandle | 少见 | 检查 NativeInterop |
避坑:
gcroot输出的链可能很长,要从根往下读,而不是从对象往上读——链的起点才是原因,终点只是受害者。另一个常见误区是「用弱引用或ConditionalWeakTable就能解决所有缓存问题」:弱引用确实不阻止回收,但一旦对象被回收,缓存就失效了,语义上未必可接受;ConditionalWeakTable的 key 存活依赖引用相等,被装箱或重新构造的 key 会导致条目永远不被回收。
5. 常见泄漏模式
一句话总结: 生产环境的泄漏绝大多数落在那几种模式里——无上限缓存、事件未退订、静态集合累积、
IDisposable未释放、异步任务悬挂。
识别模式比逐案分析快得多。下面是实践中最常遇到的五类,每类都有固定的检测手法。
// 模式一:事件订阅未退订——发布者(长寿)持有订阅者(短寿)
public sealed class OrderPublisher
{
public event EventHandler<OrderEventArgs>? OrderPlaced;
public void Raise(Order order) => OrderPlaced?.Invoke(this, new(order));
}
// 错误:每次创建 Page 都订阅,但从不退订
public sealed class Page
{
public Page(OrderPublisher pub) => pub.OrderPlaced += OnOrderPlaced;
private void OnOrderPlaced(object? s, OrderEventArgs e) { }
// 缺少:public void Dispose() => _pub.OrderPlaced -= OnOrderPlaced;
}
// 模式二:静态集合累积——无上限的 List/Dictionary 作为全局状态
public static class AuditLog
{
private static readonly List<string> _entries = new(); // 只增不减
public static void Add(string entry) => _entries.Add(entry);
}
// 模式三:IDisposable 未释放——HttpClient、Stream、DbContext
public Stream ReadFile(string path) => File.OpenRead(path); // 调用方忘了 using
// 模式四:Timer 未释放——回调持有目标对象
_timer = new Timer(OnTick, state, TimeSpan.Zero, TimeSpan.FromSeconds(1));
// 缺少:_timer.Dispose(),且 Timer 自身会被运行时根引用
// 模式五:异步任务悬挂——未 await 的 Task 保留整个状态机
public void FireAndForget() => DoWorkAsync(); // 异常被吞,状态机长期存活
| 泄漏模式 | 检测手法 | 修复 |
|---|---|---|
| 事件未退订 | gcroot 链到 publisher | 实现 IDisposable 退订 |
| 无上限缓存 | 业务类型实例数持续增长 | 用 IMemoryCache 设上限与过期 |
| 静态集合 | 静态字段在 gcroot 链起点 | 改为有界结构或加清理 |
| 未释放资源 | 大量未终结对象 + Finalizer 队列 | using 或 DI 托管生命周期 |
| 异步悬挂 | dumpasync 大量未完成状态机 | 加超时与取消传播 |
| Timer 未释放 | 回调目标对象堆积 | Dispose 并改用 PeriodicTimer |
避坑: 订阅
+=是 C# 里最容易被忽视的泄漏源,因为它看起来「只是注册了一个回调」。修复的关键不是记住退订,而是用IDisposable把订阅与退订绑定在同一个生命周期对象上,让 DI 容器负责调用 Dispose。另一个坑是IMemoryCache的SetSize与SizeLimit:不设SizeLimit时,条目永远不会因为容量被淘汰,只按过期时间回收。
6. 大对象堆与碎片
一句话总结: 大于 85 KB 的对象进入大对象堆(LOH),LOH 默认不压缩,容易形成碎片,让「总空闲空间足够但没有连续块」成为新的内存问题。
LOH 的特殊性在于:它不参与代际回收,只在 Gen2 回收时处理,且默认不做压缩(避免移动大对象的成本)。这意味着反复分配与释放大小不一的大对象会留下大量空洞,最终即使总空闲空间充足,也无法满足一次大分配,触发更频繁的 Gen2 回收甚至 OutOfMemory。
# 检查 LOH 与碎片:Free 对象占比高说明碎片严重
> eeheap -gc
# 输出中关注:
# generation 2 starts at ... size ...
# Large object heap starts at ... size ...
# Total Size: ...
> dumpheap -stat | grep Free
# 大量 Free 对象 + 总空闲很大但仍 OOM = 碎片问题
# 手动触发压缩式 GC(临时缓解,不解决根因)
> gchandles
> dumpheap -stat -min 85000
<!-- 开启 LOH 压缩:牺牲部分 GC 时间换内存连续性 -->
<configuration>
<runtime>
<GCLargePages enabled="false" />
<GCHeapHardLimitPercent>0.75</GCHeapHardLimitPercent>
</runtime>
</configuration>
// 应用层解法:用 ArrayPool 复用大数组,避免反复分配进入 LOH
public async Task<byte[]> ReadPayloadAsync(Stream source, CancellationToken ct)
{
var buffer = ArrayPool<byte>.Shared.Rent(128 * 1024); // 池化,不进 LOH
try
{
var read = await source.ReadAsync(buffer.AsMemory(0, 128 * 1024), ct);
return buffer[..read].ToArray(); // 只在必须时复制
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
| 配置 | 效果 | 代价 |
|---|---|---|
| 默认(不压缩 LOH) | GC 停顿短 | 可能碎片化 |
GCLargePages | 大页,减少 TLB 缺失 | 需要 OS 支持 |
| LOH 压缩开关 | 消除碎片 | GC 停顿变长 |
ArrayPool 复用 | 从源头减少 LOH 分配 | 代码复杂度 |
GCHeapHardLimitPercent | 限制堆上限,提前触发回收 | 可能增加 GC 频率 |
避坑: 「LOH 压缩」不是免费的午餐——它把大对象搬移的代价摊到每次 Gen2 回收上,在 LOH 很大的服务里可能让停顿从几十毫秒涨到几百毫秒。更稳的思路是从源头减少 LOH 分配:用
ArrayPool复用缓冲区、避免把大文件一次性读进byte[]、用RecyclableMemoryStream替代MemoryStream累积。诊断时先确认「确实是碎片而非泄漏」,再决定是否开启压缩。
7. 线上排查流程与自动化
一句话总结: 把排查固化成「采集两份快照、对比差异、追引用链、验证修复」的四步流程,并让它可被自动化触发,才能在生产环境稳定复现问题。
线上排查最大的困难是「问题出现时人不在场」。有效的做法是把采集动作自动化:内存超过阈值时自动抓 gcdump,两份快照上传到对象存储,事后分析。同时要注意 dump 里包含用户数据,必须加密存储并限制访问。
# 标准四步流程
# 第一步:确认趋势(实时指标)
dotnet-counters monitor -p 12345 --counters System.Runtime
# 第二步:采集两份快照,间隔一段时间或一个操作周期
dotnet-gcdump collect -p 12345 -o /tmp/before.gcdump
# ...等待 5 分钟或执行一次可疑操作...
dotnet-gcdump collect -p 12345 -o /tmp/after.gcdump
# 第三步:对比两份报告,找出增长最快的类型
dotnet-gcdump report /tmp/before.gcdump > before.txt
dotnet-gcdump report /tmp/after.gcdump > after.txt
diff before.txt after.txt | head -50
# 第四步:对增长类型追引用链,定位持有者
dotnet-dump collect -p 12345 -o /tmp/full.dump
dotnet-dump analyze /tmp/full.dump -c "dumpheap -stat" -c "exit"
// 自动采集:内存超阈值时抓 gcdump,并限制采集频率避免雪崩
public sealed class MemoryGuard(
IOptions<MemoryGuardOptions> options,
ILogger<MemoryGuard> logger) : BackgroundService
{
private DateTimeOffset _lastCapture = DateTimeOffset.MinValue;
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
while (await timer.WaitForNextTickAsync(ct))
{
var info = GC.GetGCMemoryInfo();
var usedRatio = (double)info.HeapSizeBytes / info.TotalAvailableMemoryBytes;
if (usedRatio < options.Value.ThresholdRatio) continue;
if (DateTimeOffset.UtcNow - _lastCapture < options.Value.MinInterval) continue;
_lastCapture = DateTimeOffset.UtcNow;
logger.LogWarning("内存使用率 {Ratio:P0},触发快照采集", usedRatio);
// 通过 sidecar 或 dotnet-gcdump 外部触发,避免进程内暂停
}
}
}
| 排查阶段 | 动作 | 产出 |
|---|---|---|
| 确认 | dotnet-counters 观察趋势 | 是否真的泄漏 |
| 对比 | 两份 gcdump 前后对比 | 增长最快的类型 |
| 定位 | 完整 dump + gcroot | 引用链与持有者 |
| 验证 | 修复后重复上述流程 | 曲线是否回落 |
| 自动化 | 阈值触发采集 + 加密存储 | 可复现的证据链 |
避坑: dump 文件包含完整的内存内容,可能含密码、令牌、用户个人信息,绝不能上传到公共的工单系统或未加密的存储。采集后应加密、限定保留期、记录访问审计。另一个坑是采集动作本身成为故障源:完整 dump 会暂停进程数秒,在高并发服务上可能触发超时级联,所以生产环境优先用 gcdump(停顿短),完整 dump 只在必要时采集,且避开流量高峰。
8. 总结
| 环节 | 要点 |
|---|---|
| 判定 | 看 GC 后存活堆是否单调上升,不看 RSS 绝对值 |
| 工具 | counters 看趋势、gcdump 看对象图、dump 看字段与栈 |
| 命令 | dumpheap -stat 找类型、gcroot 追链、dumpobj 看值 |
| 根分析 | 从根往下读引用链,链起点才是原因 |
| 泄漏模式 | 事件未退订、无上限缓存、静态累积、未释放、异步悬挂 |
| LOH | 85 KB 以上进 LOH 且默认不压缩,用池化从源头减少 |
| 流程 | 两份快照对比 + 引用链定位 + 修复验证 + 自动化采集 |
内存问题的排查本质是一场证据驱动的推理:监控给出怀疑对象,快照给出对象清单,引用链给出确凿结论。掌握这套流程之后,「内存一直涨」不再是只能靠重启缓解的玄学问题,而是可以在几十分钟内定位到具体那行代码的工程任务。它和上一篇讨论的分布式事务一样,考验的都是「把不可见的状态变成可见证据」的能力。下一篇我们把视角转向另一个快速增长的方向——在 .NET 里做机器学习。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。