眾所周知,記憶體管理和如何避免記憶體泄漏(memory leak)一直是軟體開發的難題,不要說C、C++等非托管(unmanaged)語言,即使是Java、.NET等托管(managed)語言,盡管有著完善的垃圾回收器(GC),記憶體泄漏也經常發生,不過,這并非GC的bug或設計缺陷,而是因為在開發時有太多能夠導致記憶體泄漏的方式了,尤其是對于系結(Binding)、事件(Event)、行為(Behavior)滿天飛的WPF/UWP應用,
對于托管類應用,記憶體泄漏主要可以分為兩大類:托管類記憶體泄漏(managed memory leak)和非托管類記憶體泄漏(unmanaged memory leak),
1.托管類記憶體泄漏(managed memory leak)
這種泄漏發生的根本原因是由于無用的、本該被回收的托管類物件(managed objects)由于被“無意的”(unintended)的參考而導致無法被回收,
這與GC的作業原理有關:在進行垃圾回收時,應用將掛起所有執行緒,這樣GC就可以遍歷所有的GC Root物件,并將它們標記為”不可回收“,接著GC進一步將它們所參考的所有物件也都標記為”不可回收“,這個程序將一直遞回進行下去,直到無法繼續,所有未被標記為”不可回收“的物件都被GC視為垃圾物件,最終都將被回收,簡而言之,GC對“無用”物件的識別機制很簡單:判斷物件是否被“GC Root”物件所參考,
可以被視為GC Root的物件主要包括三大類:
a.正在執行的執行緒的“活躍”堆疊(Live Stack of the running threads),包括正在執行的方法的引數、區域變數、暫存器變數等;
b.靜態變數(Static variables);
c.通過interop傳遞給COM物件(其記憶體回收采用“參考計數”機制)的托管物件
如果一個物件被生存期更長的物件(例如全域物件或靜態類)所參考,那么在進行GC時,即使它已經不會再被用到,也會被標記為”不可回收“,這就是記憶體泄漏 ,當然,被沒有被標記為”不可回收“的物件(“垃圾”物件)所參考是不會阻止被參考物件被回收的,這種情況就不算是記憶體泄漏,
通常,導致無用的物件被GC Root無意參考的常見場景有以下幾種:(注意這里只討論”無意“的參考,開發者通過靜態變數或生存期超長的物件建立的”有意“的參考不在討論之列)
1)Event訂閱
普通的事件訂閱(event subscribing),例如代碼source.SomeEvent += new SomeEventHandler(someObject.MyEventHandler),將創建一個從source到someObject的強參考(strong reference),如果source物件的生存期比someObject的長,那么將產生記憶體泄漏,
如一個在WPF/UWP中非常常見的場景:
public partial class UserControl1 : UserControl { public UserControl1() { InitializeComponent(); Application.Current.MainWindow.SizeChanged += this.MainWindow_SizeChanged; } private void MainWindow_SizeChanged(object sender, SizeChangedEventArgs e) { Debug.WriteLine($"主表單size改變:{e.NewSize}"); } }
UserControl1訂閱了MainWindow的SizeChanged事件,那么MainWindow將保持一個對UserControl1的參考,如果MainWindow的生存期比UserControl1長,那么將產生記憶體泄漏,
解決這類記憶體泄漏的方法有:
a)手動取消訂閱(unsubscribe)
可以將上述代碼修改如下:
public partial class UserControl1 : UserControl { public UserControl1() { InitializeComponent(); this.Loaded += this.UserControl1_Loaded; this.Unloaded += this.UserControl1_Unloaded; } private void UserControl1_Unloaded(object sender, RoutedEventArgs e) { Application.Current.MainWindow.SizeChanged -= this.MainWindow_SizeChanged; } private void UserControl1_Loaded(object sender, RoutedEventArgs e) { Application.Current.MainWindow.SizeChanged += this.MainWindow_SizeChanged; } private void MainWindow_SizeChanged(object sender, SizeChangedEventArgs e) { Debug.WriteLine($"主表單size改變:{e.NewSize}"); } }
由于Framework的Loaded和Unloaded事件都是成對出現,因此,可以保證當UserControl1被從Visual Tree卸載時,對MainWindow的SizeChanged被及時訂閱,從而解除MainWindow對UserControl1的參考,避免記憶體泄漏,
b)弱事件模式(Weak Event Pattern):使用WeakReference或 WeakEventManager,或第三方的庫(如Prism的EventAggregator),這里只舉一個WeakReference的例子,關于后者可以參考MSDN檔案:https://docs.microsoft.com/en-us/dotnet/desktop/wpf/advanced/weak-event-patterns
還是上面的例子,用WeakReference改寫后代碼如下:
public class WeakEventHandler<TEventArgs> where TEventArgs : SizeChangedEventArgs { public WeakReference Reference { get; } public MethodInfo Method { get; } public WeakEventHandler(SizeChangedEventHandler eventHandler) { this.Handler = eventHandler; this.Reference = new WeakReference(eventHandler.Target); Method = eventHandler.Method; } public SizeChangedEventHandler Handler { get; } public void Invoke(object sender, TEventArgs e) { object target = Reference.Target; if (null != target) { Method.Invoke(target, new object[] { sender, e }); } } public static implicit operator SizeChangedEventHandler(WeakEventHandler<TEventArgs> weakHandler) { return weakHandler.Handler; } } /// <summary> /// Interaction logic for UserControl1.xaml /// </summary> public partial class UserControl1 : UserControl { public UserControl1() { InitializeComponent(); Application.Current.MainWindow.SizeChanged += new WeakEventHandler<SizeChangedEventArgs>(this.MainWindow_SizeChanged); } private void MainWindow_SizeChanged(object sender, SizeChangedEventArgs e) { Debug.WriteLine($"主表單size改變:{e.NewSize}"); } }
c)盡可能利用匿名函式(anonymous method)并避免“捕獲”物件的任何成員(member),如上面的例子可以改為:
public partial class UserControl1 : UserControl { public UserControl1() { InitializeComponent(); Application.Current.MainWindow.SizeChanged += (s, e) => { Debug.WriteLine($"主表單size改變:{e.NewSize}"); }; } }
2)在匿名函式中捕獲物件的成員(member)
上面提到將event hander換成匿名函式并避免捕獲物件的成員可以避免記憶體泄漏,換句話說,如果匿名函式捕獲了物件的成員,就可能導致記憶體泄漏,如上面匿名函式的例子換成下面的:
public partial class UserControl1 : UserControl { public UserControl1() { InitializeComponent(); Application.Current.MainWindow.SizeChanged += (s, e) => { Debug.WriteLine($"{e.NewSize.Width - this.Width}"); }; } }
這里,由于UserControl1的成員Width被匿名函式捕獲,結果導致整個UserControl1的實體也被MainWindow所參考,從而產生記憶體泄漏,
這類泄漏的解決辦法可能很簡單——使用區域變數代替物件的成員:
public partial class UserControl1 : UserControl { public UserControl1() { InitializeComponent(); var w = this.Width; Application.Current.MainWindow.SizeChanged += (s, e) => { Debug.WriteLine($"{e.NewSize.Width - w}"); }; } }
3)不正確的Binding(WPF)
如果系結的不是DependencyProperty而且沒有實作INotifyPropertyChanged,那么將產生記憶體泄漏,這與WPF的Binding的實作機制有關:如果系結的是DependencyProperty或一個實作了INotifyPropertyChanged的物件的屬性,那么WPF將利用Weak events模式,不會產生記憶體泄漏,否則,WPF將不得不訴諸于訂閱System.ComponentModel.PropertyDescriptor類的ValueChanged事件來監聽系結source的屬性值的改變,問題在于,這將導致CLR創建一個從PropertyDescriptor到系結source物件的一個強參考,多數情況下,CLR將用一個全域串列保存這個參考,這無疑將導致記憶體泄漏,
不過這種記憶體泄漏只有當BindingMode為OneWay或TwoWay時才會發生,當BindingMode為OneTime或OneWayToSource時,CLR不會創建強參考,即使Binding的不是DependencyProperty而且沒有實作INotifyPropertyChanged,
與此類似的是系結沒有實作INotifyCollectionChanged介面的Collection,這是WPF將創建一個到這個Collection的強參考,產生記憶體泄漏,
4)WPF中x:Name導致的記憶體泄漏
如果Xaml的元素用x:Name進行了命名,那么WPF將創建一個到該元素的全域的強參考,例如:
<local:UserControl1 x:Name="MyUserControl1"/>
那么用code behind動態地將MyUserControl1從其父容器移除并不能真正導致該元素可以被回收,雖然看似MyUserControl1已經被移除了,
private void ButtonBase_OnClick(object sender, RoutedEventArgs e) { rootPanel.Children.Remove(this.MyUserControl1); this.MyUserControl1 = null; }
解決辦法也很簡單:
private void ButtonBase_OnClick(object sender, RoutedEventArgs e) { this.UnregisterName("MyUserControl1"); rootPanel.Children.Remove(this.MyUserControl1); this.MyUserControl1 = null; }
到目前為止,我們談的都是托管記憶體(managed memory),這類記憶體是由GC管理的,非托管記憶體(unmanaged memory)則完全是另一回事事,下面簡單討論以下非托管記憶體泄漏,
2.非托管類記憶體泄漏(unmanaged memory leak)
下面通過一個簡單的列子來說明這個問題:
public class SomeClass { private IntPtr _buffer; public SomeClass() { _buffer = Marshal.AllocHGlobal(1000); } }
上面的物件在創建時通過Marshal.AllocHGlobal()分配了一塊非托管記憶體,在底層,AllocHGlobal()呼叫了Win32的Kernel32.dll的LocalAlloc()函式,如果沒有顯式呼叫Marshal.FreeHGlobal()來釋放這塊記憶體,那么這塊非托管類記憶體將被視為已占用,將長期停駐在堆記憶體,這正是典型的非托管類記憶體泄漏,
要解決這個問題,除了主動呼叫Marshal.FreeHGlobal(),還有一種簡單直接的方法是在析構器里呼叫該方法,如:
public class SomeClass { private IntPtr _buffer; public SomeClass() { _buffer = Marshal.AllocHGlobal(1000); // do stuff without freeing the buffer memory } ~SomeClass() { if (this._buffer != IntPtr.Zero) { Marshal.FreeHGlobal(_buffer); _buffer = IntPtr.Zero; } } }
在SomeClass被回收時,Destructor必然被呼叫(除非對這個物件呼叫了GC.SuppressFinalize),進而呼叫Marshal.FreeHGlobal(),這塊非托管記憶體被回收,避免了記憶體泄漏,這意味著,只要沒有托管類記憶體泄漏導致SomeClass無法被回收,這塊非托管記憶體都能被回收,
在Constructor里分配非托管記憶體,在Destructor里釋放, 這個解決方案似乎完美無缺,但是該方案的問題有二:首先,如果SomeClass因為托管類記憶體泄漏無法被回收,那么其非托管資源將無法被釋放;其次,一個無用的托管物件何時被回收也就是說其Destructor什么時候被呼叫是不確定的,取決于GC,對于后一種情形的嚴重性,不妨考慮一個極端的例子:程式創建了大量小托管物件,而且這些物件都分配大量非托管記憶體,盡管沒有托管類記憶體泄漏,這些小托管物件都可以回收,但是由于GC只能看到托管記憶體,看不到非托管記憶體,于是它認為不需要進行垃圾回收,情況嚴重時,將導致應用占用大量記憶體,其影響不亞于記憶體泄漏,
第二個問題的解決方案是實作Dispose模式,并在物件不再使用時盡早呼叫Dispose方法,
public class SomeClass : IDisposable { private IntPtr _buffer; // To detect redundant calls private bool _disposed = false; public SomeClass() { _buffer = Marshal.AllocHGlobal(1000); } ~SomeClass() => Dispose(false); // Public implementation of Dispose pattern callable by consumers. public void Dispose() { Dispose(true);
//加上這句后,則如果已經被disposed,則在回收時不需要呼叫析構器(呼叫析構器對性能有一定影響) GC.SuppressFinalize(this); } // Protected implementation of Dispose pattern. protected virtual void Dispose(bool disposing) { if (_disposed) { return; } if (disposing) { // TODO: dispose managed state (managed objects). } // TODO: free unmanaged resources (unmanaged objects) and override a finalizer below. // TODO: set large fields to null. if (this._buffer != IntPtr.Zero) { Marshal.FreeHGlobal(_buffer); _buffer = IntPtr.Zero; } _disposed = true; } }
(上面的Dispose模式是MSDN和Resharper都推薦的模式,其中黑體字為新增代碼)這意味著,當實作IDisposable介面的物件不再有用時,應該盡早呼叫其Dispose()方法,關于Dispose模式,這里有必要補充一點:在有些存在托管記憶體泄漏的情況下,我們不能被動依靠GC在銷毀一個垃圾物件時呼叫它的析構器呼叫Dispose,因為由于托管記憶體泄漏,這個物件可能無法被GC回收,如下面的例子:
public class SomeClass : IDisposable { // To detect redundant calls private bool _disposed = false; public SomeClass() { Application.Current.Deactivated += this.Current_Deactivated; } private void Current_Deactivated(object sender, EventArgs e) { //do something } ~SomeClass() => Dispose(false); // Public implementation of Dispose pattern callable by consumers. public void Dispose() { Dispose(true); //加上這句后,則如果已經被disposed,則在回收時不需要呼叫析構器(呼叫析構器對性能有一定影響) GC.SuppressFinalize(this); } // Protected implementation of Dispose pattern. protected virtual void Dispose(bool disposing) { if (_disposed) { return; } if (disposing) { // TODO: dispose managed state (managed objects). Application.Current.Deactivated -= this.Current_Deactivated; } // TODO: free unmanaged resources (unmanaged objects) and override a finalizer below. // TODO: set large fields to null. _disposed = true; } }
由于被全域物件Application參考,在應用結束前這個物件都無法被回收的,因此其析構器也不會被呼叫,這時就必須手動呼叫其Dispose()方法,否則將產生托管記憶體泄漏,
值得一提的是,上面的例子中,由于我們必須手動呼叫Dispose(),所以也就不再需要析構器里那些代碼,也不需要判斷_disposed是否為true,因此Dispose模式在這個例子中可以大大簡化:
public class SomeClass : IDisposable { public SomeClass() { Application.Current.Deactivated += this.Current_Deactivated; } private void Current_Deactivated(object sender, EventArgs e) { //do something } // Public implementation of Dispose pattern callable by consumers. public void Dispose() { Application.Current.Deactivated += this.Current_Deactivated; } }
總結:本文簡單討論了.NET/WPF中記憶體泄漏的型別,產生的原因,GC的作業原理,常見的記憶體泄漏場景以及相應的解決方案,正確的Dispose模式等,由于實際開發中記憶體泄漏問題的復雜性,本文未涉及的記憶體泄漏場景還有很多,如TextBox的Undo快取泄漏、Attached Behaviors等,(原創文章,感謝閱讀,歡迎批評指正,專注請注明出處)
參考文章:
http://dotnet.agilekiwi.com/blog/2010/04/memory-leaks-in-managed-code.html
https://michaelscodingspot.com/ways-to-cause-memory-leaks-in-dotnet
https://michaelscodingspot.com/find-fix-and-avoid-memory-leaks-in-c-net-8-best-practices/
https://stackoverflow.com/questions/18542940/can-bindings-create-memory-leaks-in-wpf/18543350#18543350
https://blog.jetbrains.com/dotnet/2014/09/04/fighting-common-wpf-memory-leaks-with-dotmemory/
https://docs.microsoft.com/en-us/dotnet/standard/garbage-collection/implementing-dispose
轉載請註明出處,本文鏈接:https://www.uj5u.com/net/233306.html
標籤:C#
上一篇:字串截取問題
下一篇:字串轉換注意編碼
