深入理解.NET/WPF內存泄漏


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

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM