可能會導致.NET記憶體洩露的8種行為

陳嘉棟發表於2020-05-15
原文連線:
作者 Michael Shpilt。授權翻譯,轉載請保留原文連結。

任何有經驗的.NET開發人員都知道,即使.NET應用程式具有垃圾回收器,記憶體洩漏始終會發生。 並不是說垃圾回收器有bug,而是我們有多種方法可以(輕鬆地)導致託管語言的記憶體洩漏。

記憶體洩漏是一個偷偷摸摸的壞傢伙。 很長時間以來,它們很容易被忽視,而它們也會慢慢破壞應用程式。 隨著記憶體洩漏,你的記憶體消耗會增加,從而導致GC壓力和效能問題。 最終,程式將在發生記憶體不足異常時崩潰。

在本文中,我們將介紹.NET程式中記憶體洩漏的最常見原因。 所有示例均使用C#,但它們與其他語言也相關。

定義.NET中的記憶體洩漏

在垃圾回收的環境中,“記憶體洩漏”這個術語有點違反直覺。 當有一個垃圾回收器(GC)負責收集所有東西時,我的記憶體怎麼會洩漏呢?

這裡有兩個核心原因。 第一個核心原因是你的物件仍被引用但實際上卻未被使用。 由於它們被引用,因此GC將不會收集它們,這樣它們將永久儲存並佔用記憶體。 例如,當你註冊了事件但從不登出時,就有可能會發生這種情況。 我們稱其為託管記憶體洩漏。

第二個原因是當你以某種方式分配非託管記憶體(沒有垃圾回收)並且不釋放它們。 這並不難做到。 .NET本身有很多會分配非託管記憶體的類。 幾乎所有涉及流、圖形、檔案系統或網路呼叫的操作都會在背後分配這些非託管記憶體。 通常這些類會實現 Dispose 方法,以釋放記憶體。 你自己也可以使用特殊的.NET類(如Marshal)或PInvoke輕鬆地分配非託管記憶體。

許多人都認為託管記憶體洩漏根本不是記憶體洩漏,因為它們仍然被引用,並且理論上可以被回收。 這是一個定義問題,我的觀點是它們確實是記憶體洩漏。 它們擁有無法分配給另一個例項的記憶體,最終將導致記憶體不足的異常。 對於本文,我會將託管記憶體洩漏和非託管記憶體洩漏都歸為記憶體洩漏。

以下是最常見的8種記憶體洩露的情況。 前6個是託管記憶體洩漏,後2個是非託管記憶體洩漏:

1.訂閱Events

.NET中的Events因導致記憶體洩漏而臭名昭著。 原因很簡單:訂閱事件後,該物件將保留對你的類的引用。 除非你使用不捕獲類成員的匿名方法。 考慮以下示例:

public class MyClass
{
    public MyClass(WiFiManager wiFiManager)
    {
        wiFiManager.WiFiSignalChanged += OnWiFiChanged;
    }
 
    private void OnWiFiChanged(object sender, WifiEventArgs e)
    {
        // do something
    }
}

假設wifiManager的壽命超過MyClass,那麼你就已經造成了記憶體洩漏。 wifiManager會引用MyClass的任何例項,並且垃圾回收器永遠不會回收它們。

Event確實很危險,我寫了整整一篇關於這個話題的文章,名為《5 Techniques to avoid Memory Leaks by Events in C# .NET you should know.》

所以,你可以做什麼呢? 在提到的這篇文章中,有幾種很好的模式可以防止和Event有關的記憶體洩漏。 無需詳細說明,其中一些是:

  • 登出訂閱事件。
  • 使用弱控制程式碼(weak-handler)模式。
  • 如果可能,請使用匿名函式進行訂閱,並且不要捕獲任何類成員。

2.在匿名方法中捕獲類成員

雖然可以很明顯地看出事件機制需要引用一個物件,但是引用物件這個事情在匿名方法中捕獲類成員時卻不明顯了。

這裡是一個例子:

public class MyClass
{
    private JobQueue _jobQueue;
    private int _id;
 
    public MyClass(JobQueue jobQueue)
    {
        _jobQueue = jobQueue;
    }
 
    public void Foo()
    {
        _jobQueue.EnqueueJob(() =>
        {
            Logger.Log($"Executing job with ID {_id}");
            // do stuff 
        });
    }
}

在程式碼中,類成員_id是在匿名方法中被捕獲的,因此該例項也會被引用。 這意味著,儘管JobQueue存在並已經引用了job委託,但它還將引用一個MyClass的例項。

解決方案可能非常簡單——分配區域性變數:

public class MyClass
{
    public MyClass(JobQueue jobQueue)
    {
        _jobQueue = jobQueue;
    }
    private JobQueue _jobQueue;
    private int _id;
 
    public void Foo()
    {
        var localId = _id;
        _jobQueue.EnqueueJob(() =>
        {
            Logger.Log($"Executing job with ID {localId}");
            // do stuff 
        });
    }
}

通過將值分配給區域性變數,不會有任何內容被捕獲,並且避免了潛在的記憶體洩漏。

3.靜態變數

我知道有些開發人員認為使用靜態變數始終是一種不好的做法。 儘管有些極端,但在談論記憶體洩漏時的確需要注意它。

讓我們考慮一下垃圾收集器的工作原理。 基本思想是GC遍歷所有GC Root物件並將其標記為“不可收集”。 然後,GC轉到它們引用的所有物件,並將它們也標記為“不可收集”。 最後,GC收集剩下的所有內容。

那麼什麼會被認為是一個GC Root?

  1. 正在執行的執行緒的實時堆疊。
  2. 靜態變數。
  3. 通過interop傳遞到COM物件的託管物件(記憶體回收將通過引用計數來完成)。

這意味著靜態變數及其引用的所有內容都不會被垃圾回收。 這裡是一個例子:

public class MyClass
{
    static List<MyClass> _instances = new List<MyClass>();
    public MyClass()
    {
        _instances.Add(this);
    }
}

如果你出於某種原因而決定編寫上述程式碼,那麼任何MyClass的例項將永遠留在記憶體中,從而導致記憶體洩漏。

4.快取功能

開發人員喜歡快取。 如果一個操作能只做一次並且將其結果儲存,那麼為什麼還要做兩次呢?

的確如此,但是如果無限期地快取,最終將耗盡記憶體。 考慮以下示例:

public class ProfilePicExtractor
{
    private Dictionary<int, byte[]> PictureCache { get; set; } = 
      new Dictionary<int, byte[]>();
 
    public byte[] GetProfilePicByID(int id)
    {
        // A lock mechanism should be added here, but let's stay on point
        if (!PictureCache.ContainsKey(id))
        {
            var picture = GetPictureFromDatabase(id);
            PictureCache[id] = picture;
        }
        return PictureCache[id];
    }
 
    private byte[] GetPictureFromDatabase(int id)
    {
        // ...
    }
}

這段程式碼可能會節省一些昂貴的資料庫訪問時間,但是代價卻是使你的記憶體混亂。

你可以做一些事情來解決這個問題:

  • 刪除一段時間未使用的快取。
  • 限制快取大小。
  • 使用WeakReference來儲存快取的物件。 這依賴於垃圾收集器來決定何時清除快取,但這可能不是一個壞主意。 GC會將仍在使用的物件推廣到更高的世代,以使它們的儲存時間更長。 這意味著經常使用的物件將在快取中停留更長時間。

5.錯誤的WPF繫結

WPF繫結實際上可能會導致記憶體洩漏。 經驗法則是始終繫結到DependencyObject或INotifyPropertyChanged物件。 如果你不這樣做,WPF將建立從靜態變數到繫結源(即ViewModel)的強引用,從而導致記憶體洩漏。

這裡是一個例子:

<UserControl x:Class="WpfApp.MyControl"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
    <TextBlock Text="{Binding SomeText}"></TextBlock>
</UserControl>

這個View Model將永遠留在記憶體中:

public class MyViewModel
{
    public string _someText = "memory leak";
    public string SomeText
    {
        get { return _someText; }
        set
        {
            _someText = value;
        }
    }
}

而這個View Model不會導致記憶體洩漏:

public class MyViewModel : INotifyPropertyChanged
{
    public string _someText = "not a memory leak";
 
    public string SomeText
    {
        get { return _someText; }
        set
        {
            _someText = value;
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof (SomeText)));
        }
    }

是否呼叫PropertyChanged實際上並不重要,重要的是該類是從INotifyPropertyChanged派生的。 因為這會告訴WPF不要建立強引用。

另一個和WPF有關的記憶體洩漏問題會發生在繫結到集合時。 如果該集合未實現INotifyCollectionChanged介面,則會發生記憶體洩漏。 你可以通過使用實現該介面的ObservableCollection來避免此問題。

6.永不終止的執行緒

我們已經討論過了GC的工作方式以及GC root。 我提到過實時堆疊會被視為GC root。 實時堆疊包括正在執行的執行緒中的所有區域性變數和呼叫堆疊的成員。

如果出於某種原因,你要建立一個永遠執行的不執行任何操作並且具有對物件引用的執行緒,那麼這將會導致記憶體洩漏。

這種情況很容易發生的一個例子是使用Timer。考慮以下程式碼:

public class MyClass
{
    public MyClass()
    {
        Timer timer = new Timer(HandleTick);
        timer.Change(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
    }
 
    private void HandleTick(object state)
    {
        // do something
    }

如果你並沒有真正的停止這個timer,那麼它會在一個單獨的執行緒中執行,並且由於引用了一個MyClass的例項,因此會阻止該例項被收集。

7.沒有回收非託管記憶體

到目前為止,我們僅僅談論了託管記憶體,也就是由垃圾收集器管理的記憶體。 非託管記憶體是完全不同的問題,你將需要顯式地回收記憶體,而不僅僅是避免不必要的引用。

這裡有一個簡單的例子。

public class SomeClass
{
    private IntPtr _buffer;
 
    public SomeClass()
    {
        _buffer = Marshal.AllocHGlobal(1000);
    }
 
    // do stuff without freeing the buffer memory
 
}

在上述方法中,我們使用了Marshal.AllocHGlobal方法,它分配了非託管記憶體緩衝區。 在這背後,AllocHGlobal會呼叫Kernel32.dll中的LocalAlloc函式。 如果沒有使用Marshal.FreeHGlobal顯式地釋放控制程式碼,則該緩衝區記憶體將被視為佔用了程式的記憶體堆,從而導致記憶體洩漏。

要解決此類問題,你可以新增一個Dispose方法,以釋放所有非託管資源,如下所示:

public class SomeClass : IDisposable
{
    private IntPtr _buffer;
 
    public SomeClass()
    {
        _buffer = Marshal.AllocHGlobal(1000);
        // do stuff without freeing the buffer memory
    }
 
    public void Dispose()
    {
        Marshal.FreeHGlobal(_buffer);
    }
}
由於記憶體碎片問題,非託管記憶體洩漏比託管記憶體洩漏更嚴重。 垃圾回收器可以移動託管記憶體,從而為其他物件騰出空間。 但是,非託管記憶體將永遠卡在它的位置。

 

8.新增了Dispose方法卻不呼叫它

在最後一個示例中,我們新增了Dispose方法以釋放所有非託管資源。 這很棒,但是當有人使用了該類卻沒有呼叫Dispose時會發生什麼呢?

為了避免這種情況,你可以在C#中使用using語句:

using (var instance = new MyClass())
{
    // ... 
}

這適用於實現了IDisposable介面的類,並且編譯器會將其轉化為下面的形式:

MyClass instance = new MyClass();;
try
{
    // ...
}
finally
{
    if (instance != null)
        ((IDisposable)instance).Dispose();
}

這非常有用,因為即使丟擲異常,也會呼叫Dispose。

你可以做的另一件事是利用Dispose Pattern。 下面的示例演示了這種情況:

public class MyClass : IDisposable
{
    private IntPtr _bufferPtr;
    public int BUFFER_SIZE = 1024 * 1024; // 1 MB
    private bool _disposed = false;
 
    public MyClass()
    {
        _bufferPtr =  Marshal.AllocHGlobal(BUFFER_SIZE);
    }
 
    protected virtual void Dispose(bool disposing)
    {
        if (_disposed)
            return;
 
        if (disposing)
        {
            // Free any other managed objects here.
        }
 
        // Free any unmanaged objects here.
        Marshal.FreeHGlobal(_bufferPtr);
        _disposed = true;
    }
 
    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }
 
    ~MyClass()
    {
        Dispose(false);
    }
}

這種模式可確保即使沒有呼叫Dispose,Dispose也將在例項被垃圾回收時被呼叫。 另一方面,如果呼叫了Dispose,則finalizer將被抑制(SuppressFinalize)。 抑制finalizer很重要,因為finalizer開銷很大並且會導致效能問題。

然而,dispose-pattern不是萬無一失的。 如果從未呼叫Dispose並且由於託管記憶體洩漏而導致你的類沒有被垃圾回收,那麼非託管資源也將不會被釋放。

總結

知道記憶體洩漏是如何發生的很重要,但只有這些還不夠。 同樣重要的是要認識到現有應用程式中存在記憶體洩漏問題,找到並修復它們。 你可以閱讀我的文章《Find, Fix, and Avoid Memory Leaks in C# .NET: 8 Best Practices》,以獲取有關此內容的更多資訊。

希望你喜歡這篇文章,並祝你程式設計愉快。

 

https://zhuanlan.zhihu.com/p/141032986

相關文章