
C#(以及一般的 .NET)中的事件注册是内存泄漏的最常见原因。至少从我的经验来看是这样。事实上,我看到了太多来自事件的内存泄漏,以至于在代码中看到让我立即产生了怀疑。
虽然事件很棒,但它们也很危险。如果您不知道要查找什么,则很容易通过事件导致内存泄漏。在这篇文章中,我将解释此问题的根本原因,并提供几种处理此问题的最佳实践技术。最后,我将向您展示一个简单的技巧,以找出您是否确实存在内存泄漏。
了解内存泄漏
在垃圾收集环境中,术语“内存泄漏”有点违反直觉。既然有垃圾收集器负责收集所有东西,我的内存怎么会发生泄漏呢?
答案是,有了垃圾收集器(GC),内存泄漏意味着有些对象仍然被引用,但实际上未被使用。由于它们被引用,GC 不会回收它们,它们将永远存在,占用内存。
我们来看一个例子:
public class WiFiManager
{
public event EventHandler <WifiEventArgs> WiFiSignalChanged;
// ...
}
public class MyClass
{
public MyClass(WiFiManager wiFiManager)
{
wiFiManager.WiFiSignalChanged += OnWiFiChanged;
}
private void OnWiFiChanged(object sender, WifiEventArgs e)
{
// do something
}
}
public void SomeOperation(WiFiManager wiFiManager)
{
var myClass = new MyClass(wiFiManager);
myClass.DoSomething();
//... myClass is not used again
}
在这个例子中,我们假设WiFiManager在程序的整个生命周期内都处于活动状态。执行SomeOperation后,将创建一个MyClass实例,并且永远不会再使用。程序员可能认为 GC 会收集它,但事实并非如此。WiFiManager在其事件WiFiSignalChanged中持有对 MyClass 的引用,这会导致内存泄漏。GC 永远不会收集MyClass。
1. 确保取消订阅
显而易见的解决方案(尽管并不总是最简单的)是记住从事件中取消注册事件处理程序。一种方法是实现 IDisposable:
public class MyClass : IDisposable
{
private readonly WiFiManager _wiFiManager;
public MyClass(WiFiManager wiFiManager)
{
_wiFiManager = wiFiManager;
_wiFiManager.WiFiSignalChanged += OnWiFiChanged;
}
public void Dispose()
{
_wiFiManager.WiFiSignalChanged -= OnWiFiChanged;
}
private void OnWiFiChanged(object sender, WifiEventArgs e)
{
// do something
}
}
当然,你必须确保调用Dispose 。如果你有一个 WPF 控件,一个简单的解决方案是在Unloaded事件中取消订阅。
public partial class MyUserControl : UserControl
{
public MyUserControl(WiFiManager wiFiManager)
{
InitializeComponent();
this.Loaded += (sender, args) => wiFiManager.WiFiSignalChanged += OnWiFiChanged;
this.Unloaded += (sender, args) => wiFiManager.WiFiSignalChanged -= OnWiFiChanged;
}
private void OnWiFiChanged(object sender, WifiEventArgs e)
{
// do something
}
}
优点:简单、代码易读。
缺点:您很容易忘记取消订阅,或者在所有情况下都不会取消订阅,这将导致内存泄漏。
注意:并非所有事件注册都会导致内存泄漏。当注册一个您将存活很久的事件时,不会发生内存泄漏。例如,在 WPF UserControl中,您可能会注册一个按钮的Click事件。这很好,不需要取消注册,因为用户控件是唯一引用该按钮的控件。当没有人引用用户控件时,也不会有人引用按钮,GC将收集两者。
2. 让处理程序自行取消订阅
在某些情况下,您可能希望事件处理程序仅发生一次。在这种情况下,您需要代码自行取消订阅。当您的事件处理程序是命名方法时,这很容易:
public class MyClass
{
private readonly WiFiManager _wiFiManager;
public MyClass(WiFiManager wiFiManager)
{
_wiFiManager = wiFiManager;
_wiFiManager.WiFiSignalChanged += OnWiFiChanged;
}
private void OnWiFiChanged(object sender, WifiEventArgs e)
{
// do something
_wiFiManager.WiFiSignalChanged -= OnWiFiChanged;
}
}
但是,有时您希望事件处理程序是 lambda 表达式。在这种情况下,这里有一个有用的技巧可以让它自行取消订阅:
public class MyClass
{
public MyClass(WiFiManager wiFiManager)
{
var someObject = GetSomeObject();
EventHandler<WifiEventArgs> handler = null;
handler = (sender, args) =>
{
Console.WriteLine(someObject);
wiFiManager.WiFiSignalChanged -= handler;
};
wiFiManager.WiFiSignalChanged += handler;
}
}
在上面的例子中,lambda 表达式很有用,因为你可以捕获局部变量someObject,而使用处理程序方法则无法做到这一点。
优点:简单,可读,只要您确定事件至少会触发一次,就不会发生内存泄漏。
缺点:仅在需要处理一次事件的特殊情况下可用。
3. 使用弱事件和事件聚合器
在 .NET 中引用对象时,你基本上是在告诉 GC 该对象正在使用中,所以不要回收它。有一种方法可以引用对象而不必真正说“我正在使用它”。这种引用称为弱引用。你其实是在说“我不需要它,但如果它还在那里,我就会使用它”。在其他换句话说,如果一个对象仅被弱引用引用,则GC将收集它并释放该内存。这是使用 .NET 的WeakReference类实现的。
我们可以通过多种方式使用它来防止内存泄漏。一种流行的设计模式是使用事件聚合器 。概念是任何人都可以订阅类型 T 的事件,任何人都可以发布类型 T 的事件。因此,当一个类发布事件时,所有订阅的事件处理程序都将被调用。事件聚合器使用 WeakReference 引用所有内容。因此,即使一个对象订阅事件后,它仍然可以被垃圾收集。
下面是使用Prism 流行事件聚合器的示例(可通过 NuGet Prism.Core获得 )
public class WiFiManager
{
private readonly IEventAggregator _eventAggregator;
public WiFiManager(IEventAggregator eventAggregator)
{
_eventAggregator = eventAggregator;
}
public void PublishEvent()
{
_eventAggregator.GetEvent<WiFiEvent>().Publish(new WifiEventArgs());
}
}
public class MyClass
{
public MyClass(IEventAggregator eventAggregator)
{
eventAggregator.GetEvent<WiFiEvent>().Subscribe(OnWiFiChanged);
}
private void OnWiFiChanged(WifiEventArgs args)
{
// do something
}
}
public class WiFiEvent : PubSubEvent<WifiEventArgs>
{
// ...
}
优点:防止内存泄漏,相对容易使用。
缺点:充当所有事件的全局容器。任何人都可以订阅其他人。如果过度使用,系统将变得难以理解。没有关注点分离。
4. 将弱事件处理程序与常规事件结合使用
通过一些代码技巧,可以将弱引用与常规事件结合使用。这可以通过几种不同的方式实现。以下是使用 Paul Stovell 的WeakEventHandler的示例 :
public class MyClass
{
public MyClass(WiFiManager wiFiManager)
{
wiFiManager.WiFiSignalChanged += new WeakEventHandler<WifiEventArgs>(OnWiFiChanged).Handler;
}
private void OnWiFiChanged(object sender, WifiEventArgs e)
{
// do something
}
}
public class WiFiManager
{
public event EventHandler<WifiEventArgs> WiFiSignalChanged;
// ...
}
public void SomeOperation(WiFiManager wiFiManager)
{
var myClass = new MyClass(wiFiManager);
myClass.DoSomething();
//... myClass is not used again
}
我非常喜欢这种方法,因为发布者(本例中为WiFiManager)与标准 C# 事件保持一致。这只是此模式的一种实现,但实际上有很多方法可以实现它。Daniel Grunwald写了一篇 关于不同实现及其差异的详尽文章。
优点:利用标准事件。简单。无内存泄漏。关注点分离(与事件聚合器不同)。
缺点:此模式的不同实现具有微妙之处和不同的问题。示例中的实现实际上创建了一个注册的包装器对象,该对象永远不会被 GC 收集。其他实现可以解决这个问题,但存在其他问题,例如额外的样板代码。有关此内容的更多信息,请参阅 Daniel 的文章 。
WeakReference 解决方案的问题
使用弱引用意味着GC将能够在可能的情况下收集订阅类。但是,GC 不会立即收集未引用的对象。就开发人员而言,它是随机收集的。因此,对于弱事件,您可能会在当时不应该存在的对象中调用事件处理程序。
事件处理程序可能会做一些无害的事情,例如更新内部状态。或者它可能会更改程序状态,直到 GC 决定在某个随机时间收集它。这种行为确实很危险。有关此内容的附加阅读材料,请参阅《弱事件模式很危险》 。
5. 不使用内存分析器检测内存泄漏
这种技术用于测试现有的内存泄漏,而不是首先编码模式来避免它们。
假设您怀疑某个类存在内存泄漏。如果您遇到这种情况:您创建一个实例,然后希望GC收集它,那么您可以轻松地找出您的实例是否会被收集或是否存在内存泄漏。请按照以下步骤操作:
- 1.向您的可疑类添加一个Finalizer并在里面放置一个断点:

- 添加以下 3 行神奇代码,在场景开始时调用:
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
这将强制 GC 收集迄今为止所有未引用的实例(不在生产中使用),因此它们不会干扰我们的调试。
-
在场景之后添加相同的 3 行神奇代码以运行。请记住,场景是创建可疑对象并应收集的场景。
-
运行相关场景。
在步骤 1 中,我告诉您在类的 Finalizer 中放置一个断点。实际上,您应该在第一次垃圾收集完成后注意该断点。否则,您可能会与被处置的旧实例混淆。需要注意的重要时刻是,在您的场景之后,调试器是否在 Finalizer 中停止。
在类的构造函数中放置一个断点也很有帮助。这样你就可以计算出它被创建的次数和它被终结的次数。如果触发了终结器中的断点,那么 GC 就会收集你的实例,一切正常。如果没有,那么你就有内存泄漏。
下面是我调试使用上一种技术中的 WeakEventHandler 且没有内存泄漏的场景:
finalizedweakEventHandler
这是我使用常规事件注册的另一种情况,它确实存在内存泄漏:
not-finalizedregular-event
概括
我总是惊讶于 C# 似乎是一种容易学习的语言,其环境提供了辅助工具。但事实上,事实并非如此。使用事件这样的简单操作很容易让未经训练的人将应用程序变成一堆内存泄漏。
至于在代码中使用正确的模式,我认为本文的结论应该是,对于所有情况,没有正确和错误的答案。所有提供的技术,以及他们,根据具体情况,采取可行的解决方案。
这篇文章写得比较长,但我对这个问题的理解还是比较深入的。这证明了这些问题的深度,以及软件开发永远很有趣。
有关内存泄漏的更多信息,请查看我的文章《查找、修复和避免 C# .NET 中的内存泄漏:8 个最佳实践》 。它包含大量来自我自己的经验和其他高级 .NET 开发人员为我提供建议的信息。它包括有关内存分析器、非托管代码的内存泄漏、监控内存等的信息。

8248

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



