1. Dotnet9首页
  2. .NET
  3. .NET相关

如何查找,修复和避免C#.NET中内存泄漏的8个最佳实践

如何查找,修复和避免C#.NET中内存泄漏的8个最佳实践
原文来自互联网,由长沙DotNET技术社区编译。
如何查找,修复和避免C#.NET中内存泄漏的8个最佳实践

本文来源:https://michaelscodingspot.com/find-fix-and-avoid-memory-leaks-in-c-net-8-best-practices/

从事大型企业项目的任何人都知道内存泄漏就像是大型酒店中的老鼠。当它们很少时,您可能不会注意到,但是您必须始终保持警惕,以防它们过多,闯入厨房并制造混乱。

查找,修复和学习避免内存泄漏是一项重要技能。

我将列出我和高级.NET开发人员为我提供建议的8种最佳实践技术,这些技术将教您检测应用程序中何时存在内存泄漏问题,查找特定的内存泄漏并进行修复。最后,我将介绍监视和报告已部署程序的内存泄漏的策略。

定义.NET中的内存泄漏

在垃圾收集环境中,术语“内存泄漏”有点违反直觉。当有垃圾收集器(GC)负责收集所有内容时,我的内存为何还会泄漏?

有两个相关的核心原因。第一个核心原因是当您具有仍被引用但实际上未使用的对象时。由于已引用它们,垃圾收集器将不会收集它们,并且它们将永久保留,占用内存。例如,当您注册事件但从不注销时,可能会发生这种情况。

第二个原因是当您以某种方式分配非托管内存(没有垃圾回收)并且不释放它时。这并不难做到。.NET本身有很多分配非托管内存的类。几乎所有涉及流,图形,文件系统或网络调用的操作都是在后台进行的。通常,这些类实现** Dispose **方法,该方法释放内存(稍后再讨论)。您可以使用特殊的.NET类(如Marshal)或PInvoke(有一个进一步的示例)轻松地自己分配非托管内存。

让我们进入我的最佳实践技术列表:

1.使用诊断工具窗口检测内存泄漏问题

如果您去调试 | Windows | 显示诊断工具,您将看到此窗口。如果您像我一样,则可能在安装Visual Studio之后看到了此工具窗口,立即关闭了它,再也没有想到它。诊断工具窗口可能会非常有用。它可以轻松地帮助您检测两个问题:内存泄漏和GC压力。

当您有内存泄漏时,“进程内存”图如下所示:

如何查找,修复和避免C#.NET中内存泄漏的8个最佳实践

从顶部的黄线可以看到GC正在尝试释放内存,但它仍在不断上升。

当您具有GC Pressure时,过程内存图如下所示:

如何查找,修复和避免C#.NET中内存泄漏的8个最佳实践

“ GC压力”是在创建新对象并将它们处置得太快而导致垃圾收集器无法跟上时。如图所示,内存已接近极限,GC突发非常频繁。

您将无法通过这种方式找到特定的内存泄漏,但是您可以检测到内存泄漏问题,这本身就很有用。在Enterprise Visual Studio中,“诊断”窗口还包括一个内置的内存探查器,该探查器确实可以查找特定的泄漏。我们将在最佳实践#3中讨论内存分析。

2.使用任务管理器,Process Explorer或PerfMon检测内存泄漏问题

检测主要内存泄漏问题的第二种最简单方法是使用任务管理器或Process Explorer(来自SysInternals)。这些工具可以显示您的进程使用的内存量。如果它随着时间不断增加,则可能是内存泄漏。

如何查找,修复和避免C#.NET中内存泄漏的8个最佳实践

性能监视器是有点难以利用[1],但能证明你的内存使用量随时间的一个很好的曲线图。这是我的应用程序的图形,它不停地分配内存而不释放它。我正在使用过程 | 专用字节计数器。

如何查找,修复和避免C#.NET中内存泄漏的8个最佳实践

请注意,此方法众所周知是不可靠的。您可能只是因为GC尚未收集内存而增加了内存使用量。还有共享内存和私有内存的问题,因此您可能会错过内存泄漏和/或诊断不是您自己的内存泄漏(说明[2])。最后,您可能将内存泄漏误认为是GC Pressure。在这种情况下,您不会发生内存泄漏,但是创建和处理对象的速度如此之快,以至于GC无法跟上进度。

尽管有缺点,但我还是提到了这种技术,因为它既易于使用,有时又是唯一的工具。这也是一个不错的指标,长时间观察时出了点问题。

3.使用内存分析器检测内存泄漏

内存分析器就像处理内存泄漏的厨师刀。它是查找和修复它们的主要工具。尽管其他技术可能更易于使用或更便宜(探查器许可证价格昂贵),但最好精通至少一个内存探查器以有效解决内存泄漏问题。

.NET内存分析器中的大人物 是:dotMemory,SciTech内存分析器 和 ANTS Memory Profiler。如果您拥有Visual Studio Enterprise,则还有一个“免费”分析器。

所有内存分析器都以类似的方式工作。您可以附加到正在运行的进程,也可以打开转储文件。探查器将为您的进程的当前内存堆创建一个快照。您可以通过各种方式分析快照,例如,以下是当前快照中所有已分配对象的列表:

如何查找,修复和避免C#.NET中内存泄漏的8个最佳实践

您可以看到每种类型分配了多少实例,它们占用了多少内存以及GC Root的引用路径。

GC根是GC无法释放的对象,因此GC根引用所引用的所有内容也无法释放。当前活动线程的静态对象和本地对象是GC根。在了解.NET中的垃圾收集中了解更多信息。

最快,最有用的性能分析技术是比较内存应返回相同状态的2个快照。在操作之前拍摄第一个快照,在操作之后拍摄另一个快照。确切的步骤是:

  1. 从应用程序中的某种空闲状态开始。这可能是主菜单或类似的东西。
  2. 通过附加到进程或保存转储,使用Memory Profiler拍摄快照。
  3. 运行怀疑会导致内存泄漏的操作。返回到空闲状态。
  4. 拍摄第二张快照。
  5. 将这两个快照与您的内存分析器进行比较。
  6. 研究新创建的实例,它们很可能是内存泄漏。检查“ GC根目录的路径”,并尝试了解为什么未释放这些对象。

这是一个很棒的视频,其中在SciTech内存分析器 中比较了2个快照,并发现了内存泄漏:

4.使用“ Make Object ID”查找内存泄漏

在上一篇文章5避免C#.NET中的事件造成内存泄漏的技术中,您应该知道[3] 我展示了一种通过在类Finalizer中放置断点来查找内存泄漏的技术。在这里,我将向您展示一种类似的方法,该方法更易于使用,并且不需要更改代码。这利用了调试器的Make Object ID功能和Instant Window。

假设您怀疑某个类存在内存泄漏。换句话说,您怀疑在运行特定方案后,此类仍保持引用状态,并且GC从未收集过此类。要确定GC是否真正收集了它,请按照下列步骤操作:

  1. 在创建类实例的地方放置一个断点。
  2. 将鼠标悬停在变量上以打开调试器的数据提示,然后右键单击并使用Make Object ID。您可以在立即窗口$ 1中键入以查看是否正确创建了对象ID。
  3. 完成本应从实例中释放实例的方案。
  4. 使用已知的魔术线强制进行GC收集
GC.Collect();   
GC.WaitForPendingFinalizers();   
GC.Collect();

 原文中有两端视频,由于微信审核的缘故,无法上传,有兴趣的可以查看原文。

参考这个流程,您可以通过在立即窗口中键入魔术线来强制进行垃圾收集,从而使该技术成为完全的调试体验,而无需更改代码。

重要提示:这种做法在.NET Core 2.X调试器(问题[4])中不能很好地工作。强制在与对象分配相同的范围内进行垃圾回收不会释放该对象。通过将另一种方法中的垃圾回收强制超出范围,您可以花费更多的精力。

5.当心常见的内存泄漏源

始终存在导致内存泄漏的风险,但是某些模式更有可能造成内存泄漏。我建议在使用这些工具时要格外小心,并使用最新的最佳做法等技术来主动检查内存泄漏。

以下是一些较常见的违规者:

  • .NET中的事件因导致内存泄漏而臭名昭著。您可以无辜地订阅一个事件,甚至在不怀疑的情况下导致破坏性的内存泄漏。这个主题是如此重要,以至于我专门写了整篇文章:您应该知道的5种避免C#.NET中的事件造成内存泄漏的技术[5]
  • 特别是静态变量,集合和静态事件应该总是看起来可疑。请记住,所有静态变量都是GC根,因此GC绝不会收集它们。
  • 缓存功能 –任何类型的缓存机制都可以轻易导致内存泄漏。通过最终将高速缓存信息存储在内存中,它将填满并导致OutOfMemory异常。解决方案可以是定期删除较早的缓存或限制缓存量。
  • WPF绑定可能很危险。经验法则是始终绑定到DependencyObject或一种 INotifyPropertyChanged 宾语。如果您这样做失败,WPF将从静态变量创建对绑定源(即ViewModel)的强引用,从而导致内存泄漏。此有用的StackOverflow线程中有关WPF绑定泄漏的更多信息
  • 被捕获的成员 –可能很明显,事件处理程序方法意味着引用了一个对象,但是当变量在匿名方法中被捕获时,也会被引用。这是内存泄漏的示例:
public class MyClass 
{ 
 private int _wiFiChangesCounter = 0; 
 public MyClass(WiFiManager wiFiManager) 
 { 
     wiFiManager.WiFiSignalChanged += (s, e) =>      _wiFiChangesCounter++; 
 } 
}

永不终止的线程 – 每个线程的活动堆栈都被视为GC根。这意味着在线程终止之前,GC不会收集其在堆栈上的变量的任何引用。这也包括计时器。如果您的Timer的滴答处理程序是一个方法,则该方法的对象被视为已引用,并且不会被收集。这是内存泄漏的示例:

public class MyClass   
{   
    public MyClass(WiFiManager wiFiManager)   
  {   
     Timer timer = new Timer(HandleTick);   
     timer.Change(TimeSpan.FromSeconds(5),  TimeSpan.FromSeconds(5));  
  }


    private void HandleTick(object state)  
    { 
        // do something  
    }  
}

有关此主题的更多信息,请查看我的文章8 .NET中导致内存泄漏的方法[6]

6.使用“处置”模式来防止非托管内存泄漏

您的.NET应用程序不断使用非托管资源。

.NET框架本身在很大程度上依赖于非托管代码来进行内部操作,优化和Win32 API。

随时使用Streams,Graphics或Files 对于 例如,您可能正在执行非托管代码。使用非托管代码的.NET框架类通常实现IDisposable。那是因为非托管资源需要明确地释放,这发生在Dispose方法中。您唯一的工作就是记住并调用Dispose方法。如果可能,请使用using语句。

public void Foo()
{
    using (var stream = new FileStream(@"C:\Temp\SomeFile.txt",
                                       FileMode.OpenOrCreate))
    {
        // do stuff
 
    }// stream.Dispose() will be called even if an exception occurs
}

在使用语句转换的代码放到一个尝试/最后的场景,在后面声明的Dispose方法被调用的最后方法。但是,即使您不调用Dispose方法,这些资源也将被释放,因为.NET类使用Dispose Pattern[7]。这基本上意味着,如果之前未调用Dispose,则在对象被垃圾回收时从Finalizer调用它。也就是说,如果您没有内存泄漏并且确实调用了终结器。

当您自己分配非托管资源时,则绝对应该使用Dispose模式。这是一个例子:

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(),则将在垃圾回收期间(在Finalizer中)处置您的资源。

该GC.SuppressFinalize(这)也很重要。如果该对象已经存在,则可以确保终结器未在垃圾回收上被调用处置。使用终结器的对象将以不同的方式释放,并且成本更高。将终结器添加到称为F-Reachable-Queue的对象中,这使该对象在额外的GC生成后仍然存在。还有其他并发症[8]

7.从代码添加内存遥测

有时,您可能想定期记录您的内存使用情况。也许您怀疑生产服务器存在内存泄漏。当您的内存达到一定限制时,您可能想采取一些措施。或者,也许您只是养成监视内存的好习惯。

我们可以从应用程序本身中获取很多信息。使用当前的内存很简单:

Process currentProc = Process.GetCurrentProcess(); 
var bytesInUse = currentProc.PrivateMemorySize64; 

有关更多信息,可以使用用于PerfMon的PerformanceCounter类:

PerformanceCounter ctr1 = new PerformanceCounter("Process", "Private Bytes", Process.GetCurrentProcess().ProcessName); 
PerformanceCounter ctr2 = new PerformanceCounter(".NET CLR Memory", "# Gen 0 Collections", Process.GetCurrentProcess().ProcessName);  
PerformanceCounter ctr3 = new PerformanceCounter(".NET CLR Memory", "# Gen 1 Collections", Process.GetCurrentProcess().ProcessName);  
PerformanceCounter ctr4 = new PerformanceCounter(".NET CLR Memory", "# Gen 2 Collections", Process.GetCurrentProcess().ProcessName);  
PerformanceCounter ctr5 = new PerformanceCounter(".NET CLR Memory", "Gen 0 heap size", Process.GetCurrentProcess().ProcessName);  
//...  
Debug.WriteLine("ctr1 = " + ctr1 .NextValue());  
Debug.WriteLine("ctr2 = " + ctr2 .NextValue());  
Debug.WriteLine("ctr3 = " + ctr3 .NextValue());  
Debug.WriteLine("ctr4 = " + ctr4 .NextValue());  
Debug.WriteLine("ctr5 = " + ctr5 .NextValue());  

可从任何perfMon计数器获得信息,这是很多信息。但是,您可以更深入。CLR MD(Microsoft.Diagnostics.Runtime)允许您检查当前的内存堆并获取任何可能的信息。例如,您可以打印内存中所有已分配的类型,包括实例计数,根目录路径等。您几乎从代码中获得了一个内存探查器。

要了解使用CLR MD可以实现的目标,请查看Dudi Keleti的DumpMiner。

所有这些信息都可以记录到文件中,甚至更好地记录到遥测工具(如Application Insights)中。

8.测试内存泄漏

主动测试内存泄漏是一个好习惯。这并不难。您可以使用以下简短模式:

[Test]  
void MemoryLeakTest()  
{  
  var weakRef = new WeakReference(leakyObject)  
  // Ryn an operation with leakyObject    
  GC.Collect();    
  GC.WaitForPendingFinalizers();  
  GC.Collect();  
  Assert.IsFalse(weakRef.IsAlive); 
}

为了进行更深入的测试,诸如SciTech的.NET Memory Profiler和dotMemory之类的内存分析器提供了一个测试API:

MemAssertion.NoInstances(typeof(MyLeakyClass));    
MemAssertion.NoNewInstances(typeof(MyLeakyClass), lastSnapshot);   
MemAssertion.MaxNewInstances(typeof(Bitmap), 10); 

摘要

你的新年决心是怎样的?我新年的决心是:更好的内存管理。

我希望这篇文章能给您带来一些价值,如果您订阅[9]我的博客或在下面发表评论,我将非常乐意。欢迎任何反馈。

References

[1] 难以利用: https://knowledge.ni.com/KnowledgeArticleDetails?id=kA00Z0000019S9cSAE&l=en-IL
[2] 说明: https://stackoverflow.com/a/1986486/1229063
[3] 5避免C#.NET中的事件造成内存泄漏的技术中,您应该知道: https://michaelscodingspot.com/2018/12/14/5-techniques-to-avoid-memory-leaks-by-events-in-c-net-you-should-know/
[4] 问题: https://github.com/dotnet/coreclr/issues/20156
[5] 您应该知道的5种避免C#.NET中的事件造成内存泄漏的技术: https://michaelscodingspot.com/2018/12/14/5-techniques-to-avoid-memory-leaks-by-events-in-c-net-you-should-know/
[6] 8 .NET中导致内存泄漏的方法: https://michaelscodingspot.com/ways-to-cause-memory-leaks-in-dotnet/
[7] Dispose Pattern: https://docs.microsoft.com/en-us/dotnet/standard/garbage-collection/implementing-dispose
[8] 其他并发症: https://www.jetbrains.com/help/dotmemory/Analyzing_GC_Roots.html
[9] 订阅: https://michaelscodingspot.com/subscribe/

原文出处:微信公众号【 Michael's Coding DotNET技术圈】

原文链接:https://mp.weixin.qq.com/s/4SAzBg3Zmj8chSMAOWD1yA

本文观点不代表Dotnet9立场,转载请联系原作者。

发表评论

登录后才能评论