为什么以及如何避免事件处理程序内存泄漏?

通过阅读StackOverflow上的一些问题和答案,我才意识到在C#(或者我猜是其他.NET语言)中使用+=来添加事件处理程序可能会导致常见的内存泄漏。

我过去曾多次使用这样的事件处理程序,但从未意识到它们会导致或已经导致我的应用程序中的内存泄漏。

是如何工作的(意思是,为什么这实际上会导致内存泄漏)?
如何解决此问题?对同一事件处理程序使用-=是否足够?
有没有常见的设计模式或最佳实践来处理这种情况?
示例:我应该如何处理具有许多不同线程的应用程序,使用许多不同的事件处理程序在UI上引发多个事件?

在已经构建的大型应用程序中,有没有好的、简单的方法来有效地监视这一点?

72029 次浏览

原因很容易解释:当订阅事件处理程序时,事件的出版商通过事件处理程序委托(假设委托是实例方法)保存对订阅者的引用。

如果发布服务器的生存期比订阅服务器的生存期长,则即使没有对订阅服务器的其他引用,它也会使订阅服务器保持活动状态。

如果您取消订阅具有相等处理程序的事件,那么是的,这将删除处理程序和可能的泄漏。然而,根据我的经验,这实际上很少是一个问题-因为通常我发现发布者和订阅者的生命周期大致相等。

IT可能的原因..但根据我的经验,它被过度炒作了。当然,你的里程可能会有所不同。你只需要小心。

是的,-=就足够了,但是,可能很难跟踪分配的每个事件。(有关详细信息,请参阅乔恩的帖子)。关于设计模式,请参阅弱事件模式

事件实际上是事件处理程序的链接列表。

当您在事件上执行+=new EventHandler时,如果此特定函数之前已作为侦听器添加,则并不重要,它将每+=添加一次。

当事件被引发时,它会逐项遍历链表,并调用添加到此列表中的所有方法(事件处理程序),这就是为什么即使页面不再运行,只要它们是活动的(根),事件处理程序仍然会被调用,并且只要它们被连接起来,它们就会是活动的。因此,它们将被调用,直到EventHandler与-=new EventHandler脱钩。

看这里

以及这里是MSDN

我已经在https://www.spicelogic.com/Blog/net-event-handler-memory-leak-16上的一篇博客中解释了这种困惑。我在这里试着总结一下,让大家心中有数。

参考手段,";需要";:

首先,您需要了解,如果对象A持有对对象B的引用,那么,这将意味着,对象A需要对象B才能运行,对吗?因此,只要对象A在内存中处于活动状态,垃圾收集器就不会收集对象B.

+=表示,将右侧对象的引用注入到左侧对象:

混乱来自于C#+=操作符。该操作符没有明确告诉开发人员,该操作符的右侧实际上是注入对左侧对象的引用。

enter image description here

通过这样做,对象A认为,它需要对象B,即使从你的角度来看,对象A不应该关心对象B是否活着。当对象A认为需要对象B时,只要对象A处于活动状态,对象A就会保护对象B免受垃圾收集器的攻击。但是,如果你不想要这种保护提供给事件订阅者对象,那么,您可以说,发生了内存泄漏。为了强调这句话,让我澄清一下,在.NET世界中,不存在像典型的C++非托管程序那样的内存泄漏概念。但是,正如我所说的,对象A保护对象B免受垃圾收集,如果这不是您的意图,那么您可以说发生了内存泄漏,因为对象B不应该存在于内存中。

enter image description here

您可以通过分离事件处理程序来避免此类泄漏。

如何做出决定?

整个代码库中有大量的事件和事件处理程序。这是否意味着,您需要在任何地方都分离事件处理程序?答案是否定的。如果你不得不这样做,你的代码库将会因为冗长而变得非常难看。

您可以按照一个简单的流程图来确定是否需要分离事件处理程序。

enter image description here

大多数情况下,您可能会发现事件订阅者对象与事件发布者对象一样重要,并且两者应该同时存在。

无需担心的场景示例

例如,窗口的按钮单击事件。

enter image description here

在这里,事件发布者是按钮,事件订阅者是主窗口。应用该流程图,问一个问题,主窗口(事件订阅者)是否应该在按钮(事件发布者)之前关闭?显然不是。对的?这根本说不通。那么,为什么要担心分离Click事件处理程序呢?

必须使用事件处理程序分离时的示例。

我将提供一个示例,其中假设Subscriber对象在Publisher对象之前是死的。例如,您的主窗口发布了一个名为“ Somethinghappened ”的事件并通过单击按钮显示主窗口的子窗口。子窗口订阅主窗口的该事件。

enter image description here

并且,子窗口订阅主窗口的事件。

enter image description here

从这段代码中,我们可以清楚地了解到主窗口中有一个按钮。单击该按钮将显示一个子窗口。子窗口侦听来自主窗口的事件。在执行某些操作之后,用户关闭子窗口。

现在,根据我提供的流程图,如果你问一个问题";子窗口(事件订阅者)是否应该在事件发布者(主窗口)之前死亡?答案应该是肯定的。对的?因此,分离事件处理程序。我通常从窗口的卸载事件中执行此操作。

经验法则:如果您的视图(即WPF、WinForm、UWP、Xamarin Form等)订阅ViewModel的事件,请始终记住分离事件处理程序。因为ViewModel通常比View存在的时间更长。因此,如果不销毁ViewModel,则该ViewModel的订阅事件的任何视图都将保留在内存中,这是不好的。

使用内存分析器验证概念。

如果我们不能用内存分析器来验证这个概念,那就不会很有趣了。我在这个实验中使用了JetBrain DotMemory Profiler.

首先,我运行了主窗口,它显示如下:

enter image description here

然后,我拍了一张记忆快照。然后,我单击按钮3次。出现了三个子窗口。我关闭了所有这些子窗口,并单击DotMemory Profiler中的Force GC按钮,以确保调用垃圾收集器。然后,我拍摄了另一张内存快照并进行了比较。看哪!我们的恐惧是真的。垃圾回收器未收集子窗口,即使它们已关闭。不仅如此,ChildWindow对象的泄漏对象计数也显示为“3 ”。(我单击按钮3次以显示3个子窗口)。

enter image description here

好的,然后,我分离了事件处理程序,如下所示。

enter image description here

然后,我执行了相同的步骤,并检查了内存分析器。这一次,哇!不再有内存泄漏。

enter image description here

我可以告诉你,这可能会成为Blazor的一个问题。您可以让一个组件使用+=语法订阅事件,从长远来看,这将导致泄漏。

唯一的解决方案(据我所知)是不使用匿名方法,让组件从IDisposable继承,并使用Dispose()来取消订阅事件处理程序。