.NET内存泄漏问题(转)

MSDN 2007年1月期刊上的一篇文章,时间比较早了,但对于内存泄漏的分析还是有很大的参考价值,值得一读。

原文出处:Debug Leaky Apps Identify And Prevent Memory Leaks In Managed Code

目录:

  • 应用程序中的内存
  • 排查内存泄漏问题
  • 堆栈中的内存泄漏问题
  • 非托管堆的内存泄漏问题
  • 托管堆中的内存泄漏问题

有许多开发人员认为,托管代码中的内存泄漏是不可能的,毕竟垃圾回收器(GC)会处理好一切,对吧?不过事实却是GC只会处理托管内存。但是基于.NET Framework的应用程序仍会大量使用非托管内存,可能是公共语言运行时(CLR)本身,也可能是被程序员直接使用。即便是对于托管内存,GC在某些情况下好像也未尽到其责任,它并未有效的处理内存。所以,作为一名拥有良好习惯的内存使用者,我们必须分析我们的应用程序,保证他们在没有内存泄漏问题的同时还能够高效的使用内存。

.NET应用程序中的内存

您大概已经知道:.NET应用程序主要使用以下几种内存:堆栈、非托管堆和托管堆,我们先简单的回顾以下这几个知识点。

堆栈

堆栈是应用程序执行期间用于存储局部变量、方法参数、返回值和其他临时值的地方。堆栈按照每个线程进行分配并作为其执行工作的暂存区。GC不负责清理堆栈,因为当方法返回时,为方法保留的堆栈空间会被自动清理。不过需要注意的是,GC可以识别出在堆栈上存储的对象的引用。当一个对象在方法中被实例化时,它的引用(32位或64位整型,其长度取决于平台类型)将保存在堆栈上,而对象自身却被保存在托管堆上,d当变量超出其作用域时就被GC所“回收”。

非托管堆

非托管堆用于运行时结构、方法表、Microsoft中间语言(MSIL),JITed代码等。非托管代码会根据对象的实例化方式选择将其分配在非托管堆或堆栈上。托管代码可以通过调用非托管的Win32® APIs或实例化COM对象来直接分配非托管内存。CLR出于自身代码和数据结构的原因广泛的使用非托管堆。

托管堆

托管堆是托管对象分配的地方,也是GC的地盘。CLR使用分代压缩GC。GC中的分代指的是由于它将垃圾收集后保留下的对象按生存时间进行划分,这样做有助于提高性能,所有的.NET Framework都采用三代分代方法:Gen0、Gen1和Gen2(从年轻代到老年代);GC中的压缩指的是它会将对象在托管堆上重新定位,从而消除漏洞以保证内存的连续性。移动大型对象的开销很高,因此GC会将这些对象分配到独立且不会被压缩的大型对象堆上。有关托管堆和垃圾收集器的详细信息,请参阅Jeffrey Richter以下两篇文章:

  • Garbage Collection: Automatic Memory Management in the Microsoft .NET Framework
  • Garbage Collection-Part 2: Automatic Memory Management in the Microsoft .NET Framework

尽管这些文章是基于.NET Framework 1.0编写的,且.NET GC在1.1和2.0中也做了改进,但其核心概念却并未改变。

泄漏分析

当一个应用程序发生泄漏时,会出现很多迹象。有可能是抛出OutOfMemoryException异常、也可能是因为应用程序发生了虚拟内存与磁盘交换而运行缓慢、还可能是任务管理器中应用程序内存线性或突发的增长。当内存泄漏发生时,我们需要首先确认内存泄漏的类型,这样才能事半功倍。我们可以使用PerfMon(性能监视器)来检查应用程序的下列性能计数器:

  • Process下的Private Bytes计数器用于报告系统总专门为某一进程分配而无法与其他进程共享的所有内存
  • .NET CLR Memory下的 #Bytes in All Heaps计数器用于报告Gen0、Gen1、Gen2和大型对象堆(LOH)的合计大小
  • .NET CLR LocksAndThreads下的 #of current logical Threads用于报告AppDomain中逻辑线程的数量

如果应用程序的逻辑线程计数出现意想不到的增大,则表明线程堆栈发生泄漏;如果Private Bytes增大,而#Bytes in All Heaps保持不变,则表明非托管内存发生泄漏;如果上述两个计数器均有所增加,则表明托管堆中的内存消耗在增长。

这里插句嘴,分析内存泄漏的话,可以用dotmemory、ants、windbg这些专业工具。

堆栈内存泄漏

虽然堆栈空间不足时可能会引发StackOverflowException异常,但方法调用期间使用的任何堆栈空间都会在该方法返回后被回收。因此,实际上只有两种情况下才会发生堆栈空间泄漏:一种情况是进行及其耗费堆栈资源且从不返回的方法调用,从而使其关联的堆栈无法释放;另一种情况是发生线程泄漏,从而使线程的整个堆栈发生泄漏。如果一个应用程序为了执行后台任务而创建了工作线程,但是却忽视了去终止这些线程,就可能会引发线程堆栈泄漏。默认情况下,现代桌面和服务器版的Windows®堆栈大小均为1MB。如果应用程序的Process/Private Bytes定期增大1MB,同时.NET CLR LocksAndThreads/# of current logical Threads也相应增大,那么罪魁祸首就极有可能是线程堆栈泄漏。示例1向大家展示了多线程下错误的线程清理逻辑。

示例1:线程清理

while (true)
{
    Console.WriteLine("输入回车创建一个新线程...");
    Console.ReadLine();
    Thread t = new Thread(new ThreadStart(ThreadProc));
    t.Start();
}

static void ThreadProc()
{
    Console.WriteLine("启动线程 #{0} ...", Thread.CurrentThread.ManagedThreadId);
    Thread.CurrentThread.Join();
}

在上述的例子中,线程启动后会显示其线程ID,然后尝试调用他自己的Join方法,而Join的作用就是被调用的线程等待另外一个线程的终止。这样,该线程就会陷入一个类似于先有鸡还是先有蛋的尴尬局面中那就是线程需要等待他自己终止。在任务管理器中查看该程序就会发现,每次按下按回车键时内存都会增长1MB。

每次循环时,Thread对象的引用都会被删除,但垃圾回收器并不会回收分配给线程堆栈的内存。一个受托管线程的生存期并不依赖于创建他的线程,这种非关联性设计的好处是显而易见的,因为你肯定不希望一个正在执行任务的线程仅仅是因为你丢失了所有与该线程的引用关系而被GC终止。所以GC只负责回收Thread对象,而非实际的托管线程。受托管线程并未终止(同时其线程堆栈内存也不会释放)直到ThreadProc返回或者被强制终止。因此,当托管线程的终止方式不正确时,分配至其线程堆栈的内存就会发生泄漏。

非托管内存泄漏

如果总的内存使用率增加,但是逻辑线程数和受托管内存并未增加,则可能是非托管内存发生泄漏。接下来我们将对一些引起非托管内存泄漏的常见原因进行分析,包括与非托管代码进行交互、终结器的终止和程序集泄漏。

非托管代码交互

非托管内存泄漏的原因之一,就是与非托管代码进行交互,例如在组件交互中通过P/Invoke和COM对象使用C Style动态链接库,垃圾收集器无法识别非托管内存,而正是在托管代码编写的过程中错误的使用了非托管内存,才导致内存出现泄漏。如果应用程序与非托管代码进行交互,需要检查非托管调用前后的内存使用情况,以验证内存是否被正确回收。如果内存未被正确回收,则需要使用传统的调试方法来找出非托管组件的泄漏原因。

异常中止的终结器

还有一种非常隐蔽的泄漏方式就是对象的终结器中包含了清理非托管内存的代码,但却未被正确调用。正常情况下,终结器会被CLR调用,但CLR并不保证他一定会被调用。未来的版本可能会解决这个问题,但目前为止,CLR只维护了一个线程来处理终结器。考虑到可能会有一些行为不端终结器,比如它试图在离线时将信息记录到数据库中,如果这个行为不端的终结器错误的一次又一次的访问数据库,但从未跳出,那么“行为良好”的终结器将无法运行。这个问题可能会偶发,这取决于这些终结器的的在终结队列中的顺序和行为。

说终结器有些人可能不太理解,以前叫析构器,用于在垃圾回收器收集实例时执行的必要的清理操作。

当应用程序域(AppDomain)被移除时,CLR会尝试通过运行所有终结器来清除终结器队列,停滞的终结器可能会阻止CLR完成应用程序域移除。为此,CLR在这个过程中引入了超时机制,当超时后,CLR会停止终结进程。一般来说,这些不是什么大事,毕竟大多数应用程序只有一个应用程序域,何况他被拆除的原因还是因为进程被关闭。当一个操作系统进程被关闭时,它占用的资源就会被操作系统恢复。不幸的是,在 ASP.NET 或SQL Server™等托管情况下,应用程序域的拆除并不意味着托管进程的拆除。另一个应用程序域可以在同一进程中启动。因为终结器未执行导致组件处于未引用、无法访问和占用空间均会导致泄漏。随着时间的推移,越来越多的内存泄漏就会导致灾难的发生。

应用程序在执行时CLR希望代码之间相互隔离,这种隔离可以通过创建多个进程来实现,但操作系统中创建进程是一件耗时又耗费资源的事情,所以CLR引入了AppDomain的概念,他提供了用户执行托管代码的独立环境。

在 .NET 1.x中,唯一的解决方案是拆除进程并重启。.NET Framework 2.0 引入了关键的终结器,这表示终结器将清理非托管资源,并且必须有机会在 AppDomain 拆卸期间运行。有关详细信息,请参阅 Stephen Toub 的文章“Keep Your Code Running with the Reliability Features of the .NET Framework”。

程序集泄漏

程序集泄漏相对常见因为一旦程序集被加载,直到AppDomain卸载前,它无法卸载。在大多数情况下,除你非动态的生成和加载程序集,否则这并非什么问题。现在让我们更详细的看一下动态代码生成导致的泄漏,特别是XmlSerializer泄漏。

动态代码生成泄漏

有时候我们需要动态的生成代码,可能是应用程序有类似于Microsoft Office的宏扩展接口。也可能也许是债券定价引擎需要动态的加载定价规则,以便用户自定义自己的债券类型,又或者应用程序是Python的动态语言运行时/编译器。在大部分时候,处于性能考虑,需要将宏、定价规则或代码编译为MSIL。System.CodeDom可用于动态生成MSIL。

下面示例演示了如何在内存中动态生成程序集,毫无问题它可以重复调用。遗憾的是,如果宏、定价规则或代码发生变更,就必须重新生成动态程序集。旧的程序集将不再使用,但你也无法从内存中清除这部分数据,直到加载这段程序集的AppDomain被卸载。用于其代码、JIT方法和其他运行时结构的这部分非托管堆内存就会泄漏(托管内存也以动态生成的类上的任何静态字段的形式泄漏)。没有神奇的公式可以检测此问题。如果使用 System.CodeDom 动态生成 MSIL,请检查是否重新生成代码。如果这样做,则会泄漏非托管堆内存。

CodeCompileUnit program = new CodeCompileUnit();
CodeNamespace ns = new 
  CodeNamespace("MsdnMag.MemoryLeaks.CodeGen.CodeDomGenerated");
ns.Imports.Add(new CodeNamespaceImport("System"));
program.Namespaces.Add(ns);

CodeTypeDeclaration class1 = new CodeTypeDeclaration("CodeDomHello");
ns.Types.Add(class1);
CodeEntryPointMethod start = new CodeEntryPointMethod();
start.ReturnType = new CodeTypeReference(typeof(void));
CodeMethodInvokeExpression cs1 = new CodeMethodInvokeExpression(
  new CodeTypeReferenceExpression("System.Console"), "WriteLine", 
    new CodePrimitiveExpression("Hello, World!"));
start.Statements.Add(cs1);
class1.Members.Add(start);

CSharpCodeProvider provider = new CSharpCodeProvider();
CompilerResults results = provider.CompileAssemblyFromDom(
  new CompilerParameters(), program);

有两种主要的技术方案可以解决此问题:第一种方法是将动态生成的 MSIL 加载到子应用程序域中,当生成的代码发生更改并启动新代码以托管更新的 MSIL 时,可以卸载子 AppDomain,此技术适用于所有版本的 .NET Framework;另一种方法是.NET Framework 2.0 中引入的轻量级代码生成方式,也称为动态方法,使用 DynamicMethod,显式发出 MSIL 操作代码以定义方法主体,然后通过 DynamicMethod.Invoke 或通过合适的委托直接调用 DynamicMethod。

DynamicMethod dm = new DynamicMethod("tempMethod" + 
  Guid.NewGuid().ToString(), null, null, this.GetType());
ILGenerator il = dm.GetILGenerator();

il.Emit(OpCodes.Ldstr, "Hello, World!");
MethodInfo cw = typeof(Console).GetMethod("WriteLine", 
  new Type[] { typeof(string) });
il.Emit(OpCodes.Call, cw);

dm.Invoke(null, null);

动态方法的主要优点是 MSIL 和所有相关代码生成数据结构都分配在托管堆上。这意味着,一旦对动态方法的最后一个引用超出范围,GC 就可以回收内存。

这里有些晦涩,可以看看应用程序域动态编程

XmlSerializer 泄漏

.NET Framework 的某些部分(如 XmlSerializer)在内部使用动态代码生成。请考虑以下典型的 XmlSerializer 代码:

XmlSerializer serializer = new XmlSerializer(typeof(Person));
serializer.Serialize(outputStream, person);

构造函数将使用反射分析 Person 类,生成一对派生自 XmlSerializationReader 和 XmlSerializationWriter 的类。它将创建临时 C# 文件,将生成的文件编译为临时程序集,最后将该程序集加载到进程中。像这样的代码生成也相对昂贵。因此,XmlSerializer 基于每种类型缓存临时程序集。这意味着下次创建 Person 类的 XmlSerializer 时,将使用缓存的程序集,而不是生成新的程序集。

默认情况下,XmlSerializer 使用的 XmlElement 名称是类的名称。因此,人员将被序列化为:

<?xml version="1.0" encoding="utf-8"?>
<Person xmlns:xsi="https://www.w3.org/2001/XMLSchema-instance" 
  xmlns:xsd="https://www.w3.org/2001/XMLSchema">
 <Id>5d49c002-089d-4445-ac4a-acb8519e62c9</Id>
 <FirstName>John</FirstName>
 <LastName>Doe</LastName>
</Person>

有时需要更改根元素名称而不更改类名(为了与现有架构兼容,可能需要根元素名称)。因此,Person 可能必须序列化为 <PersonInstance>。方便的是,XmlSerializer 构造函数的重载将根元素名称作为其第二个参数,如下所示:

XmlSerializer serializer = new XmlSerializer(typeof(Person), new XmlRootAttribute("PersonInstance"));

当应用开始序列化/反序列化 Person 对象时,一切正常,直到引发内存不足异常。XmlSerializer 构造函数的这种重载不会缓存动态生成的程序集,而是在每次实例化新的 XmlSerializer 时生成一个新的临时程序集!应用以临时程序集的形式泄漏非托管内存。若要修复泄漏,请在类上使用 XmlRootAttribute 来更改序列化类型的根元素名称:

[XmlRoot("PersonInstance")]
public class Person {
  // code
}

如果将属性直接应用于类型,则 XmlSerializer 将缓存为该类型生成的程序集,并且不会发生泄漏。如果需要动态切换根元素名称,则应用程序可以使用工厂检索 XmlSerializer 实例本身的缓存:

XmlSerializer serializer = XmlSerializerFactory.Create(
  typeof(Person), "PersonInstance");

XmlSerializerFactory 是我创建的一个类,用于检查 Dictionary<TKey, TValue> 是否包含使用 PersonInstance 根元素名称的 XmlSerializer for Person。如果是,则返回实例。如果没有,则会创建一个新数据,将其存储在哈希表中,然后返回给调用方。

托管堆内存“泄漏”

现在,让我们将注意力转向托管内存“泄漏”。在处理托管内存时,GC会为我们处理大部分工作。我们需要向GC提供其完成工作所需的信息。但是,有许多情况会阻止GC有效地完成其工作,并导致托管内存使用率高于其他方式所需的内存使用率。这些情况包括大型对象堆碎片、不需要的根引用和中生代危机。

大型对象堆碎片

如果对象为85000字节或更大,则会在大型对象堆上分配该对象。请注意,这是对象本身的大小,而不是任何子对象的大小。以以下类为例:

public class Foo {
  private byte[] m_buffer = new byte[90000]; // large object heap
}

Foo实例将在正常的代托管堆上分配,因为它只包含对缓冲区的4字节(32位框架)或8字节(64位框架)引用,以及.NET Framework使用的一些其他内务管理数据,缓冲区将在大型对象堆上分配。

与托管堆的其余部分不同,由于移动大型对象的成本过高,大型对象堆不会压缩。因此,随着大型对象的分配、释放和清理,将出现间隙。根据使用模式的不同,大型对象堆中的间隙可能会导致内存使用量明显多于当前分配的大型对象所需的内存使用量。本月下载中包含的 LOHFragmentation应用程序通过在大型对象堆中随机分配和释放字节数组来演示这一点。应用程序的某些运行会导致新创建的字节数组很好地适应释放的字节数组留下的间隙。在应用程序的其他运行中,情况并非如此,所需的内存远大于当前分配的字节数组所需的内存。若要可视化大型对象堆的碎片,请使用内存探查器,如 CLRProfiler。图 3 中的红色区域是分配的字节数组,而白色区域是未分配的空间。
图3

没有单一的解决方案可以避免大型对象堆碎片。使用 CLRProfiler 等工具检查应用程序如何使用内存,特别是大型对象堆上的对象类型。如果碎片是由于重新分配缓冲区造成的,请维护一组可重用的固定缓冲区。如果碎片是由大量字符串的串联引起的,请检查 System.Text.StringBuilder 类是否可以减少创建的临时字符串数。基本策略是确定如何减少应用程序对临时大型对象的依赖,这些对象会导致大型对象堆中的间隙。

不需要的根引用

让我们考虑一下GC如何确定何时可以回收内存。当CLR尝试分配内存并且保留的内存不足时,它将执行垃圾回收。GC枚举所有根引用,包括任何线程调用堆栈上的静态字段和范围内局部变量。它将这些引用标记为可访问,并遵循这些对象包含的任何引用,将它们也标记为可访问。它继续此过程,直到它访问了所有可访问的引用。任何未标记的物体都无法访问,因此是垃圾。GC压缩托管堆,整理引用以指向其在堆中的新位置,并将控制权返回给CLR。如果释放了足够的内存,则使用此释放的内存继续分配。如果没有,则从操作系统请求额外的内存。

如果我们忘记清空根引用,GC 将无法尽快有效地释放内存,从而导致应用程序的内存占用量更大。这个问题可能很微妙,例如,在进行远程调用(如数据库查询或对 Web 服务的调用)之前创建临时对象的大型图形的方法。如果在远程调用期间发生垃圾回收,则整个图形将标记为可访问且不会收集。这变得更加昂贵,因为会后后幸存下来的数据被提升到下一代,这可能导致中生代危机。

中生代危机

中生代危机不会导致申请出去买保时捷。但是,它可能会导致过度使用托管堆内存,并在 GC 中花费过多的处理器时间。如前所述,GC 使用分代算法,该算法基于启发式算法,即如果一个对象存活了一段时间,它可能会存活一段时间。例如,在 Windows 窗体应用程序中,主窗体在应用程序启动时创建,应用程序在主窗体关闭时退出。GC 不断验证主窗体是否被引用是浪费。当系统需要内存来满足分配请求时,它首先执行 Gen0 集合。如果没有足够的内存可用,则执行 Gen1 收集。如果仍然无法满足分配请求,则会执行 Gen2 收集,这涉及对整个托管堆进行代价高昂的扫描。Gen0 集合相对便宜,因为只考虑收集最近分配的对象。

中生代危机发生在物体倾向于活到第1代(或更糟的是第2代),但此后不久死亡时。这具有将廉价的 Gen0 集合转换为更昂贵的 Gen1(或 Gen2)集合的效果。怎么会这样?请看下面的代码:

class Foo {
  ~Foo() { }
}

此对象将始终在第 1 代集合中回收!终结器 ~Foo()允许我们为对象实现清理代码,除非粗鲁的将AppDomain中止,否则这些代码将在释放对象的内存之前运行。GC的工作是尽快释放尽可能多的托管内存。终结器是用户编写的代码,绝对可以做任何事情。虽然不推荐,但终结器可以做一些愚蠢的事情,例如记录数据库或调用 Thread.Sleep(int.最大值)。因此,当 GC 找到带有终结器的未引用对象时,它会将该对象放在终结队列中并继续前进。该对象在垃圾收集中幸存下来,因此被提升了一代。甚至还有一个性能计数器:.NET CLR 内存终结幸存者,这是上次垃圾回收期间由于终结器而幸存的对象数。最终,终结器线程将运行对象的终结器,随后可以收集它。但是你已经把一个便宜的 Gen0 集合变成了一个 Gen1 集合,所有这些都是通过简单地添加一个终结器!

在大多数情况下,编写托管代码时不需要终结器。仅当托管对象包含对需要清理的非托管资源的引用时,才需要它们,即使这样,也应使用SafeHandle派生类型来包装非托管资源,而不是实现终结器。此外,如果使用非托管资源或实现 IDisposable 的其他托管类型,请实现 Dispose 模式,以允许对象的用户主动清理资源并避免任何相关的完成。

如果某个对象仅包含对其他托管对象的引用,则 GC 将清理未引用的对象。这与必须在子对象上调用删除的C++形成鲜明对比。如果终结器为空或只是清空对子对象的引用,请将其删除。它不必要地将对象推广给老一代,从而损害性能,使它们的清理成本更高。

还有其他方法可以进入中生代危机,例如在进行阻塞调用(如查询数据库、阻塞另一个线程或调用 Web 服务)之前保留对象。在调用期间,可能会发生一个或多个集合,并导致廉价的Gen0对象提升到更高一代,再次导致更高的内存使用量和收集成本。

事件处理程序和回调还会出现更微妙的情况。我将以 ASP.NET 为例,但任何应用程序中都可能发生相同类型的问题。请考虑执行昂贵的查询,并希望将结果缓存5分钟。查询特定于页面,并基于查询字符串参数。为了监视缓存行为,事件处理程序会在从缓存中删除项时记录下来(请参阅记录从缓存中删除的项目)。

rotected void Page_Load(object sender, EventArgs e) {
  string cacheKey = buildCacheKey(Request.Url, Request.QueryString);
  object cachedObject = Cache.Get(cacheKey);
  if(cachedObject == null) {
    cachedObject = someExpensiveQuery();
    Cache.Add(cacheKey, cachedObject, null, 
      Cache.NoAbsoluteExpiration,
      TimeSpan.FromMinutes(5), CacheItemPriority.Default, 
      new CacheItemRemovedCallback(OnCacheItemRemoved));
  }
  ... // Continue with normal page processing
}

private void OnCacheItemRemoved(string key, object value,
                CacheItemRemovedReason reason) {
  ... // Do some logging here
}

这个看起来无害的代码包含一个主要问题。所有这些ASP.NET Page实例都变成了长期存在的对象。OnCacheItemRemoved是一个实例方法,CacheItemRemovedCallback委托包含一个隐式this指针,其中这是Page实例。委托将添加到缓存对象。因此,现在存在从缓存到委托再到Page实例的依赖项。发生垃圾回收时,Page实例仍可从根引用(缓存对象)访问。Page实例(以及它在呈现时创建的所有临时对象)现在必须等待至少五分钟才能收集,在此期间,它们可能会提升到Gen2。幸运的是,此示例有一个简单的解决方案。使回调函数成为静态函数。对Page实例的依赖已中断,现在可以将其廉价地收集为Gen0对象。

结束语

我已经讨论了.NET应用程序中可能导致内存泄漏或内存过度消耗的各种问题。尽管.NET减少了您关心内存的需要,但您仍然必须注意应用程序对内存的使用,以确保它运行良好且高效。仅仅因为应用程序是托管的并不意味着您可以将良好的软件工程实践抛在窗外并依靠GC来执行魔术。在开发和测试过程中,必须继续监视应用程序的内存性能计数器。但这是值得的。请记住,一个表现良好的应用程序意味着满意的客户。

posted @ 2023-04-12 17:40  猫探长  阅读(187)  评论(0编辑  收藏  举报