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

201

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

我以前经常使用这样的事件处理程序,却从未意识到它们可能导致我的应用程序出现内存泄漏。

这是怎么回事(也就是说,为什么这实际上会导致内存泄漏)?
如何解决这个问题?只使用-=来移除相同的事件处理程序就足够了吗?
有没有常见的设计模式或最佳实践可以处理这种情况?
例如:我应该如何处理一个具有许多不同线程、使用许多不同事件处理程序在UI上引发多个事件的应用程序?

在已构建的大型应用程序中,有没有好的简单方法有效地监视这个问题?

5个回答

236
原因很简单:当事件处理程序被订阅时,事件的发布者通过事件处理程序委托(假设该委托是实例方法)持有对订阅者的引用。
如果发布者比订阅者存活时间更长,那么即使没有对订阅者的其他引用,它也会保持订阅者的生命周期。
如果您使用相等的处理程序取消订阅事件,那么是的,它将删除处理程序和可能存在的泄漏。然而,根据我的经验,这实际上很少是一个问题-因为通常我发现发布者和订阅者的寿命大致相等。
这确实是可能的原因...但在我的经验中,它被夸大了。当然,您需要小心。

41
出版商可以采取的方法是,一旦确定不再触发某个事件,就将该事件设置为null。这将隐式地移除所有订阅者,并且在对象生命周期的某些阶段仅触发特定事件时,这种方法非常有用。 - JSBձոգչ
3
处理方法将是设置事件为null的好时机。 - Davi Fiamenghi
7
如果某个对象正在被处理,那至少说明它很可能会很快满足垃圾回收的条件,到那时候订阅者就不重要了。 - Jon Skeet
2
如果事件方法是静态的呢?那么就没有实例可以继续持有,对吧?--看起来有一个相当简单的解决方法(而且典型的事件模式无论如何都包括一个发送者...)。 - BrainSlugs83
1
@BrainSlugs83:“典型的事件模式包括发送者” - 是的,但那是事件生产者。通常事件订阅者实例是相关的,而发送者并不是。所以,如果您可以使用静态方法进行订阅,这就不是问题 - 但根据我的经验,这很少是一个选项。 - Jon Skeet
显示剩余12条评论

88
我已经在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存活,就会保护对象B免受垃圾收集器的影响。但是,如果您不希望给事件订阅对象提供此保护,则可以说发生了内存泄漏。为了强调这一声明,在.NET世界中,没有像典型的C++非托管程序那样的内存泄漏概念。但是,正如我所说,对象A保护对象B免受垃圾回收的影响,如果这不是您的意图,则可以说发生了内存泄漏,因为对象B不应该存活在内存中。

enter image description here

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

如何做出决定?

您的整个代码库中有许多事件和事件处理程序。这是否意味着您需要在所有地方保持分离事件处理程序?答案是否定的。如果您必须这样做,那么您的代码库将变得非常丑陋和冗长。

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

enter image description here

大多数情况下,您可能会发现事件订阅者对象与事件发布者对象一样重要,并且两者应该同时存在。 不需要担心的场景示例 例如,窗口的按钮单击事件。

enter image description here

在这里,事件发布者是按钮(Button),事件订阅者是MainWindow。应用该流程图,提出一个问题,Main Window(事件订阅者)是否应该在Button(事件发布者)之前死亡?显然不是。对吧?那甚至没有意义。那么,为什么要担心分离单击事件处理程序呢?
一个必须分离事件处理程序的例子。
我将提供一个示例,其中订阅对象在发布对象之前应该死亡。假设您的MainWindow通过单击按钮显示一个子窗口,并发布名为"SomethingHappened"的事件。子窗口订阅了主窗口的该事件。

enter image description here

而且,子窗口订阅了主窗口的一个事件。

enter image description here

从这段代码中,我们可以清楚地了解到主窗口中有一个按钮。点击该按钮会显示一个子窗口。子窗口监听来自主窗口的事件。在做完某些事情后,用户关闭子窗口。
现在,根据我提供的流程图,如果你问一个问题:“事件订阅者(子窗口)是否应该在事件发布者(主窗口)之前死亡?”答案应该是是。所以,要分离事件处理程序。我通常会在窗口的Unloaded事件中这样做。
一个经验法则:如果你的视图(例如WPF、WinForm、UWP、Xamarin Form等)订阅了ViewModel的事件,一定要记得分离事件处理程序。因为ViewModel通常比视图存在时间更长。所以,如果ViewModel没有被销毁,任何订阅该ViewModel事件的视图都将留在内存中,这不好。
证明概念的方法是使用内存分析器。
如果我们不能用内存分析器验证概念,那就不会有太多乐趣。在这个实验中,我使用了JetBrain dotMemory profiler。
首先,我运行了MainWindow,它显示如下:

enter image description here

然后,我拍了一张内存快照。接着我点击了按钮3次。三个子窗口出现了。我关闭了所有这些子窗口,并在dotMemory分析器中点击了Force GC按钮以确保垃圾回收器被调用。然后,我再次拍了一张内存快照并进行了比较。看哪!我们的担心成真了。即使这些子窗口已经关闭,Child Window仍未被垃圾回收器回收。不仅如此,ChildWindow对象的泄漏对象计数也显示为"3"(我点击按钮3次以显示3个子窗口)。

enter image description here

好的,那么我将如下所示取消绑定事件处理程序。

enter image description here

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

enter image description here


10
漂亮的插图。 - ahdung

14

是的,-=足够了,但是要跟踪每个分配的事件可能会很困难(有关详细信息,请参见Jon的帖子)。关于设计模式,请查看弱事件模式


1
4.0版本仍然具备此功能。 - Femaref
如果我知道发布者的生命周期比订阅者长,我会将订阅者设置为IDisposable并取消事件订阅。 - Shimmy Weitzhandler
弱事件处理程序非常出色,除非你有成千上万个它们。这是因为它们占用的内存比正常的事件处理程序多得多。只有在数量较少时才会使用它们。 - rollsch

3

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

当您在事件上执行 += new EventHandler 时,不管此特定函数以前是否作为侦听器添加过,它都将每个+=添加一次。

当事件被触发时,它会逐个遍历链接列表,并调用添加到此列表中的所有方法(事件处理程序)。这就是为什么即使页面不再运行,只要它们还活着(已连接),事件处理程序仍然会被调用,它们将一直存活,直到使用 -= new EventHandler 取消注册。

请参阅此处

MSDN 此处


另请参阅:http://blogs.msdn.com/b/tess/archive/2006/01/23/net-memory-leak-case-study-the-event-handlers-that-made-the-memory-baloon.aspx - Cody Gray

0

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

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


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接