前面三篇 GC 文章,我们分别聊了 DATAS 与 LOH 压缩、POH 固定对象堆与 GC API 实时调优、以及零分配对象设计——都是在"怎么少制造垃圾"。可真正线上出问题那天,你面对的是一台你不了解的进程:GC 到底在回收哪一代?暂停有多久?堆为什么缓涨不跌?靠猜只会越调越乱。本篇转向"怎么看清 GC",用 EventListener 订阅运行时事件、用 Metrics 自建实时指标流、用 EventPipe 把 GC 行为落盘、用 GetGCMemoryInfo 做阈值告警,最后串成一条"内存暴涨 10 分钟定位"的实战链路。每段代码都能本地复现,每组对比都有量化收益。
没有监控时,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}"); } }
|
|
|
|
|---|---|---|
|
|
不可见 | 实时计数 |
|
|
黑盒 | 结构化事件 |
|
|
约 4 小时 | 约 8 分钟 |
选型:EventListener 适合"进程内自带诊断",零依赖、随应用启动即可。想要暂停时长明细,可进一步监听 GCStop 事件比对自己的时间戳,算出每次暂停的精确毫秒数。
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); } }
|
|
|
|
|---|---|---|
|
|
黑盒 | 每秒可见 |
|
|
几天后告警 | 当轮即可见 |
|
|
否 | 是(Prometheus 等) |
排坑:Counter 记录的是"增量",所以 Sample 里用 CollectionCount - 上次值 再 Add;若直接 Add 累计值,面板上会显示成单调台阶而非速率。想看速率就配合 UpDownCounter 或自己算环比。
进程内监控能看到"现在",但出事往往在半夜——你希望的是"把那段时间原样录下来,第二天慢慢看"。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); } }
|
|
|
|
|---|---|---|
|
|
不可重现 | 完整留痕 |
|
|
无法回看 | 可 dotnet-trace 回放 |
|
|
— | 否,跨进程抓 |
延伸:需 dotnet add package Microsoft.Diagnostics.NETCore.Client。生产环境建议把录制接到"收到告警才抓 60 秒"的开关上,平时零开销,出事才留证,避免一直写盘。
事件的"流"和指标的"曲线"都有了,还差一道"主动吹哨"的闸。 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 占比"双重条件,能把正常波动和真泄露区分开。
把前面四招串起来,就是一套"内存暴涨定位作战流":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 里再加一条"内存回归"红线,性能问题就能从"救火"变"防火"。
第一招(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