ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C# 内存泄漏排查:从托管堆、非托管资源到 VS2022 诊断实战

C# 内存泄漏排查:从托管堆、非托管资源到 VS2022 诊断实战 1. 先把话说清楚C# 里的内存泄漏到底长什么样C# 有 GC很多人第一反应是托管代码哪来的内存泄漏。这个说法只对了一半。GC 能回收的是没有任何根引用指向的对象它管不了你还拿着引用却不打算再用的对象。所以 C# 里的内存泄漏本质上不是内存丢了而是引用丢了——你以为对象已经没用了但某个长生命周期的容器、委托链、静态字段、运行时内部队列还牢牢攥着它。我做上位机和工控软件那几年跑七天八天不重启是常态内存泄漏几乎是最常见的现场故障而且往往不是崩在功能上是崩在跑了三天之后开始卡、第五天开始报 OutOfMemoryException。还有一个更麻烦的分支非托管资源泄漏。Bitmap、Graphics、文件句柄、串口、Socket、OPC UA 会话这些东西在托管堆上只占几十个字节的壳子真正的内存在 GDI 或者内核里。你用托管内存快照看曲线平得像一条直线但任务管理器里的句柄数一路往上走最后画图直接卡死。所以排查内存泄漏之前第一件事是分清楚这次到底是托管堆在涨还是句柄/原生内存在涨。这两条路的工具链完全不一样用错工具就是白干一整天。Visual Studio 2022 在诊断能力上比 2019 强了不少尤其是对 .NET 6/7/8 的支持内存快照的差异对比、分配调用堆栈、原生内存视图都做进去了。但工具好不代表能定位我见过太多人拍了两个快照看到某个类型数量涨了 5000 个然后就卡在那里了——他不知道接下来该看哪一列不知道怎么从实例走到根引用也不知道涨了到底是不是正常的缓存预热。这篇就把这套流程完整走一遍从判断到底是不是泄漏开始到工具选型、快照对比、根路径追踪再到八种高频泄漏模式的逐个拆解。1.1 托管堆泄漏和原生泄漏是两码事托管泄漏的典型表现是进程的 Private Bytes 持续上涨GC 堆大小GC Heap Size在 Gen2 回收之后仍然不回落。你可以在任务管理器里看提交大小或者用性能计数器 .NET CLR Memory 下的 # Bytes in all Heaps。如果是 .NET Core 及以上用 dotnet-counters 看 gc-heap-size 最直观。判断标准不是涨了而是Full GC 之后不降。GC 回收是有成本的运行时会根据分配速率调整回收频率短时间上涨完全正常。原生泄漏的表现是GC 堆稳定但进程的 Working Set 和句柄数一直涨。这时候你要看的是任务管理器里的句柄数和GDI 对象需要在任务管理器详细信息里手动加列或者用 GDIView 这类小工具看 GDI 对象的具体类型分布。典型元凶是 Bitmap 没 Dispose、Graphics 没释放、FileStream 没关、串口对象没 Close。还有一种更隐蔽的P/Invoke 调用返回的指针用 Marshal.AllocHGlobal 分配了但没 Free这种在托管堆里完全看不出来。我一般会先花两分钟做个粗筛打开任务管理器跑一次典型业务循环 50 遍观察三个数字——提交大小、句柄数、GDI 对象数。如果只有提交大小涨基本是托管问题如果句柄数或 GDI 对象数涨先按非托管查如果两个都涨那大概率是同一处代码同时持有托管对象和非托管资源比如自定义控件里缓存了太多 Bitmap。1.2 三个数字帮你判断是泄漏还是正常缓存第一个数字是 GC 堆大小在压力测试前后的差值。做法很简单启动程序等它跑完预热手动触发一次 Full GCVS 诊断工具里有强制垃圾回收按钮或者代码里调 GC.Collect() 加 GC.WaitForPendingFinalizers()记录堆大小然后跑 200 次核心业务再强制回收再记录。差值超过 5MB 而且随循环次数线性增长基本可以定性了。这里的关键是线性两个字缓存预热通常是一条快速上升然后走平的曲线泄漏是一条斜率固定的斜线。第二个数字是某个具体类型的实例数量差值。这个必须靠快照对比才能拿到后面第 3 节会详细讲。我自己的经验阈值是跑 100 次业务如果某个业务对象的实例数净增超过 100 个也就是每次都不回收那就不是缓存了是泄漏。因为正常缓存不会在同一个 key 上反复产生新实例。第三个数字是 GC 的 Gen2 回收次数。如果跑 200 次业务触发了 300 次 Gen2 回收堆却还在涨说明这些对象是根可达的GC 想收也收不了。反过来如果 Gen2 回收次数很少但堆在涨也可能只是回收没触发这时候手动 GC.Collect() 再观察一次就行。这个数据用 dotnet-counters 看 gen-2-gc-count 最方便。提示在排查之前一定要关闭 VS 的编辑并继续和任何动态插桩功能这些会让托管堆的形态发生偏移快照对比容易出现假阳性。2. 工具选型VS2022 自带的那把刀够不够用工具选型这件事上我踩过不少坑。早年用第三方 Profiler功能确实全但附加到工控机上的长跑进程时经常因为版本不匹配或者符号加载失败直接卡死目标进程现场是不允许重启的。后来我把策略改成了默认用自带工具只有自带工具搞不定时才上第三方。VS2022 自带的内存工具覆盖了 80% 的场景而且因为它和调试器是一套符号体系附加到进程时稳定性明显更好。VS2022 的内存诊断能力分三块调试时自动开启的诊断工具窗口、性能探查器里的内存使用率工具、以及命令行下的 dotnet 系列工具。这三块不是替代关系是不同阶段用的。前者用于快速判断和交互式排查中者用于给出分配调用堆栈后者用于生产环境或者不方便挂调试器的场景。2.1 诊断工具窗口和性能探查器怎么选诊断工具窗口快捷键 CtrlAltF2是调试启动后自动出现的那个小窗里面有内存使用率勾选框。它的优点是零成本你按 F5 启动调试勾上就能用缺点是它默认只采样不跟踪每个对象的分配位置。适合的场景是你已经知道大概哪段代码有问题想快速确认某个操作前后对象数量的变化。性能探查器菜单调试 - 性能探查器或 AltF2里的内存使用率工具是另一套逻辑。它需要在启动前选择目标然后它会记录完整的内存分配事件可以给出每个类型是从哪个方法分配出来的调用堆栈。代价是性能开销大通常会让程序慢 2 到 10 倍所以不要在有实时性要求的场景下用。但排查阶段慢一点没关系能定位到方法名才是关键。我自己的用法是先在诊断工具窗口里拍快照确认有泄漏并且锁定到具体类型然后重新用性能探查器跑一遍专门看这个类型的分配调用堆栈直接定位到代码行。两步走比一上来就开性能探查器要省事得多。2.2 命令行三件套counters、gcdump、dump现场排查的时候很多情况是不能挂 VS 调试器的——机器上没有 VS、进程是 Windows 服务、或者你不希望暂停进程。这时候命令行工具就是唯一选择。dotnet-counters 是实时监控的它不做快照只打点。用法dotnet-counters monitor --process-id 12345 --counters System.Runtime输出里重点看四行System.Runtime cpu-usage (%) gc-heap-size (MB) gen-2-gc-count working-set (MB)把 gc-heap-size 和 gen-2-gc-count 两条曲线放一起看如果 Gen2 回收之后堆大小回落幅度很小而业务还在持续跑那就是典型的根可达问题。这个工具开销极低可以长期挂着甚至可以在生产环境跑一整天看趋势。dotnet-gcdump 是采集托管堆图的体积小通常几 MB 到几十 MB只包含托管对象和引用关系不含原生内存。用法dotnet-gcdump collect -p 12345采下来是个 .gcdump 文件可以直接在 VS2022 里打开做法是文件 - 打开 - 文件选中它VS 会以只读的堆视图展示。这个能力很多人不知道其实非常好用因为它不需要你在现场安装完整的调试环境。dotnet-dump 是采集完整转储的体积大可能几百 MB 到几 GB但信息最全包含原生堆、线程栈、句柄。用法dotnet-dump collect -p 12345 dotnet-dump analyze core_20250101_120000.dmp进入分析会话后就是 SOS 命令的世界后面 3.4 节会讲具体命令。2.3 什么时候该掏 PerfView 和第三方工具PerfView 我一般在这几种情况下用需要看 GC 的详细事件比如 LOH 分配、Gen2 压缩、GC 暂停时间分布或者需要跨多个进程对比或者需要把内存数据和 CPU 数据放一起看。它的 GC Heap Net Mem 视图和 Take Heap Snapshot 的差异对比在处理 LOH 碎片化问题上比 VS 更清楚。缺点是界面反人类第一次用基本找不到东西。第三方工具里我用得比较多的是 dotMemory 和 SciTech 的 .NET Memory Profiler。它们的优势是快照对比做得更细比如能按分配调用堆栈分组、能自动识别常见的泄漏模式、能给出这个对象为什么还活着的自然语言解释。代价是要钱而且在某些受限环境里附加会失败。我的建议是如果团队里有预算并且经常做性能优化值得买如果只是偶尔排查一次自带工具加 dotnet-dump 完全够。注意不要在生产环境用需要暂停进程的 Profiler。dotnet-gcdump 默认会短暂暂停进程通常在几百毫秒内dotnet-counters 则完全不影响运行。选工具之前先想清楚能不能暂停。3. 一次完整的抓漏实操从快照到根路径这一节我把整个流程按真实顺序走一遍。假设场景是一个 C# 上位机程序负责从若干台设备采集温度数据并刷新界面跑一晚上之后内存涨到 2GB 然后崩掉。这种场景在工控、MES、检测设备里极其常见套路也基本通用。3.1 复现脚本的设计决定后面所有事很多人排查失败不是因为工具不会用是因为复现没做好。我见过有人一边点界面一边等十分钟才做三遍操作拍出来的快照全是噪声。正确的做法是先写一个能自动跑 N 遍的复现脚本把业务循环剥离出来。以上位机为例我会在程序里加一段临时代码// 仅用于排查发布前必须删除 private async void btnLeakTest_Click(object sender, EventArgs e) { // 先做一次全量回收把之前的垃圾清干净作为基线 GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); for (int i 0; i 100; i) { await _deviceManager.PollOnceAsync(); // 采集一轮 _mainForm.RefreshDashboard(); // 刷新界面 await Task.Delay(50); // 留出渲染时间 } GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); }这里有两个细节很关键。第一循环前的强制回收必须做否则你拍的第一张快照里包含了之前所有的历史垃圾基线就废了。第二循环里要留出界面渲染时间因为 WPF 和 WinForms 的部分泄漏是发生在渲染管线里的跑得太快反而不复现。我一般用 50 到 100 毫秒的间隔。复现脚本跑完之后不要急着拍快照先等两三秒让 UI 线程把排队的渲染任务处理完否则会有大量中间状态对象被算进快照里。3.2 拍两张快照重点看哪几列VS 里拍快照的两种入口调试状态下在诊断工具窗口点拍摄快照或者用性能探查器启动后点拍摄快照。两者拍出来的东西一样但性能探查器那个能额外记录分配堆栈。流程是这样启动调试等程序完全初始化完毕拍第一张快照我习惯给它改名叫基线。然后点复现按钮等循环跑完再拍第二张快照叫复现后。然后在快照列表里选中第二张点对象类型表把视图切到差异。这时候表格里会出现几列我逐个说清楚含义列名含义排查时怎么用计数当前快照里该类型的实例总数绝对值大不代表有问题缓存本来就会有几万个计数差异相对上一张快照的实例数变化核心指标正增长且接近循环次数就是嫌疑对象大小该类型所有实例占用的字节数判断影响面字节数组要看这个大小差异相对上一张快照的字节变化用于算每次循环泄漏多少字节非独占大小包含它引用的所有对象判断泄漏子树有多大排序我一般按计数差异降序。重点看两类一是差异值接近 100 的正好等于循环次数二是差异值为 0 但大小差异很大的。前者说明每轮都新建一个不回收后者说明某几个大对象在原地膨胀比如一个 Dictionary 或 List 在不断 Add。这里有个坑很多类型都会出现计数差异为正比如各种反射缓存、JIT 产生的类型、字符串字面量。判断的关键是看这个类型是不是你自己代码里的业务类型以及它持有的大小。泛型集合如 List 、DictionaryK,V 会显示为具体实例化类型比如 List 这种一眼就能看出来。3.3 Paths to Root把引用链读到最上面一层锁定嫌疑类型之后在类型上右键选查看实例会列出该类型的所有活着的实例。随便挑一个右键选根路径Paths to RootVS 会给你一棵树展示从 GC 根静态字段、线程栈、GC 句柄、终结器队列到这个对象的完整引用链。这棵树怎么读从叶往上看每一层都会标出字段名或者集合元素索引。你要找的是那条不应该存在的路径。比如[Static] AppEvents.OrderSaved → EventHandler._invokeList → OrderView.OnOrderSaved (target) → OrderView看到这条链基本就定性了一个静态事件 AppEvents.OrderSaved 的委托链里挂着 OrderView 实例。静态字段是 GC 根它的生命周期等于整个 AppDomain只要不退订OrderView 永远不会被回收。每次打开界面新建一个 OrderView就多挂一个这就是典型的静态事件泄漏。再举一个更隐蔽的[Finalizer Queue] TimerHolder → TimerQueueTimer._timer → Heartbeat.Send (target) → Heartbeat终结器队列也是 GC 根。System.Threading.Timer 在没有 Dispose 的情况下会被 TimerQueue 内部的静态链表持有回调委托指向你的实例方法于是你的整个对象图都被拖住了。这条链特别容易被忽略因为 Timer 对象本身很小你在类型列表里根本不会注意到它。我通常会在根路径树里做一件事把每一条路径复制到记事本里然后把它们分组。如果十条路径里有八条都经过同一个字段那这个字段就是主犯如果分散在七八个不同字段说明是多点泄漏得逐个改。3.4 命令行下用 SOS 追踪dumpheap 加 gcroot没有 VS 的时候就得靠 dotnet-dump。完整流程# 采集 dotnet-dump collect -p 12345 -o /tmp/core.dmp # 进入分析会话 dotnet-dump analyze /tmp/core.dmp进来之后先加载 SOS.NET Core 的 dotnet-dump 会自动加载然后跑统计 dumpheap -stat Statistics: MT Count TotalSize Class Name 00007ff9... 1 1234 98765 System.Byte[] 00007ff9... 2 5432 456789 MyApp.DeviceReading 00007ff9... 3 981 123456 System.Collections.Generic.ListMyApp.DeviceReadingdumpheap -stat 给出的是按类型聚合的结果按 TotalSize 排序。但统计单独的 dump 有个问题你只有一张没法做差值。所以我在生产环境排查时习惯采两张 dump一张是稳态运行一段时间后一张是再跑 N 轮之后然后在两次分析会话里手工对比 Count 列。锁定类型之后拿方法表地址去列实例 dumpheap -mt 00007ff9ABCD1234 -min 1000-mt 后面跟的是上一步查到的 Method Table 地址-min 1000 表示只列大于 1000 字节的实例避免刷屏。输出里每一行是一个对象地址随便挑一个 gcroot 0000020a12345678gcroot 会打印出所有到达这个对象的根路径格式和 VS 里那棵树差不多Found 1 unique root(s): 00007ff9abcd0000 MyApp.AppEvents static var OrderSaved ...要判断对象本身多大用 objsize objsize 0000020a12345678要看字段内容用 dumpobj能看到各个字段的当前值这在确认这个集合里到底存了什么 key的时候特别有用 dumpobj 0000020a12345678我一般会 dumpobj 一个 Dictionary 实例看看里面的 key 是不是带时间戳或者 GUID这一招几乎能瞬间确认无界缓存型泄漏。提示dotnet-dump 采集的完整转储可能非常大工控机磁盘空间紧张的话先用 dotnet-gcdump确认是托管问题再采完整 dump。4. 八种高频泄漏模式逐个拆开看工具会用了接下来是见过才能认出来。我在实际项目里遇到的泄漏八成以上能归到下面这八类里。每一类我都会给一段反例代码和对应的修法代码都比较短但你可以在自己的项目里搜相似结构。4.1 事件订阅没退订这是头号杀手尤其是跨模块通信和静态事件。看代码public sealed class PriceFeed { public event EventHandlerdecimal? PriceChanged; public void Publish(decimal price) PriceChanged?.Invoke(this, price); } public sealed class PricePanel { private readonly PriceFeed _feed; private readonly byte[] _renderBuffer new byte[1024 * 200]; public PricePanel(PriceFeed feed) { _feed feed; _feed.PriceChanged OnPriceChanged; // 订阅了但从来没退 } private void OnPriceChanged(object? sender, decimal price) { // 刷新界面 } }PriceFeed 在这里是长生命周期的通常是单例或者 Module 级对象它的 PriceChanged 委托链里持有 PricePanel 的实例方法引用而委托是强引用。每次新建 PricePanel 就在链上多挂一个旧的永远不回收连带着 200KB 的 _renderBuffer 一起泄漏。修法有三种。最直接的是实现 IDisposable 并在销毁时退订public sealed class PricePanel : IDisposable { private readonly PriceFeed _feed; public PricePanel(PriceFeed feed) { _feed feed; _feed.PriceChanged OnPriceChanged; } private void OnPriceChanged(object? sender, decimal price) { } public void Dispose() _feed.PriceChanged - OnPriceChanged; }第二种是发布端主动用弱引用。WPF 里的 WeakEventManager 就是干这个的WeakEventManagerPriceFeed, decimal.AddHandler( _feed, nameof(PriceFeed.PriceChanged), OnPriceChanged);第三种是改架构让发布者和订阅者一起生一起死不搞跨生命周期的订阅。我个人偏好第一种因为它显式、可控review 的时候一眼能看出来弱事件虽然优雅但会让为什么我的回调没执行变成新的排查难题。4.2 静态集合当缓存用没有上限也没有过期public static class SnapshotCache { private static readonly ConcurrentDictionarystring, byte[] _cache new(); public static byte[] Get(string key) _cache.GetOrAdd(key, k LoadFromDisk(k)); }这段代码本身写得挺漂亮问题在调用方var key ${deviceId}_{DateTime.Now:yyyyMMddHHmmssfff}; var data SnapshotCache.Get(key);key 里带了毫秒级时间戳每次调用都是一个新 key字典无限增长。而且因为 ConcurrentDictionary 是静态的它是 GC 根里面所有 byte[] 全部根可达。这种泄漏增长曲线非常标准——斜率完全线性跑多久涨多久。修法不是简单加个 Remove而是要给缓存加上限和过期策略。简单场景可以用 Microsoft.Extensions.Caching.Memory 里的 MemoryCacheprivate static readonly MemoryCache _cache new(new MemoryCacheOptions { SizeLimit 512, // 最多 512 个单位 CompactionPercentage 0.25 // 满了之后压缩 25% }); public static byte[] Get(string key) { return _cache.GetOrCreate(key, entry { entry.Size 1; entry.AbsoluteExpirationRelativeToNow TimeSpan.FromMinutes(10); return LoadFromDisk(key); })!; }注意MemoryCache 的 SizeLimit 配了但 entry.Size 不设的话限制不会生效这个坑我踩过。另外 ConcurrencyLimit 默认是 CPU 核数工控机上核数少的话记得调。4.3 Timer 忘了 Dispose对象跟着一起挂public sealed class Heartbeat { private readonly System.Threading.Timer _timer; public Heartbeat() { _timer new System.Threading.Timer(_ Send(), null, 0, 5000); } private void Send() { } }System.Threading.Timer 的内部实现是把自己挂到 TimerQueue 的静态链表上只要没 Dispose这个 Timer 就一直是根可达的。更麻烦的是回调委托_ Send()捕获了 this所以整个 Heartbeat 实例也被拖住。如果 Heartbeat 里还持有设备连接对象那连接对象的 Socket 也不会释放。正确写法就是老老实实实现 IDisposablepublic sealed class Heartbeat : IDisposable { private readonly System.Threading.Timer _timer; private int _disposed; public Heartbeat() { _timer new System.Threading.Timer( _ Send(), null, TimeSpan.Zero, TimeSpan.FromSeconds(5)); } private void Send() { if (Volatile.Read(ref _disposed) 1) return; } public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) 1) return; _timer.Dispose(); } }这里加了 _disposed 检查是因为 Timer.Dispose() 不会中断已经在执行的回调如果不检查回调可能在对象已经被释放之后还在跑去访问已经关闭的连接抛 ObjectDisposedException。这个坑在排查阶段很难看出来因为异常被吞在一个空 catch 里。同类的还有 System.Timers.Timer它多了一个 AutoReset 和事件订阅泄漏路径类似但它还会额外持有订阅者。DispatcherTimer 是另一个典型WPF 里没 Stop 的 DispatcherTimer 会被 Dispatcher 的定时器列表持有而且它是 UI 线程上的泄漏起来连界面都会被拖慢。4.4 异步状态机与闭包捕获public void StartMonitor() { var bigBuffer new byte[1024 * 1024 * 50]; // 50MB _ Task.Run(async () { while (true) { await Task.Delay(1000); Console.WriteLine(bigBuffer.Length); } }); }这段代码的问题在于闭包会把 bigBuffer 提升为编译器生成的状态机的字段而这个状态机挂在一个永不完成的 Task 上Task 又被线程池的 continuation 链和定时器队列引用着。结果就是 50MB 的数组跟着这个死循环一起活着而且没有任何办法能停掉它。这类泄漏的识别特征是快照里出现很多名字带c__DisplayClass或者d__的编译器生成类型。这些名字一眼就能认出来是闭包和状态机看到它们数量飙升就要警惕。修法其实很简单用 CancellationToken 把生命周期管起来public sealed class Monitor : IDisposable { private readonly CancellationTokenSource _cts new(); public void StartMonitor() { var buffer new byte[1024 * 1024 * 50]; var token _cts.Token; _ Task.Run(async () { while (!token.IsCancellationRequested) { await Task.Delay(1000, token).ConfigureAwait(false); Console.WriteLine(buffer.Length); } }, token); } public void Dispose() _cts.Cancel(); }另一个高频场景是 async void。async void 方法抛出的异常无法被捕获而且调用方拿不到 Task也就没法知道它什么时候结束。如果里面挂着一个长循环同样会泄漏。我现在的原则是除了事件处理器业务代码一律不用 async void。4.5 WPF 和 WinForms 里的绑定与静态事件WPF 的泄漏有几个独有的来源。第一个是绑定到不实现 INotifyPropertyChanged 的普通对象。老版本 .NET Framework 下WPF 的绑定引擎会通过 PropertyDescriptor 建立对源对象的强引用导致源对象被 Binding 持有。.NET 4.5 之后对实现了 INPC 的源改用了弱引用但如果你的 ViewModel 是 POCO 且绑定用了完全限定路径还是可能出问题。我现在的习惯是所有 ViewModel 一律实现 INotifyPropertyChanged不给自己留隐患。第二个是静态事件和全局消息总线public partial class OrderView : UserControl { public OrderView(OrderViewModel vm) { InitializeComponent(); DataContext vm; AppEvents.OrderSaved OnOrderSaved; // AppEvents.OrderSaved 是 static event } private void OnOrderSaved(object? sender, OrderSavedEventArgs e) { } }每次导航到这个页面就新建一个 OrderView静态事件上就多挂一个。修法是在 Unloaded 事件里退订或者改用弱事件。WinForms 里对应的是各种静态事件比如 Application.Idle、SystemEvents.UserPreferenceChanged这些特别阴因为它们藏在框架内部你看自己的代码根本看不出来。第三个是 WPF 的容器虚拟化。ItemsControl 如果没有开启 VirtualizingStackPanel或者外层套了 ScrollViewer 破坏了虚拟化一千条数据就会生成一千个容器。虽然这些容器在你滚动离开视野后理论上可以回收但如果数据模板里有绑定到静态资源的转换器或者用了自定义的附加行为它们就可能被静态引用拖住。判断方法是看快照里 ContentPresenter 和你的数据模板根元素的数量。4.6 HttpClient 与 IDisposable 资源的正确姿势// 反例一每次都 newsocket 耗尽 public async Taskstring GetAsync(string url) { using var client new HttpClient(); return await client.GetStringAsync(url); } // 反例二单例但从来不用配置不刷新 private static readonly HttpClient _client new HttpClient();第一种写法会导致 TIME_WAIT 状态的 socket 大量堆积虽然严格说不是内存泄漏但在上位机里表现是一模一样的——跑久了就连接不上句柄数暴涨。第二种写法没泄漏但失去了 DNS 刷新能力服务端切 IP 之后你会一直连旧地址。正确姿势是用 IHttpClientFactory或者至少做一个带过期时间的单例管理器services.AddHttpClient(device, c { c.Timeout TimeSpan.FromSeconds(10); c.DefaultRequestHeaders.ConnectionClose false; }) .SetHandlerLifetime(TimeSpan.FromMinutes(5)); // 5 分钟轮换一次 handler除了 HttpClient其他 IDisposable 资源也要注意。我见过最多的三个是FileStream 在异常路径上没走到 Dispose用 using 就不会有这个问题、SerialPort 没 Close 导致端口被占、SqlConnection 在连接池满的时候被误判为泄漏。这里有个通用的判断方法在快照里看类型名如果出现大量 XXXStream、SafeHandle 子类、Connection 子类的实例优先怀疑资源没释放。4.7 非托管句柄与 GDI 对象的排查当托管堆快照显示一切正常但内存还在涨就该换赛道了。先看句柄数任务管理器 - 详细信息 - 右键列头 - 选择列 - 勾上句柄和GDI 对象。跑 N 轮业务句柄数从 800 涨到 5000那就是句柄泄漏。GDI 对象泄漏的经典代码private void UpdatePreview(string path) { var bmp new Bitmap(path); pictureBox.Image bmp; // 上一张图从来没 Dispose }每次调用都新建 Bitmap赋给 PictureBox 之后旧的 Bitmap 引用被覆盖理论上会被 GC 回收但 Bitmap 的 Dispose 会释放底层的 GDI 句柄而 GC 回收托管对象后还要等终结器线程跑 Finalize终结器队列一堆积GDI 句柄就下不去。修法private void UpdatePreview(string path) { var old pictureBox.Image; pictureBox.Image new Bitmap(path); old?.Dispose(); }GDI 对象的具体类型分布用 GDIView 能看到 Bitmap、Pen、Brush、Font 各占多少。这个工具很小下载解压就能跑比写代码排查快多了。非托管内存还有一种情况C/CLI 或者 P/Invoke 里的手动分配。这方面 VS2022 提供了原生内存视图需要 .NET 5 及以上可以在诊断工具里勾选内存使用率后切到原生内存标签能看到原生分配的调用堆栈。这个功能挺新的处理混合模式程序特别有用。4.8 被冤枉的 LOH 碎片化有一种情况是看起来像泄漏其实是碎片。大于 85000 字节的对象走 LOH大对象堆LOH 默认不压缩只在 Gen2 回收时做标记清除。如果你反复分配和释放大小接近但不完全相同的大数组LOH 上会留下大量空洞虽然存活对象不多但堆的提交大小一直很高。判断方法是在性能探查器的内存工具里看 LOH 的碎片率或者在 dumpheap -stat 里看 Free 对象的 TotalSize。如果 System.Free 类型的 TotalSize 很大而 Count 不多那就是碎片。修法有两条一是把大对象拆成小块比如把 50MB 的数组改成 5 个 10MB 的二是显式触发 LOH 压缩GCSettings.LargeObjectHeapCompactionMode GCLargeObjectHeapCompactionMode.CompactOnce; GC.Collect();注意LOH 压缩会暂停所有线程几百 MB 的 LOH 压缩可能要一两秒。在实时性要求高的场景里只能作为应急手段不能定时调用。5. 容易误判的几种情况与排查速查表排查过程中最容易出的问题不是找不到原因而是找错原因。下面几种情况我都实际遇到过写出来供参考。5.1 GC 没跑不代表泄漏第一个常见误判只看任务管理器的内存数字看到涨就以为泄漏。实际上 GC 的回收时机是运行时根据分配速率动态决定的如果一个程序分配得很少GC 可能几分钟才跑一次这段时间内内存当然在涨。判断标准必须是强制 Full GC 之后不降。第二个误判.NET Core 的 Server GC 模式。Server GC 会为每个 CPU 核分配独立的堆段而且默认的段大小是 64MB 到几 GB 不等取决于配置所以一个 8 核机器上进程的提交大小天然就会比 Workstation GC 高出几百 MB。这不是泄漏是设计使然。要不要关掉 Server GC取决于你的程序是吞吐优先还是内存优先。上位机一般建议用 Workstation GC可以在 csproj 里配PropertyGroup ServerGarbageCollectionfalse/ServerGarbageCollection ConcurrentGarbageCollectiontrue/ConcurrentGarbageCollection /PropertyGroup第三个误判把缓存预热当成泄漏。第一张快照是在预热前拍的第二张是在预热后拍的中间跑了一次业务循环结果所有缓存相关的类型都显示正增长。避免方法就是我在 3.1 节强调的跑业务循环之前先做一轮完整的预热把缓存填满再拍基线。5.2 常见问题速查表现象可能原因优先使用的工具定位要点GC 堆强回收后仍持续上涨托管对象被根引用VS 快照对比 根路径看计数差异接近循环次数的类型提交大小涨但 GC 堆稳定非托管资源或 LOH 碎片任务管理器 GDIView看句柄数和 GDI 对象数句柄数持续增长文件/串口/Socket 未释放dotnet-dump搜 SafeHandle 系列的实例数GDI 对象数增长Bitmap/Pen/Brush 未释放GDIView按类型看分布快照里出现大量编译器生成类型闭包或状态机泄漏VS 快照对比搜c__DisplayClass和d__静态字段引用链始终存在静态事件或静态集合根路径视图看 Static 类型的根终结器队列里有大量对象IDisposable 未及时释放dotnet-dump看 Finalizer Queue 根LOH 提交大但存活对象少大对象堆碎片PerfView看 Free 对象的占比界面卡顿且内存缓升WPF 绑定或容器泄漏VS 内存工具看 ContentPresenter 数量5.3 我自己用的几条排查习惯第一条习惯永远先改动最少的那个方案。比如怀疑是事件泄漏先在卸载事件里加一行退订跑 100 遍验证不要一上来就重构整个通信架构。我见过太多人花了三天做完重构结果问题还在因为根因在另一个地方。第二条习惯每次只验证一个假设。如果内存曲线下降了你要能说清楚是哪一行代码改的。如果同时改了五处曲线降了你也不知道哪处有用下次遇到同样问题还是不会。第三条习惯给关键路径加上对象计数的探针。比如在设备管理器的构造函数里加Interlocked.Increment(ref _instanceCount)析构或者 Dispose 里减一然后用性能计数器或者定时日志打出来。这个方法土但在不能挂调试器的现场特别有用而且它能给你一个长期的监控指标下次出问题你能第一时间发现。第四条习惯把复现脚本留成一个隐藏的调试入口发布前用条件编译排除#if DEBUG // 泄漏复现脚本 #endif这样下次出问题不用重新写直接打开就能跑。最后分享一个我踩过最深的坑。有一次排查一个上位机程序的内存泄漏快照对比显示 DeviceReading 对象每次采集都净增一个根路径指向一个叫_latest的字段。我改了半天最后发现那个字段是用来给界面显示最新一条数据的本身设计就是要留一个问题出在另一处每次采集都在一个静态的 ObservableCollection 上 Add而界面绑定没有用虚拟化。真正的泄漏源是那个集合_latest只是恰好也指向了最新那条数据误导了我两个小时。这件事之后我养成了一个习惯根路径树至少要读三条如果三条都指向同一个集合那才是真凶只有一条指向某个字段很可能是巧合。
返回列表