内存剖析与 dump 分析

讲解 .NET 线上内存问题的排查方法,涵盖 dotnet-dump 与 gcdump 采集、SOS 命令体系、GC 根分析定位泄漏、常见泄漏模式,以及大对象堆与碎片治理。

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 看值
根分析从根往下读引用链,链起点才是原因
泄漏模式事件未退订、无上限缓存、静态累积、未释放、异步悬挂
LOH85 KB 以上进 LOH 且默认不压缩,用池化从源头减少
流程两份快照对比 + 引用链定位 + 修复验证 + 自动化采集

内存问题的排查本质是一场证据驱动的推理:监控给出怀疑对象,快照给出对象清单,引用链给出确凿结论。掌握这套流程之后,「内存一直涨」不再是只能靠重启缓解的玄学问题,而是可以在几十分钟内定位到具体那行代码的工程任务。它和上一篇讨论的分布式事务一样,考验的都是「把不可见的状态变成可见证据」的能力。下一篇我们把视角转向另一个快速增长的方向——在 .NET 里做机器学习。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「csharp」更多文章

  1. .NET 机器学习实战
  2. 分布式事务与 Saga 编排
  3. GraphQL 服务端开发