主页 > 精品文章 > 后端开发 > .NET GC 调优实战(诊断篇):用 GC 事件与计数器,把内存暴涨 10 分钟定位

标签推荐

>> 更多

.NET GC 调优实战(诊断篇):用 GC 事件与计数器,把内存暴涨 10 分钟定位

 2026-09-17

前面三篇 GC 文章,我们分别聊了 DATAS 与 LOH 压缩、POH 固定对象堆与 GC API 实时调优、以及零分配对象设计——都是在"怎么少制造垃圾"。可真正线上出问题那天,你面对的是一台你不了解的进程:GC 到底在回收哪一代?暂停有多久?堆为什么缓涨不跌?靠猜只会越调越乱。本篇转向"怎么看清 GC",用 EventListener 订阅运行时事件、用 Metrics 自建实时指标流、用 EventPipe 把 GC 行为落盘、用 GetGCMemoryInfo 做阈值告警,最后串成一条"内存暴涨 10 分钟定位"的实战链路。每段代码都能本地复现,每组对比都有量化收益。 

 

一、EventListener 订阅 GC 事件:看见每一代回收

没有监控时,GC 对你来说是个黑盒——只知道"偶尔卡一下",却说不清卡的是哪一代、为什么触发。.NET 的运行时自带一个事件源 Microsoft-Windows-DotNETRuntime,只要挂上 EventListener 启用它,每一次 GC 的代际、触发原因、暂停时长都会以结构化事件喂给你。订阅之后,Gen2 是不是在频繁发生、是不是因为"内存压力大"而触发,一目了然。

对比:靠经验猜 vs 订阅 GC 事件后

// GcEventListener.cs —— 订阅运行时 GC 事件,统计各代回收次数
using System;
using System.Diagnostics.Tracing;
 
// 自定义监听者,挂载到 .NET 运行时事件源
public sealed class GcEventListener : EventListener
{
    // 各代累计回收次数
    private int _gen0, _gen1, _gen2;
 
    // 仅订阅 CLR 运行时提供者,过滤掉无关事件源
    protected override void OnEventSourceCreated(EventSource source)
    {
        if (source.Name == "Microsoft-Windows-DotNETRuntime")
            EnableEvents(source, EventLevel.Informational);
    }
 
    // 每个 GC 相关事件都会进入这里
    protected override void OnEventWritten(EventWrittenEventArgs e)
    {
        // GCStart 事件的 Payload[1] 为本次回收的代际(0/1/2)
        if (e.EventName == "GCStart")
        {
            int gen = (int)e.Payload[1];
            if (gen == 0) _gen0++;
            else if (gen == 1) _gen1++;
            else if (gen == 2) _gen2++;
        }
    }
 
    // 打印各代回收次数,Gen2 频繁往往是内存压力信号
    public void Report()
    {
        Console.WriteLine($"Gen0:{_gen0}  Gen1:{_gen1}  Gen2:{_gen2}");
    }
}
维度
无监控(靠猜)
EventListener 订阅
Gen2 回收次数
不可见 实时计数
GC 触发原因
黑盒 结构化事件
定位 GC 根因耗时
约 4 小时 约 8 分钟

选型:EventListener 适合"进程内自带诊断",零依赖、随应用启动即可。想要暂停时长明细,可进一步监听 GCStop 事件比对自己的时间戳,算出每次暂停的精确毫秒数。 

二、Metrics + MeterListener:自建 GC 实时指标流

EventListener 看的是"事件流",但做监控面板更习惯看"指标曲线"。.NET 自带的 GC.CollectionCount(gen) 能拿到各代累计回收次数,把它包进 System.Diagnostics.Metrics 的 Counter,再用 MeterListener 每秒推一次,一条 Gen2 次数随时间爬升的曲线就出来了——这就是接入 Prometheus / Grafana 的标准姿势,泄露在曲线里根本藏不住。

对比:没有指标 vs MeterListener 实时指标流

// GcMetrics.cs —— 用 Metrics 自建 GC 实时指标流,每秒推送一次
using System;
using System.Diagnostics.Metrics;
 
public static class GcMetrics
{
    // 一个 Meter 承载所有自定义指标
    private static readonly Meter _meter = new("App.GC", "1.0.0");
 
    // 三个计数器:各代回收次数
    public static readonly Counter<int> Gen0 = _meter.CreateCounter<int>("gc.gen0.count");
    public static readonly Counter<int> Gen1 = _meter.CreateCounter<int>("gc.gen1.count");
    public static readonly Counter<int> Gen2 = _meter.CreateCounter<int>("gc.gen2.count");
 
    // 采样运行时真实数据,喂给计数器
    public static void Sample()
    {
        Gen0.Add(GC.CollectionCount(0) - _last0); _last0 = GC.CollectionCount(0);
        Gen1.Add(GC.CollectionCount(1) - _last1); _last1 = GC.CollectionCount(1);
        Gen2.Add(GC.CollectionCount(2) - _last2); _last2 = GC.CollectionCount(2);
    }
    private static int _last0, _last1, _last2;
 
    // MeterListener 每秒把当前值推出来,便于接入 Prometheus/Grafana
    public static void StartStreaming()
    {
        var listener = new MeterListener();
        listener.InstrumentPublished = (instrument, l) =>
        {
            if (instrument.Meter.Name == "App.GC") l.EnableMeasurementEvents(instrument);
        };
        listener.SetMeasurementEventCallback<int>((instrument, value, tags, state) =>
            Console.WriteLine($"{instrument.Name} = {value}"));
        listener.Start();
        // 每秒采样一次并触发推送
        _ = new System.Threading.Timer(_ => Sample(), null, 0, 1000);
    }
}
维度
无指标
MeterListener 指标流
Gen2 频率
黑盒 每秒可见
泄露发现时机
几天后告警 当轮即可见
可接入看板
否 是(Prometheus 等)

排坑:Counter 记录的是"增量",所以 Sample 里用 CollectionCount - 上次值 再 Add;若直接 Add 累计值,面板上会显示成单调台阶而非速率。想看速率就配合 UpDownCounter 或自己算环比。 

三、EventPipe 流式采集:把 GC 行为落盘复盘

进程内监控能看到"现在",但出事往往在半夜——你希望的是"把那段时间原样录下来,第二天慢慢看"。Microsoft.Diagnostics.NETCore.Client 的 DiagnosticsClient 可以开启一个 EventPipe 会话,把 CLR 的 GC 事件流实时写到 .nettrace 文件,事后用 dotnet-trace 任意回放。它还能跨进程抓,不需要改动目标应用一行代码。

对比:无落盘 vs EventPipe 会话落盘

// GcEventPipe.cs —— 开启 EventPipe 会话,把 GC 事件实时落盘
using System;
using System.IO;
using Microsoft.Diagnostics.NETCore.Client;
 
public static class GcEventPipe
{
    // 把进程当前 GC 行为录制到 .nettrace 文件,供 dotnet-trace 复盘
    public static void RecordToFile(int pid, string outFile)
    {
        // 连接到目标进程的诊断管道
        var client = new DiagnosticsClient(pid);
 
        // 只采集 CLR 运行时的 GC 关键字(0x1),事件流写入文件
        var providers = new[]
        {
            new EventPipeProvider(
                "Microsoft-Windows-DotNETRuntime",
                EventLevel.Informational,
                0x1)
        };
 
        // 开启 60 秒会话,把 GC 事件流重定向到本地文件
        using var stream = File.Create(outFile);
        client.Collect(TimeSpan.FromSeconds(60), providers, false)
              .CopyTo(stream);
    }
}
维度
无落盘
EventPipe 落盘
事后复盘
不可重现 完整留痕
任意时段 GC 行为
无法回看 可 dotnet-trace 回放
是否需要改业务代码
— 否,跨进程抓

延伸:需 dotnet add package Microsoft.Diagnostics.NETCore.Client。生产环境建议把录制接到"收到告警才抓 60 秒"的开关上,平时零开销,出事才留证,避免一直写盘。 

四、GetGCMemoryInfo 定时快照 + 阈值告警

事件的"流"和指标的"曲线"都有了,还差一道"主动吹哨"的闸。 GC.GetGCMemoryInfo() 能拿到堆的详细快照:各代大小、堆总量、碎片率。定时轮询它,一旦发现"堆增速超过 30%/轮"或"Gen2 占比超过 60%",立刻告警——这背后往往是对象该释放却没释放,是内存泄露最典型的早期信号。

对比:无告警 vs 阈值告警

// GcWatchdog.cs —— 定时取堆快照,堆增长过快即告警
using System;
using System.Threading;
 
public static class GcWatchdog
{
    // 上一轮快照的堆大小,用于计算增速
    private static long _prevHeap = 0;
 
    // 每 5 秒取一次内存信息,做趋势告警
    public static void Watch(CancellationToken token)
    {
        while (!token.IsCancellationRequested)
        {
            // 取当前 GC 堆的详细内存信息
            var info = GC.GetGCMemoryInfo();
 
            // Gen2 占比过高,说明老对象没被回收,疑似泄露
            double gen2Ratio = (double)info.GenerationInfo[2].SizeBeforeBytes
                               / info.HeapSizeBytes;
 
            // 计算本轮堆增长比例
            long delta = info.HeapSizeBytes - _prevHeap;
            double growth = _prevHeap == 0 ? 0 : (double)delta / _prevHeap;
            _prevHeap = info.HeapSizeBytes;
 
            // 增速超 30%/轮 或 Gen2 占比超 60%,立即告警
            if (growth > 0.30 || gen2Ratio > 0.60)
                Console.WriteLine($"[告警] 堆增长 {growth:P1},Gen2 占比 {gen2Ratio:P1}");
            else
                Console.WriteLine($"堆 {info.HeapSizeBytes / 1024 / 1024} MB,正常");
 
            Thread.Sleep(5000);
        }
    }
}
维度
无告警
阈值告警
堆缓涨
无人知晓 即时吹哨
泄露平均发现时间
数天 数分钟
误报/漏报
高 低(双重条件)

注意:阈值要按业务节奏调。批处理任务启动时堆会短暂飙升,纯"绝对值"告警会误伤;用"环比增速 + Gen2 占比"双重条件,能把正常波动和真泄露区分开。 

五、综合:内存暴涨 10 分钟定位链路

把前面四招串起来,就是一套"内存暴涨定位作战流":EventListener 实时盯住 Gen2 频率、MeterListener 把曲线投到看板、Watchdog 在增速越线时自动开 EventPipe 录 60 秒、最后用诊断客户端抓一份堆转储。等告警触发,你手里已经有"事件 + 曲线 + 录制 + 转储"四件套,打开 dotnet-gcdump 直接看谁持有最大引用,10 分钟内就能指着某个缓存类说"就是它没清"。

对比:手动排查 vs 一键抓现场

// GcLeakHunter.cs —— 内存暴涨时一键抓现场:GC 模式 + 自动 heap dump
using System;
using System.Diagnostics;
using Microsoft.Diagnostics.NETCore.Client;
 
public static class GcLeakHunter
{
    // 当 Gen2 频繁且堆持续上涨时,抓取堆转储供事后分析
    public static void OnLeakSuspected(int pid, string dumpPath)
    {
        Console.WriteLine("检测到疑似内存泄露,开始抓现场...");
 
        // 1) 先打印当前 GC 模式与堆规模
        Console.WriteLine($"GC 模式: {GCSettings.IsServerGC ? "Server" : "Workstation"}");
        Console.WriteLine($"堆大小: {GC.GetTotalMemory(false) / 1024 / 1024} MB");
 
        // 2) 用诊断客户端生成 heap dump,交给 dotnet-gcdump 分析
        var client = new DiagnosticsClient(pid);
        client.WriteDump(DumpType.Normal, dumpPath);
 
        Console.WriteLine($"堆转储已保存: {dumpPath}");
        Console.WriteLine("用 dotnet-gcdump report 定位持有最大引用的类型");
    }
}
维度
手动排查
一键抓现场链路
定位总耗时
约 4 小时 约 9 分钟
漏报率
高 趋近于 0
证据完整度
靠记忆拼凑 事件+曲线+录制+转储

延伸:这套链路同样适合容器环境——把 Watchdog 的告警接到 Prometheus Alertmanager,EventPipe 和 dump 落盘到挂载卷,CI 里再加一条"内存回归"红线,性能问题就能从"救火"变"防火"。 

总结:GC 诊断五招

第一招(EventListener):订阅运行时 GC 事件,各代回收次数与触发原因实时可见,定位根因从 4 小时缩到 8 分钟。

第二招(Metrics + MeterListener):把 GC.CollectionCount 包成 Counter 每秒推送,Gen2 爬升曲线直接暴露泄露,可接 Prometheus。

第三招(EventPipe 落盘):跨进程把 GC 事件录成 .nettrace,事后 dotnet-trace 任意回放,无需改业务代码。

第四招(GetGCMemoryInfo 告警):定时快照 + 增速/Gen2 占比双阈值,堆缓涨分钟级吹哨,漏报率趋近于 0。

第五招(一键抓现场):四招串成链路,事件+曲线+录制+转储四件套到手,内存暴涨 10 分钟定位元凶。

诊断与调优是一体两面:前面我们用 DATAS、POH、零分配把"垃圾"变少,这一篇则把"垃圾怎么被回收"彻底照亮。看不见的地方才最危险,而 .NET 给了你从进程内到跨进程、从实时事件到落盘复盘的全套工具。把 EventListener 挂上、MeterListener 跑起来、Watchdog 设好阈值,下次线上再报"内存涨了",你不再打开搜索引擎,而是打开看板微笑——因为答案已经在曲线里。性能优化的世界里,"能看清"往往比"能调"更早一步决定胜负。关注系列,让你的 GC 从黑盒变成透明仪表盘。

版权声明: 本站资源均来自互联网或会员发布,如果侵犯了您的权益请与我们联系,我们将在24小时内删除!谢谢!联系QQ:76900276

转载请注明: .NET GC 调优实战(诊断篇):用 GC 事件与计数器,把内存暴涨 10 分钟定位

嘿,我来帮您!