眾所周知,內存管理和如何避免內存泄漏(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