.NET 內存泄漏的爭議

前幾天發佈了幾篇關於要小心使用 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($"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]. //dwz.date/d48W

[2]. //dwz.date/d48U

附前兩篇文章鏈接:

小心使用 Task.Run 續篇

小心使用 Task.Run 終篇解惑

Tags: