用 dotMemory 定位 C# WPF 客户端内存泄露(附最小可复现示例)
前言
WPF(Windows Presentation Foundation)运行在 .NET 托管环境中,理论上由 GC(垃圾回收器)自动回收内存。但"自动管理"并不等于"不会泄露"——在 WPF 开发中,事件订阅、数据绑定、静态引用等很容易让对象"本该被回收却回收不掉",导致内存持续上涨、界面越来越卡,最终崩溃。
很多开发者以为托管环境没有内存泄露,直到线上客户端内存占用涨到几个 GB 才意识到问题。本文介绍如何用 JetBrains 的 dotMemory 定位 WPF 应用的内存泄露,并给出一个最小可复现的示例。
一、WPF 内存泄露的常见原因
- 事件订阅后未取消订阅(最常见):窗口/控件订阅了静态对象或单例的事件,关闭时忘了
-=。 - 静态字段持有实例引用:把窗口、控件或大对象塞进
static集合后不清理。 - 数据绑定未实现
INotifyPropertyChanged:绑定到普通对象且Mode不是OneTime,WPF 会通过反射/属性描述符持有引用。 DispatcherTimer/ 后台线程未停止。- 未释放非托管资源:
Bitmap、Font、Brush、ImageSource等。 - lambda / 闭包捕获了长生命周期的外部变量。
ICommand/ 事件聚合器(Event Aggregator)的循环引用。
其中事件订阅泄露是最典型、最好复现、也最能体现 dotMemory 价值的场景,本文就以它为例。
二、dotMemory 简介
dotMemory 是 JetBrains 出品的 .NET 内存分析工具,属于 dotUltimate 套件,也可以集成在 Rider 中使用。它的核心能力:
- 获取内存快照(Snapshot)。
- 对比多个快照,找出"新增对象 / 存活对象 / 已回收对象"。
- 查看对象的保留路径(Retention Paths / Key Retention Paths),回答"到底是谁拽着这个对象不让 GC 回收"。
- 分析支配树(Dominators)、大对象堆(LOH)等。
本文以独立版 dotMemory 为例,界面术语会同时给出中文说明。
三、准备一个会泄露的示例
我们先写一个"故意泄露"的 WPF 应用,用来演示定位过程。
3.1 一个全局事件源(单例)
// EventService.cs
using System;
namespace LeakDemo
{
/// <summary>
/// 全局事件源:进程存活期间一直存在(静态单例)。
/// </summary>
public sealed class EventService
{
public static readonly EventService Instance = new EventService();
// 注意:这是一个"实例事件",订阅它的对象会被事件源反向持有引用
public event EventHandler? DataChanged;
public void RaiseDataChanged()
{
DataChanged?.Invoke(this, EventArgs.Empty);
}
}
}
3.2 一个会泄露的窗口
// LeakyWindow.xaml.cs
using System;
using System.Windows;
namespace LeakDemo
{
public partial class LeakyWindow : Window
{
public LeakyWindow()
{
InitializeComponent();
// 订阅单例的事件,但关闭窗口时【没有取消订阅】
EventService.Instance.DataChanged += OnDataChanged;
}
private void OnDataChanged(object? sender, EventArgs e)
{
Title = $"收到事件:{DateTime.Now:HH:mm:ss.fff}";
}
}
}
<!-- LeakyWindow.xaml -->
<Window x:Class="LeakDemo.LeakyWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="LeakyWindow" Height="200" Width="300">
<TextBlock Text="这是一个会泄露的窗口" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Window>
3.3 主窗口触发打开/关闭
// MainWindow.xaml.cs
using System.Windows;
namespace LeakDemo
{
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
private void OpenLeakyWindow_Click(object sender, RoutedEventArgs e)
{
var win = new LeakyWindow();
win.Show();
}
private void RaiseEvent_Click(object sender, RoutedEventArgs e)
{
EventService.Instance.RaiseDataChanged();
}
}
}
<!-- MainWindow.xaml -->
<Window x:Class="LeakDemo.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="MainWindow" Height="200" Width="400">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<Button Content="打开 LeakyWindow" Click="OpenLeakyWindow_Click" Margin="0,0,0,10"/>
<Button Content="触发事件" Click="RaiseEvent_Click"/>
</StackPanel>
</Window>
泄露原理:EventService.Instance.DataChanged 是一个委托链,每打开一次 LeakyWindow 就往链里追加一个指向 OnDataChanged 的委托,而 OnDataChanged 是 LeakyWindow 的实例方法,委托会持有 this(即窗口实例)。只要静态单例 EventService.Instance 还活着(整个进程存活期间它都活着),所有被订阅过的窗口实例就都无法被 GC 回收。
四、用 dotMemory 定位泄露
4.1 编译并运行示例
用 Visual Studio 编译运行上面的 WPF 应用(Debug 或 Release 均可,建议 Release 更贴近真实环境)。
4.2 附加到进程
- 打开 dotMemory,选择 Profiling 页签。
- 点击 Attach to Running Process(附加到运行中的进程),在列表中找到你的 WPF 进程(进程名通常与项目名一致,如
LeakDemo.exe)。 - 附加成功后,主界面会显示内存概览和 Get Snapshot(获取快照)按钮。
4.3 获取基线快照
- 先点击 Force GC(强制 GC),把"可回收但还没回收"的对象先清掉,排除假象。
- 点击 Get Snapshot,得到快照 #1(基线)。
4.4 制造泄露
- 回到应用,反复点击"打开 LeakyWindow",然后关闭窗口,重复 20 次(次数多一点对比更明显)。
- 回到 dotMemory,再次 Force GC,再 Get Snapshot,得到快照 #2。
4.5 对比快照
- 在快照列表里勾选快照 #1 和 #2。
- 点击 Compare(对比),进入对比视图。
- 对比视图通常分为 New objects(新增对象)、Dead objects(已回收对象)、Survived objects(存活对象)几类。
4.6 搜索泄露的类型
- 在 New objects 视图中,用搜索框输入
LeakyWindow(或它的完整类型名LeakDemo.LeakyWindow)。 - 你会看到 20 个
LeakyWindow实例仍然存活——而我们早就把窗口关掉了,理想情况下应该是 0 个。
4.7 查看保留路径,找到根因
- 选中任意一个
LeakyWindow实例。 - 查看 Key Retention Paths(关键保留路径),会看到类似这样的引用链:
LeakyWindow
← EventHandler (LeakDemo.LeakyWindow.OnDataChanged)
← EventService.DataChanged
← EventService.Instance (static field)
这条路径清晰地告诉我们:静态单例 EventService.Instance 通过 DataChanged 事件订阅,反向持有了每一个 LeakyWindow 实例,所以它们永远无法被回收。根因就此定位。
五、修复方案
针对"事件订阅未取消"这类泄露,常见有三种修法,按推荐程度排列。
方案一:关闭时取消订阅(最简单直接)
public partial class LeakyWindow : Window
{
public LeakyWindow()
{
InitializeComponent();
EventService.Instance.DataChanged += OnDataChanged;
// 窗口关闭时取消订阅
Closed += OnWindowClosed;
}
private void OnDataChanged(object? sender, EventArgs e)
{
Title = $"收到事件:{DateTime.Now:HH:mm:ss.fff}";
}
private void OnWindowClosed(object? sender, EventArgs e)
{
EventService.Instance.DataChanged -= OnDataChanged;
}
}
方案二:使用 WeakEventManager(弱事件模式)
当订阅方生命周期不确定、容易漏写取消订阅时,用 WPF 自带的 WeakEventManager 让事件源不持有强引用:
public partial class LeakyWindow : Window
{
public LeakyWindow()
{
InitializeComponent();
WeakEventManager<EventService, EventArgs>.AddHandler(
EventService.Instance,
nameof(EventService.DataChanged),
OnDataChanged);
Closed += (s, e) => WeakEventManager<EventService, EventArgs>.RemoveHandler(
EventService.Instance,
nameof(EventService.DataChanged),
OnDataChanged);
}
private void OnDataChanged(object? sender, EventArgs e)
{
Title = $"收到事件:{DateTime.Now:HH:mm:ss.fff}";
}
}
需要给
EventService添加一个对应的static事件(WeakEventManager要求事件源提供静态事件),或改用社区成熟的弱事件库。
方案三:WeakReference 包装
用 WeakReference 持有订阅方,事件触发时再判断目标是否存活。逻辑较繁琐,一般优先选前两种。
六、验证修复效果
修复后,重复第四节的流程:
- 重新编译运行。
- 附加 dotMemory,Force GC 后取基线快照。
- 反复打开/关闭窗口 20 次。
- Force GC 后再取快照,对比 New objects。
这次搜索 LeakyWindow,实例数量应为 0(已被回收),说明泄露已修复。
七、总结与最佳实践
- 凡订阅了长生命周期对象(静态字段、单例、App 级服务)的事件,务必在关闭/释放时取消订阅。
- 对生命周期不确定的订阅,优先使用
WeakEventManager等弱事件方案。 - 定时器、后台线程、非托管资源要显式停止/释放。
- 数据绑定目标对象应实现
INotifyPropertyChanged,避免绑定到普通对象产生隐式引用。 - 把内存检查纳入开发流程:功能开发阶段定期用 dotMemory 做快照对比,把"打开/关闭窗口后对象应回收"作为验收项。
内存泄露的本质不是"内存不够用",而是该释放的对象被一条看不见的引用链拽住了。dotMemory 的价值,就是把这根看不见的链子清晰地画出来。
&spm=1001.2101.3001.5002&articleId=163831672&d=1&t=3&u=1145c209e29f469caa0db70b895d630e)
1096

被折叠的 条评论
为什么被折叠?



