挑战:你能找到多少内存泄漏?





0/5 (0投票)
MVP Rainer Stropek 为 .NET 开发者设置了一个有趣的挑战。下面是一个包含 3 个内存泄漏的示例应用程序。你能找到它们吗?
引言
.NET 的垃圾回收器有时会给人一种 .NET 开发者不需要关心内存使用的印象。实际上,情况并非如此。稍有不慎,你的应用程序就会出现大量内存泄漏。
在本文中,我准备了一个只有 32 行 C# 代码的微型 WPF 应用程序,其中隐藏了一些内存泄漏。WPF 代码并非最优,并且缺乏按照 MVVM 原则进行的适当分离。我特意保持原样,因为它反映了我在研讨会和培训课程中经常看到的情况。
你能找出其中的内存泄漏吗?
示例应用程序
示例程序由一个带有主菜单和选项卡控件的窗口组成。在后者中,你可以打开客户列表。你也许以后可以向其中添加其他列表(产品、订单等)。客户列表由一个包含姓名的列和一个包含详细信息的列组成。
下面的截图显示了示例程序的用户界面
代码
GitHub
代码可从这个 GitHub 仓库 下载
数据访问
数据访问使用 Entity Framework 进行处理。以下是表示客户的类
namespace WpfApplication19 { public class Customer { [Key] public string FirstName { get; set; } public string LastName { get; set; } } }
此类在Entity Framework DbContext中使用
using System; using System.Data.Entity; namespace WpfApplication19 { class CustomerRepository : DbContext { public CustomerRepository() : base( "Server=(localdb)\\v11.0;Database=DemoCrm;Integrated Security=true") { } public DbSet<Customer> Customers { get; set; } } }
主窗口
主窗口的 XAML 代码仅包含菜单和选项卡控件
<Window x:Class="WpfApplication19.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="MainWindow" Height="350" Width="525">
<DockPanel>
<Menu DockPanel.Dock="Top" Name="MainMenu">
<MenuItem Header="Open new tab" Click="OnCreateNewTab" />
<MenuItem Header="Close tab" Click="OnCloseTab" />
<MenuItem Header="Print" Name="PrintMenuItem" />
</Menu>
<TabControl Name="Content"/>
</DockPanel>
</Window>
应用程序的开发者计划使用 Managed Extensibility Framework (MEF) 来表示不仅仅是客户列表,因此他在OnStartup
方法中使用CompositionContainer
。
using System.ComponentModel.Composition.Hosting;
using System.Reflection;
using System.Windows;
namespace WpfApplication19
{
public partial class App : Application
{
public CompositionContainer Container { get; private set; }
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
// Setup DI container for executing assembly
this.Container = new CompositionContainer(
new AssemblyCatalog(Assembly.GetExecutingAssembly()));
}
}
}
MEF 用于主窗口的代码数据,以创建客户列表
using System.ComponentModel.Composition; using System.Windows; using System.Windows.Controls; namespace WpfApplication19 { public partial class MainWindow : Window { private App currentApp; public MainWindow() { InitializeComponent(); this.currentApp = ((App)Application.Current); // Export print menu via MEF so that all views can subscribe to click event. ((App)Application.Current).Container.ComposeExportedValue<MenuItem>("PrintMenuItem", this.PrintMenuItem); } private void OnCreateNewTab(object sender, RoutedEventArgs e) { // Note that we use MEF to create instance here. var view = this.currentApp.Container.GetExportedValue<UserControl>("CustomerView"); this.Content.Items.Add( new TabItem() { Header = "Customers", Content = view }); } private void OnCloseTab(object sender, RoutedEventArgs e) { var selectedView = this.Content.SelectedItem as TabItem; if (selectedView != null) { // Remove selected tab this.Content.Items.Remove(selectedView); } } } }
客户列表
现在是客户列表。XAML 代码再次使用简单的结构
<UserControl x:Class="WpfApplication19.CustomerControl" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> <DockPanel> <ListBox Name="CustomersList" ItemsSource="{Binding Path=Customers}" DisplayMemberPath="LastName" DockPanel.Dock="Left" MinWidth="250" /> <Grid Margin="5"> <Grid.RowDefinitions> <RowDefinition Height="Auto" /> <RowDefinition Height="Auto" /> <RowDefinition Height="Auto" /> </Grid.RowDefinitions> <TextBlock Text="Details:" FontSize="15" /> <TextBlock Text="{Binding ElementName=CustomersList, Path=SelectedItem.FirstName}" Grid.Row="1" Margin="0,10,0,0" /> <TextBlock Text="{Binding ElementName=CustomersList, Path=SelectedItem.LastName}" Grid.Row="2" /> </Grid> </DockPanel> </UserControl>
相关的代码包含两个重要方面
- 该类正确实现了
IDisposable
,因为它包含对DbContext
的引用,而该类实现了IDisposable
。 - MEF 被用来获取对主菜单的引用,并将其连接到相应的事件处理程序。
这是代码
using System; using System.Collections.Generic; using System.ComponentModel.Composition; using System.Diagnostics; using System.Linq; using System.Windows.Controls; namespace WpfApplication19 { // Export class so that it can be created by MEF [Export("CustomerView", typeof(UserControl))] [PartCreationPolicy(CreationPolicy.NonShared)] public partial class CustomerControl : UserControl, IPartImportsSatisfiedNotification, IDisposable // Note that class has to implement IDisposable as it contains a member // (repository) that implements IDisposable, too. { private CustomerRepository repository = new CustomerRepository(); [Import("PrintMenuItem")] private MenuItem PrintMenuItem; public CustomerControl() { InitializeComponent(); // For simplicity we do not implement full MVVM here. Note that you should // use MVVM in practice when working with XAML. this.DataContext = this; } public IEnumerable<Customer> Customers { get { // In practice we would find more complex data access logic. In this simple // sample we just select all existing customers. return this.repository.Customers.ToArray(); } } public void OnImportsSatisfied() { // Connect to click event in main menu to "print" this item. this.PrintMenuItem.Click += (s, ea) => Debug.WriteLine("Printing {0} ...", this); } // Implementation of IDisposable public void Dispose() { this.Dispose(true); } private void Dispose(bool disposing) { if (disposing) { this.repository.Dispose(); GC.SuppressFinalize(this); } } } }
就是这样——你发现内存泄漏了吗?
如果你想试用示例应用程序,请随时尝试。不要忘记输入一些测试数据集,并且你可能需要更新数据库连接字符串才能做到。如果你继续使用 localdb,那么填充一些测试数据的最简单方法是在OnStartup
方法中添加如下代码
[...] protected override void OnStartup(StartupEventArgs e) { //Populate the database with some test data using (var context = new CustomerRepository()) { context.Customers.Add(new Customer { FirstName = "Ted", LastName = "Jones" }); context.Customers.Add(new Customer { FirstName = "Jeremy", LastName = "Hugo" }); context.Customers.Add(new Customer { FirstName = "Sarah", LastName = "Higgins" }); context.Customers.Add(new Customer { FirstName = "Fiona", LastName = "Wells" }); context.SaveChanges(); } base.OnStartup(e); [...]
如何检测内存泄漏
示例应用程序包含三个严重的编程错误,这些错误会导致内存泄漏。经验丰富的 WPF 开发者可能纯粹通过查看有问题代码就能找到它们,但大多数业务应用程序的代码量太大,无法一一检查。获得工具的支持通常是正确的方法。
作为一名软件架构师,我倾向于使用像 Red Gate 的 ANTS Memory Profiler 这样的工具。如果你想试用示例应用程序,只需下载内存分析器的免费试用版。
谜题的答案——追逐内存泄漏
现在我将向你展示如何检测内存泄漏以及如何纠正错误。
首先,启动 ANTS Memory Profiler 并选择要分析的应用程序。进行第一次内存快照,我们将用它作为基线。
然后,例如,我们通过打开新选项卡打开三个记录;然后我们关闭这三个选项卡,并进行第二次快照。如果内存已正确释放,内存中不应该有剩余对象。
现在我们有了两个快照,我们可以通过单击类列表并按有源代码的类进行筛选来轻松比较它们
很容易发现,我们的第二个快照中有三个CustomerControl
对象,尽管它们本应被释放。为什么会这样?
MEF 和IDisposable
这就是实例分类器的作用
这向我们展示了保持对CustomerControl
引用的对象。事情变得清晰起来:看起来 MEF组合容器是罪魁祸首。
此内存泄漏的原因是,MEFCompositionContainer
引用了实现IDisposable
的CustomerControl
对象,并且这些引用将一直存在,直到CompositionContainer
被释放(它本身实现了IDisposable
)。但在我们的例子中,CompositionContainer
会一直存在到应用程序终止,因此CustomerControl
对象永远不会被释放。找到一个内存泄漏。但我们如何消除它呢?
有几种解决方案。一种解决方案是使用CompositionContainer.ReleaseExport() 方法。对MainWindow.xaml.cs进行以下修改可以解决问题
[...] namespace WpfApplication19 { public partial class MainWindow : Window { [...] // Dictionary to remember exports that led to view objects private Dictionary<UserControl, Lazy<UserControl>> exports = new Dictionary<UserControl, Lazy<UserControl>>(); private void OnCreateNewTab(object sender, RoutedEventArgs e) { // Get the export that can be used to generate a new instance var viewExport = currentApp.Container.GetExport<UserControl>("CustomerView"); // Store the export and the generated instance this.exports.Add(viewExport.Value, viewExport); this.Content.Items.Add( new TabItem() { Header = "Customers", Content = viewExport.Value }); } private void OnCloseTab(object sender, RoutedEventArgs e) { var selectedTabItem = this.Content.SelectedItem as TabItem; if (selectedTabItem != null) { var selectedView = selectedTabItem.Content as UserControl; // Remove selected tab this.Content.Items.Remove(selectedTabItem); // Release the export; releases the created instance, too currentApp.Container.ReleaseExport(this.exports[selectedView]); this.exports.Remove(selectedView); } } } }
事件处理器
现在我们已经解决了IDisposable
问题,CustomerControl
对象应该被正确释放。我们重复上述测试,发现情况并非如此。
ANTS Memory Profiler 向我们展示,MenuItem
对我们的CustomerControl
对象持有间接引用。通过处理菜单项的点击事件,从菜单项到CustomerControl
对象的引用就被创建了。不幸的是,我们没有取消注册事件处理程序,这就是内存泄漏的来源。
在这个简单的例子中,解决方案是:必须取消注册事件处理程序。然而,在实践中,这通常是一个挑战,因为并不总是清楚可以在哪里进行。在这个例子中,我们使用匿名方法订阅了事件处理程序,这使得取消订阅变得困难。我们要么需要将匿名方法存储为委托,要么像我们在这里选择的那样,将匿名方法提取到一个名为Print
的方法中并进行注册,以便以后可以取消注册。
这是解决第二个内存泄漏的代码
namespace WpfApplication19 { [...] public void OnImportsSatisfied() { // Connect to click event in main menu to "print" this item. this.PrintMenuItem.Click += this.Print; } private void Print(object sender, RoutedEventArgs ea) { Debug.WriteLine("Printing {0} ...", this); } private void Dispose(bool disposing) { if (disposing) { this.PrintMenuItem.Click -= this.Print; this.repository.Dispose(); GC.SuppressFinalize(this); } } } }
没有INotifyPropertyChanged的数据绑定
现在让我们看看Customer
对象。为什么它们会留在内存中?CustomerControl
内存泄漏当然也会使Customer
对象留在内存中,但这是否就是全部?还是有其他原因?
这次,我们想使用实例列表查看单个Customer
对象实例
通过使用保留图,你可以确切地看到每个对象引用的内容。确实,除了CustomerControl
的引用外,还有更多对Customer
实例的引用。
类名提供了有关潜在问题的线索。在这种情况下,我们在Customer
类中创建了双向绑定,但没有使用INotifyPropertyChanged
实现。在 WPF 中这不是个好主意——会保留所有这些对象的引用。属性CustomerControl.Customers
中的Customer列表也不对。双向绑定需要实现INotifyCollectionChanged
。找到第三个内存泄漏。
该问题通过实现INotifyPropertyChanged
来解决
using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Runtime.CompilerServices; namespace WpfApplication19 { public class Customer : INotifyPropertyChanged { private string FirstNameValue; [Key] public string FirstName { get { return this.FirstNameValue; } set { if (this.FirstNameValue != value) { this.FirstNameValue = value; this.RaisePropertyChanged(); } } } private string LastNameValue; public string LastName { get { return this.LastNameValue; } set { if (this.LastNameValue != value) { this.LastNameValue = value; this.RaisePropertyChanged(); } } } private void RaisePropertyChanged([CallerMemberName]string propertyName = null) { if (this.PropertyChanged != null) { this.PropertyChanged(this, new PropertyChangedEventArgs(propertyName)); } } public event PropertyChangedEventHandler PropertyChanged; } }
结论
内存泄漏在 .NET 中比大多数初学者认为的更频繁。库使用不当或事件处理程序未正确移除很快就会导致内存泄漏。
没有像ANTS Memory Profiler这样的内存分析器,真正只能解决小型应用程序中的问题。如果你正在处理一个较大的商业应用程序,那么一个专业的分析器很可能是一笔不错的投资。