事件如何在C#中引起内存泄漏,弱引用又如何帮助缓解这种情况?

27

我所知道的在C#中引起非意识性内存泄漏的两种方法:

  1. 未处置实现了IDisposable的资源。
  2. 错误地引用和取消引用事件。

我不太理解第二点。如果源对象的生命周期比监听器长,当没有其他引用时监听器不再需要事件,使用普通的.NET事件会导致内存泄漏:源对象会将监听器对象持有在内存中,而这些对象应该被垃圾回收。

请问如何通过C#代码解释事件如何导致内存泄漏,并编写代码以避免使用弱引用和不使用弱引用?


1
你也可以使用System.Runtime.InteropServices.Marshal.AllocHGlobal并且没有释放它...在.Net中旧的风格泄漏更难但不是不可能的。 - Julien Roncaglia
4
有点晚了,但顺便说一下,不处理IDisposable并不会导致内存泄漏。 IDisposable 存在的原因是为了实现“资源的确定性释放”。换句话说,开发人员可以精确控制资源何时被释放。总的来说,即使是IDisposable对象,最终也会被垃圾回收器处理,但你无法确定具体的时间。 - Jesse C. Slicer
3个回答

37

当监听器将事件监听器附加到事件时,源对象将获得对监听器对象的引用。这意味着,直到事件处理程序被分离或源对象被收集之前,监听器都不能被垃圾收集器收回。

考虑以下类:

class Source
{
    public event EventHandler SomeEvent;
}

class Listener
{
    public Listener(Source source)
    {
        // attach an event listner; this adds a reference to the
        // source_SomeEvent method in this instance to the invocation list
        // of SomeEvent in source
        source.SomeEvent += new EventHandler(source_SomeEvent);
    }

    void source_SomeEvent(object sender, EventArgs e)
    {
        // whatever
    }
}

...然后是以下代码:

Source newSource = new Source();
Listener listener = new Listener(newSource);
listener = null;

即使我们将null分配给listener,由于newSource仍然持有对事件处理程序(Listener.source_SomeEvent)的引用,因此它不会符合垃圾回收条件。为了解决这种泄漏问题,在不再需要它们时,始终分离事件侦听器非常重要。

上述示例是为了集中展示泄漏问题而编写的。为了修复该代码,最简单的方法可能是让Listener保留对Source的引用,以便稍后可以分离事件侦听器:

class Listener
{
    private Source _source;
    public Listener(Source source)
    {
        _source = source;
        // attach an event listner; this adds a reference to the
        // source_SomeEvent method in this instance to the invocation list
        // of SomeEvent in source
        _source.SomeEvent += source_SomeEvent;
    }

    void source_SomeEvent(object sender, EventArgs e)
    {
        // whatever
    }

    public void Close()
    {
        if (_source != null)
        {
            // detach event handler
            _source.SomeEvent -= source_SomeEvent;
            _source = null;
        }
    }
}

然后调用代码可以使用该对象发出完成信号,这将删除Source对`Listener`的引用;

Source newSource = new Source();
Listener listener = new Listener(newSource);
// use listener
listener.Close();
listener = null;

6
@pst: 没有循环引用;请注意 Listener 没有持有对 Source 的引用。Listener 之所以能够保持存活是因为 Source 持有对 Listener 的引用。只要有任何东西持有对 Source 的引用,Listener 就无法被回收,但是 Listener 并不会阻止 Source 被回收。 - Fredrik Mörk
2
你应该编辑你的帖子,告诉人们如何取消事件处理程序以及在哪里这样做。 - George Stocker
2
@pst,这不是循环引用的问题,而是对象生命周期的问题。这个例子并不是很好,但它似乎试图表明事件源超出了侦听器的生命周期,在这种情况下,只有当事件源不再具有引用或侦听器显式地从事件源中删除其事件处理程序时,侦听器才不会被收集。典型的Windows窗体将监听其子控件的事件,在这种情况下,事件源的寿命较短,因此在这种典型的WinForms场景中没有内存泄漏,其他场景存在并经常造成问题。 - Chris Taylor
2
@jnvjgt @Avatar 不会。在此处将null分配给监听器实际上并没有做任何事情,除了告诉垃圾收集器该项已准备好被收集;一旦它离开块,垃圾收集器就会这样做。很少需要将对象设置为null以使其在离开块后被收集,但有些程序员将其放在那里以明确说明他们将对象设置为null。 - George Stocker
在第二段代码中存在循环引用。ref 保留在 private Source _source; 中,对吧?@GeorgeStocker?还是他在谈论第一段代码? - Royi Namir
显示剩余5条评论

13

阅读Jon Skeet关于事件的优秀文章。 这不是经典意义上的“内存泄漏”,而更像是未被断开的保留引用。 因此,请始终记住,要使用-=取消之前使用+=添加的事件处理程序,您就应该没问题了。


2
严格来说,在托管的.NET项目的“沙盒”中不存在“内存泄漏”,只有引用被保持的时间比开发人员认为的必要时间长。Fredrik是正确的;当你将处理程序附加到事件时,由于处理程序通常是实例方法(需要实例),包含监听器的类的实例会一直保留在内存中,只要维护这个引用。如果监听器实例反过来包含对其他类的引用(例如,对包含对象的反向引用),则堆可以在监听器已经超出所有其他范围之后仍然很大。
也许有些对Delegate和MulticastDelegate有更深奥知识的人可以解释一下。我认为,如果以下所有条件都成立,真正的泄漏可能是可能的:
- 事件侦听器需要通过实现IDisposable来释放外部/非托管资源,但它不这样做; - 事件多路广播委托不从其重写的Finalize()方法调用Dispose()方法; - 包含事件的类没有在其自己的IDisposable实现或Finalize()中对委托的每个Target调用Dispose()。
我从未听说过任何关于在委托目标上调用Dispose(),更不用说事件侦听器了,所以我只能假设.NET开发人员在这种情况下知道他们在做什么。如果这是真的,并且事件背后的MulticastDelegate尝试正确地处理侦听器,那么唯一必要的就是在需要处理的监听类上正确实现IDisposable。

2
我的内存泄漏定义表明,如果存在一系列重复的输入,将导致程序在通过有限数量的可观察状态时需要无限量的内存,则该程序通常具有内存泄漏。按照这样的定义,即使在完全托管的 .net 程序中,事件也可以导致内存泄漏。事件侦听器应实现 IDisposable,并应在 Dispose 中取消订阅其事件,但 Microsoft 并不容易始终如一地做到这一点,不幸的是粗心大意通常不会引起麻烦。 - supercat
MulticastDelegate 本身不会对 IDisposable 进行任何操作。调用 Delegate.Remove(通常是取消事件订阅的方法)只会创建一个不包含已删除项的新的 MulticastDelegate。针对事件,终结器通常是无用的,因为只要发布者在作用域内,事件将保持其所有订阅者的作用域。到订阅者可能被终结时,发布者将超出作用域并且事件将变得无关紧要。 - supercat

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