面试官:工作3年,你连 .NET 内存泄露都不知道?

    科技2026-01-15  17

    原文连接:

    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》,以获取有关此内容的更多信息。

     

    希望你喜欢这篇文章,并祝你编程愉快。

    Processed: 0.014, SQL: 9