Liam W
封面

内存泄漏的争议

作者
王亮·发表于 3 年前

前几天发布了几篇关于要小心使用 Task.Run 的文章,看了博客园的所有评论。发现有不少人在纠结示例中的现象是不是属于内存泄漏,本文分享一下我个人的看法,大家可以保留自己的意见。

在阅读本文前,如果你对 GC 分代算法还不了解,建议先阅读我的上一篇文章:小心使用 Task.Run 终篇解惑

背景

还是先把前面两篇文章的示例贴出来:

class Program
{
    static void Main(string[] args)
    {
        Test();

        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();

        // 程序保活
        while (true)
        {
            Thread.Sleep(100);
        }
    }

    static void Test()
    {
        var myClass = new MyClass();
        myClass.Foo();
        // 到这,myClass 实例不再需要了
    }
}

public class MyClass
{
    private int _id;

    public Task Foo()
    {
        return Task.Run(() =>
        {
            Console.WriteLine(
quot;Task.Run is executing with ID
{_id}"
); Thread.Sleep(100); // 模拟耗时操作 }); } ~MyClass() { Console.WriteLine("MyClass instance has been colleted."); } }

或许是我表述的问题,更或许是我把原本是一篇的文章折成了两篇发布,造成了一些误解。所以在这里我对后两篇的内容再解释一下。

有的童鞋可能误解了这个示例要演示的是什么。我演示的是,myClass 实例对象不再需要使用时,GC 在其成员被捕获的情况下能否把它回收掉。我特意用 Test() 方法包装了一下 MyClass 实例的创建和调用,当 Test() 方法执行结束时,myClass 对象则变成了不再需要使用的对象。为了保证 GC 强制回收时,myClass 对象的成员是被引用(捕捉)着的,我在 Task.Run 的匿名方法中使用了 Thread.Sleep(100)

如果在 while 循环内不断执行强制回收或者在强制回收前等待足够长的时间,保证 Task.Run 执行完,myClass 对象当然会被回收,因为此时它不存在被不可回收的资源捕获的成员,这点我本以为不需要示例演示大家应该也是这么认为的。如果你了解 GC 的分代算法,你关注的会是,当 myClass 对象变成不再需要使用的资源时,它能否被 GC 在 Gen 0 阶段被回收;而不是关注它最终会不会被回收。

在实际 GC 自动回收的情况下(非手动强制回收),如果第一次扫描到 myClass 发现它被其它对象引用,则会把它标记为 Gen 1,再扫描到它时就会把它标记为 Gen 2。每错过一次回收时机,在内存驻留的时间就越长,它就越难被回收。GC 进行 Root 搜索时,它是否会去搜索某个对象是有统计学基础的。

好了,现在切入正题。问:示例中的现象在 .NET 中是否属于内存泄漏?

正题

我们知道,.NET 应用程序主要使用三种类型的内存:堆栈托管堆非托管堆。绝大多数我们在 .NET 中使用的引用类型都是分配在托管堆上的,例如本文示例中的 myClass 对象。发生在托管堆上的内存泄漏我们可以把它称为托管内存泄漏

关于 .NET 托管堆上的内存泄漏,我直接引用其它两篇文章的现象描述吧(文章地址在文末)。

第一篇[1]描述的一个内存泄漏的现象是:

If the reference is stored in a field reference in the class where the method is declared, it’s not so smart, since it’s impossible to determine whether it will be reused later on, or at least very very hard. If this data structure becomes unnecessary, you should clear the reference you’re holding to it so that GC will pick it up later.

也说是在方法中捕获类成员的现象,和本文示例相符。如果对象不再需要使用了,你应该清除掉它“身上”的引用,以让 GC 在下一次搜索时把它回收掉。

第二篇[2](我的《为什么要小心使用Task.Run》文章就参考了这篇文章)是这样描述的:

There are 2 related core causes for memory leaks. The first core cause is when you have objects that are still referenced but are effectually unused. Since they are referenced, the GC won’t collect them and they will remain forever, taking up memory. This can happen, for example, when you register to events but never unregister. Let’s call this a managed memory leak.

和第一篇的意思差不多,也是说当对象实际上不再使用了,但因为它还被引用,GC 则不会回收它们,这种现象作者把它归为导致内存泄漏的一个主要原因。

第二篇[2]文中还有这么一段:

Many share the opinion that managed memory leaks are not memory leaks at all since they are still referenced and theoretically can be de-allocated. It’s a matter of definition and my point of view is that they are indeed memory leaks. They hold memory that can’t be allocated for another instance and will eventually cause an out-of-memory exception.

翻译如下:

很多人都认为,托管内存泄漏根本不是内存泄漏,因为它们仍然被引用,理论上可以去分配。这是一个定义的问题,我的观点是,它们确实是内存泄漏。它们持有的内存无法分配给另一个实例,最终可能会造成内存溢出异常。

简单概括就是很多人认为托管内存泄漏不属于内存泄漏,这具有争议性,作者认为这是定义问题。

维基上的定义是这样的:

内存泄漏(Memory leak)是在计算机科学中,由于疏忽或错误造成程序未能释放已经不再使用的内存。

这个定义并没有对内存泄漏在时间上设限,请注意“由于疏忽或错误”和“不再使用”这两个重要关键词。”未能释放“是永久还是长时间?并没有明解定义。如果你要说我是在咬文嚼字,嗯,随你吧。

一个 .NET 应用,托管堆中处于 Gen 2 的未回收资源会有很多,其中基本上都是需要使用的。

不需要再使用的资源长时间驻留在内存的托管堆上,它逃过了 Gen 0,逃过了 Gen 1,甚至逃过了 N 次 Gen 2,亦或是仅仅延迟了一点点回收时间,这是否属于内存泄漏,存在很大的争议。我认为这也是定义问题,站在操作系统的视角和托管堆“分代”的视角自然会得到不一样的理解。

就像最近头条上很多人对 1=0.999...(无限循环)这个数学问题的争议一样,有的人认为这个等式是对的,有的人认为它是错的。不同的角度,不同的定义,答案就不一样。

最后,我选择以托管堆的视角来理解,我个人认为,因编码不当导致不再需要使用的资源长时间驻留内存,属于内存泄漏。大家随意,哪种更能帮助你理解你便选择哪种。

文中链接:

[1]. http://dwz.date/d48W
[2]. http://dwz.date/d48U