原文連接: https://michaelscodingspot.com/ways-to-cause-memory-leaks-in-dotnet/
作者 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?
- 正在運行的線程的實時堆棧。
- 靜態變量。
- 通過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》,以獲取有關此內容的更多信息。
希望你喜歡這篇文章,並祝你編程愉快。