您的位置:首页 > 其它

.NET 应用程序中能够导致内存泄漏或内存消耗过度的各种问题

2009-01-09 10:55 369 查看
 一提到托管代码中出现内存泄漏,很多开发人员的第一反应都认为这是不可能的。毕竟垃圾收集器 (GC) 会负责管理所有的内存,没错吧?但要知道,垃圾收集器只处理托管内存。基于 Microsoft .NET Framework 的应用程序中大量使用了非托管内存,这些非托管内存既可以被公共语言运行库 (CLR) 使用,也可以在与非托管代码进行互操作时被程序员显式使用。在某些情况下,垃圾管理器似乎在逃避自己的职责,没有对托管内存进行有效处理。这通常是由于不易察觉的(也可能是非常明显的)编程错误妨碍了垃圾收集器的正常工作而造成的。作为经常与内存打交道的程序员,我们仍需要检查自己的应用程序,确保它们不会发生内存泄漏并能够合理有效地使用所需内存。
.NET 应用程序中的内存您大概已经知道,.NET 应用程序中要使用多种类型的内存,包括:堆栈、非托管堆和托管堆。这里我们需要简单回顾一下。堆栈 堆栈用于存储应用程序执行过程中的局部变量、方法参数、返回值和其他临时值。堆栈按照每个线程进行分配,并作为每个线程完成其工作的一个暂存区。垃圾收集器并不负责清理堆栈,因为为方法调用预留的堆栈会在方法返回时被自动清理。但是请注意,垃圾收集器知道在堆栈上存储的对象的引用。当对象在一种方法中被实例化时,该对象的引用(32 位或 64 位整型值,取决于平台类型)将保留在堆栈中,而对象自身却存储于托管堆中,并在变量超出范围时被垃圾收集器收集。非托管堆 非托管堆用于运行时数据结构、方法表、Microsoft 中间语言 (MSIL)、JITed 代码等。非托管代码根据对象的实例化方式将其分配在非托管堆或堆栈上。托管代码可通过调用非托管的 Win32 API 或实例化 COM 对象来直接分配非托管堆内存。CLR 出于自身的数据结构和代码原因广泛地使用非托管堆。托管堆 托管堆是用于分配托管对象的区域,同时也是垃圾收集器的域。CLR 使用分代压缩垃圾收集器。垃圾收集器之所以称为分代式,是由于它将垃圾收集后保留下来的对象按生存时间进行划分,这样做有助于提高性能。所有版本的 .NET Framework 都采用三代分代方法:第 0 代、第 1 代和第 2 代(从年轻代到年老代)。垃圾收集器之所以称为压缩式,是因为它将对象重新定位于托管堆上,从而能够消除漏洞并保持可用内存的连续性。移动大型对象的开销很高,因此垃圾收集器将这些大型对象分配在独立的且不会压缩的大型对象堆上。有关托管堆和垃圾收集器的详细信息,请参阅 Jeffrey Richter 所著的分为两部分的系列文章“垃圾收集器:Microsoft .NET Framework 中的自动内存管理”和“垃圾收集器 -第 2 部分:Microsoft .NET Framework 中的自动内存管理”。虽然该文的写作是基于 .NET Framework 1.0,而且 .NET 垃圾收集器已经有所改进,但是其中的核心思想与 1.1 版或 2.0 版是保持一致的。
检测泄漏很多迹象能够表明应用程序正在发生内存泄漏。或许应用程序正在引发 OutOfMemoryException。或许应用程序因启动了虚拟内存与硬盘的交换而变得响应迟缓。或许出现任务管理器中内存的使用率逐渐(也可能突然地)上升。当怀疑应用程序发生内存泄漏时,必须首先确定是哪种类型的内存发生泄漏,以便您将调试工作的重点放在合适的区域。使用 PerfMon 来检查用于应用程序的下列性能计数器:Process/Private Bytes、.NET CLR Memory/# Bytes in All Heaps 和 .NET CLR LocksAndThreads/# of current logical Threads。Process/Private Bytes 计数器用于报告系统中专门为某一进程分配而无法与其他进程共享的所有内存。.NET CLR Memory/# Bytes in All Heaps 计数器报告第 0 代、第 1 代、第 2 代和大型对象堆的合计大小。.NET CLR LocksAndThreads/# of current logical Threads 计数器报告 AppDomain 中逻辑线程的数量。如果应用程序的逻辑线程计数出现意想不到的增大,则表明线程堆栈发生泄漏。如果 Private Bytes 增大,而 # Bytes in All Heaps 保持不变,则表明非托管内存发生泄漏。如果上述两个计数器均有所增加,则表明托管堆中的内存消耗在增长。
堆栈内存泄漏虽然有可能出现堆栈空间不足而导致在受托管的情况下引发 StackOverflowException 异常,但是方法调用期间使用的任何堆栈空间都会在该方法返回后被回收。因此,实际上只有在两种情况下才会发生堆栈空间泄漏。一种情况是进行一种极其耗费堆栈资源并且从不返回的方法调用,从而使关联的堆栈帧无法得到释放。另一种情况是发生线程泄漏,从而使线程的整个堆栈发生泄漏。如果应用程序为了执行后台工作而创建了工作线程,但却忽略了正常终止这些进程,则可引起线程泄漏。默认情况下,最新桌面机和服务器版的 Windows 堆栈大小均为 1MB。因此如果应用程序的 Process/Private Bytes 定期增大 1MB,同时 .NET CLR LocksAndThreads/# of current logical Threads 也相应增大,那么罪魁祸首很可能是线程堆栈泄漏。图 1 显示了(恶意的)多线程逻辑导致的不正确的线程清理示例。当一个线程启动后会显示其线程 ID,然后尝试自联接。联接会导致调用线程停止等待另一线程的终止。这样该线程就会陷入一个类似于先有鸡还是先有蛋的尴尬局面之中 — 线程要等待自身的终止。在任务管理器下查看该程序,会发现每次按 <Enter> 时,其内存使用率会增长 1MB(即线程堆栈的大小)。每次经过循环时,Thread 对象的引用都会被删除,但垃圾收集器并未回收分配给线程堆栈的内存。托管线程的生存期并不依赖于创建它的 Thread 对象。如果您只是因为丢失了所有与 Thread 对象相关联的引用而不希望垃圾收集器将一个仍在运行的进程终止,这种不依赖性是非常有好处的。由此可见,垃圾收集器只是收集 Thread 对象,而非实际托管的线程。只有在其 ThreadProc 返回后或者自身被直接终止的情况下,托管线程才会退出(其线程堆栈的内存不会释放)。因此,如果托管线程的终止方式不正确,分配至其线程堆栈的内存就会发生泄漏。[align=right][/align]
非托管堆内存泄漏如果总的内存使用率增加,而逻辑线程计数和托管堆内存并未增加,则表明非托管堆出现内存泄漏。我们将对导致非托管堆中出现内存泄漏的一些常见原因进行分析,其中包括与非托管代码进行互操作、终结器被终止以及程序集泄漏。与非托管代码进行互操作:这是内存泄漏的起因之一,涉及到与非托管代码的互操作,例如在 COM Interop 中通过 P/Invoke 和 COM 对象使用 C 样式的 DLL。垃圾收集器无法识别非托管内存,而正是在托管代码的编写过程中错误地使用了非托管内存,才导致内存出现泄漏。如果应用程序与非托管代码进行互操作,要逐步查看代码并检查非托管调用前后内存的使用情况,以验证内存是否被正确回收。如果内存未被正确回收,则使用传统的调试方法在非托管组件中查找泄漏。终结器被终止:当一个对象的终结器未被调用,并且其中含有用于清理对象所分配的非托管内存的代码时,会造成隐性泄漏。在正常情况下,终结器都将被调用,但是 CLR 不会对此提供任何保证。虽然未来可能会有所变化,但是目前的 CLR 版本仅使用一个终结器线程。请考虑这样一种情况,运行不正常的终结器试图将信息记录到脱机的数据库。如果该运行不正常的终结器反复尝试对数据库进行错误的访问而从不返回,则“运行正常”的终结器将永远没有机会运行。该问题会不时出现,因为这取决于终结器在终结队列中的位置以及其他终结器采取何种行为。当 AppDomain 拆开时,CLR 将通过运行所有终结器来尝试清理终结器队列。被延迟的终结器可阻止 CLR 完成 AppDomain 拆开。为此,CLR 在该进程上做了超时操作,随后将停止该终止进程。但是这并不意味着世界末日已经来临。因为通常情况下,大多数应用程序只有一个 AppDomain,而只有进程被关闭才会导致 AppDomain 的拆开。当操作系统进程被关闭,操作系统会对该进程资源进行恢复。但不幸的是,在诸如 ASP.NET 或 SQL Server" 之类的宿主情况下,AppDomain 的拆开并不意味着宿主进程的结束。另一个 AppDomain 会在同一进程中启动。任何因自身终结器未运行而被组件泄漏的非托管内存都将继续保持未引用状态,无法被访问,并且占用一定空间。因为内存的泄漏会随着时间的推移越来越严重,所以这将带来灾难性的后果。在 .NET 1.x 中,唯一的解决方法是结束并重新启动该进程。.NET Framework 2.0 中引入了关键的终结器,指明在 AppDomain 关闭期间,终结器将清理非托管资源并必须获得运行的机会。有关详细信息,请参阅 Stephen Toub 的文章:“利用 .NET Framework 的可靠性功能确保代码稳定运行”。程序集泄漏:程序集泄漏相对来说要常见一些。一旦程序集被加载,它只有在 AppDomain 被卸载的情况下才能被卸载。程序集泄漏也正是由此引发的。大多数情况下,除非程序集是被动态生成并加载的,否则这根本不算个问题。下面我们就来看一看动态代码生成造成的泄漏,特别要详细分析 XmlSerializer 的泄漏。动态代码生成有时会泄漏我们需要动态生成代码。也许应用程序具有与 Microsoft Office 相似的宏脚本编写接口来提高其扩展性。也许某个债券定价引擎需要动态加载定价规则,以便最终用户能够创建自己的债券类型。也许应用程序是用于 Python 的动态语言运行库/编译器。在很多情况下,出于性能方面的考虑,最好是通过编写宏、定价规则或 MSLI 代码来解决问题。您可以使用 System.CodeDom 来动态生成 MSLI。图 2 中的代码可在内存中动态生成一个程序集。该程序集可被重复调用而不会出现问题。遗憾的是,一旦宏、定价规则或代码有所改变,就必须重新生成新的动态程序集。原有的程序集将不再使用,但是却无法从内存中清除,加载有程序集的 AppDomain 也无法被卸载。其代码、JITed 方法和其他运行时数据结构所用的非托管堆内存已经被泄漏。(托管内存也在动态生成的类上以任意静态字段的形式被泄漏。)要检测到这一问题,我们尚无良方妙计。如果您正使用 System.CodeDom 动态地生成 MSLI,请检查是否重新生成了代码。如果有代码生成,那么您的非托管堆内存正在发生泄漏。目前有两种主要方法可解决这一问题。第一种方法是将动态生成的 MSLI 加载到子 AppDomain 中。子 AppDomain 能够在所生成的代码发生改变时被卸载,并运行一个新的子 AppDomain 来托管更新后的 MSLI。这种方法在所有版本的 .NET Framework 中都是行之有效的。.NET Framework 2.0 中还引入了另外一种叫做轻量级代码生成的方法,也称动态方法。使用 DynamicMethod 可以显式发出 MSLI 的操作码来定义方法体,然后可以直接通过 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);
动态方法的主要优势是 MSLI 和所有相关代码生成数据结构均被分配在托管堆上。这意味着一旦 DynamicMethod 的最后一个引用超出范围,垃圾收集器就能够回收内存。XmlSerializer 泄漏:.NET Framework 中的某些部分(例如 XmlSerializer)会在内部使用动态代码生成。请看下列典型的 XmlSerializer 代码:
XmlSerializer serializer = new XmlSerializer(typeof(Person));
serializer.Serialize(outputStream, person);
XmlSerializer 构造函数将使用反射来分析 Person 类,并藉此生成一对由 XmlSerializationReader 和 XmlSerializationWriter 派生而来的类。它将创建临时的 C# 文件,将结果文件编译成临时程序集,并最终将该程序集加载到进程。通过这种方式生成的代码同样需要相当大的开销。因此 XmlSerializer 对每种类型的临时程序集进行缓存。也就是说,下一次为 Person 类创建 XmlSerializer 时,会使用缓存的程序集,而不再生成新的程序集。默认情况下,XmlSerializer 所使用的 XmlElement 名称就是该类的名称。因此,Person 将被序列化为:
<?xml version="1.0" encoding="utf-8"?>
<Person xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:xsd="http://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 对象进行序列化/反序列化时,一切运转正常,直至引发 OutOfMemoryException。对 XmlSerializer 构造函数的重载并不会对动态生成的程序集进行缓存,而是在每次实例化新的 XmlSerializer 时生成新的临时程序集。这时应用程序以临时程序集的形式泄漏非托管内存。要修复该泄漏,请在类中使用 XmlRootAttribute 以更改序列化类型的根元素名称:
[XmlRoot("PersonInstance")]
public class Person {
// code
}
如果直接将属性赋予类型,则 XmlSerializer 对为类型所生成的程序集进行缓存,从而避免了内存的泄漏。如果需要对根元素名称进行动态切换,应用程序能够利用工厂对其进行检索,从而对 XmlSerializer 实例自身进行缓存。
XmlSerializer serializer = XmlSerializerFactory.Create(
typeof(Person), "PersonInstance");
XmlSerializerFactory 是我创建的一个类,它可以使用 PersonInstance 根元素名称来检查 Dictionary<Tkey, Tvalue> 中是否包含有用于 Person 的 Xmlserializer。如果包含,则返回该实例。如果不包含,则创建一个新的实例,并将其存储在哈希表中返回给调用方。


“泄漏”托管堆内存现在让我们关注一下托管内存的“泄漏”。在处理托管内存时,垃圾收集器会帮助我们完成绝大部分的工作。我们需要向垃圾收集器提供工作所需的信息。但是,在很多场合下,垃圾收集器无法有效地工作,导致需要使用比正常工作要求更高的托管内存。这些情况包括大型对象堆碎片、不必要的根引用以及中年危机。大型对象堆碎片 如果一个对象的大小为 85,000 字节或者更大,就要被分配在大型对象堆上。请注意,这里是指对象自身的大小,并非任何子对象的大小。以下列类为例:
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 中的大型对象堆 (单击该图像获得较大视图)目前尚无一种单一的解决方案能够避免大型对象堆碎片的产生。您可以使用类似 CLRProfiler 的工具对应用程序的内存使用情况,特别是大型对象堆中的对象类型进行检查。如果碎片是由于重新分配缓冲区而产生的,则请保持固定数量的重用缓冲区。如果碎片是由于大量字符串串连而产生的,请检查 System.Text.StringBuilder 类是否能够减少创建临时字符串的数量。基本策略是要确定如何降低应用程序对临时大型对象的依赖,而临时大型对象正是大型对象堆中产生空隙的原因所在。不必要的根引用 让我们思考一下垃圾收集器是如何决定回收内存的时间。当 CLR 试图分配内存并保留不足的内存时,它就在扮演着垃圾收集器的角色。垃圾收集器列出了所有的根引用,包括位于任何线程的调用堆栈上的静态字段和域内局部变量。垃圾收集器将这些引用标记为可访问,并跟据这些对象所包含的引用,将其同样标记为可访问。这一过程将持续进行,直至所有可访问的引用均被访问。任何没有被标记的对象都是无法访问的,因此是垃圾。垃圾收集器对托管堆进行压缩,整理引用以指向它们在堆中的新位置,并将控件返回给 CLR。如果释放充足的内存,则使用此释放的内存进行分配。如果释放的内存不足,则向操作系统请求额外的内存。如果我们忘记清空根引用,系统会立即阻止垃圾收集器有效地释放内存,从而导致应用程序需要更多的内存。问题可能微妙,例如一种方法,它能够在做出与查询数据库或调用某个 Web 服务相类似的远程调用前为临时对象创建大型图形。如果垃圾收集发生在远程调用期间,则整个图形被标记为可访问的,并不会收集。这样会导致更大的开销,因为在收集中得以保留的对象将被提升到下一代,这将引起所谓的中年危机。中年危机 中年危机不会使应用程序去购买一辆保时捷。但它却可以造成托管堆内存的过度使用,并使垃圾收集器花费过多的处理器时间。正如前面所提到的,垃圾收集器使用分代式算法,采取试探性的推断,它会认为如果对象已经存活一段时期,则有可能存活更长的一段时期。例如,在 Windows 窗体应用程序中,应用程序启动时会创建主窗体,主窗体关闭时应用程序则退出。对于垃圾收集器来说,持续地验证主窗体是否正在被引用是一件浪费资源的事。当系统需要内存以满足分配请求时,会首先执行第 0 代收集。如果没有足够的可用内存,则执行第 1 代收集。如果仍然无法满足分配请求,则继续执行第 2 代收集,这将导致整个托管堆以极大的开销进行清理工作。第 0 代收集的开销相对较低,因为只有当前被分配的对象才被认为是需要收集的。如果对象有继续存活至第 1 代(或更严重至第 2 代)的趋势,但却随即死亡,此时就会出现中年危机。这样做的效果是使得开销低的第 0 代收集转变为开销大得多的第 1 代(或第 2 代)收集。为什么会发生这种现象呢?请看下面的代码:
class Foo {
~Foo() { }
}
对象将始终在第 1 代收集中被回收!终结器 ~Foo() 使我们可以实现对象的代码清理,除非强行终止 AppDomain,否则代码将在对象内存被释放前运行。垃圾收集器的任务是尽快地释放尽可能多的托管内存。终结器是由用户编写的代码,并且毫无疑问可以执行任何操作。虽然我们并不建议,但是终结器也会执行一些愚蠢的操作,例如将日志记录到数据库或调用 Thread.Sleep(int.MaxValue)。因此,当垃圾收集器发现具有终结器但未被引用的对象时,会将该对象加入到终结队列中,并继续工作。该对象由此在垃圾收集中得以保留,被提升一代。这里甚至为其准备了一个性能计数器:.NET CLR Memory-Finalization Survivors,可显示最后一次垃圾收集期间由于具有终结器而得以保留的对象的数量。最后,终结器线程将运行对象的终结器,随后对象即被收集。但此时您已经从开销低的第 0 代收集转变为第 1 代收集,而您仅仅是添加了一个终结器!大多数情况下,编写托管代码时终结器并不是必不可少的。只有当托管对象具有需要清理的非托管资源的引用时,才需要终结器。而且即使这样,您也应该使用 SafeHandle 派生类型来对非托管资源进行包装,而不要使用终结器。此外,如果您使用非托管资源或其他实现 Idispoable 的托管类型,请实现 Dispose 模式来让使用对象的用户大胆地清理资源,并避免使用任何相关的终结器。如果一个对象仅拥有其他托管对象的引用,垃圾收集器将对未引用的对象进行清理。这一点与 C++ 截然不同,在 C++ 中必须在子对象上调用删除命令。如果终结器为空或仅仅将子对象引用清空,请将其删除。将对象不必要地提升至更高一代将对性能造成影响,使清理开销更高。还有一些做法会导致中年危机,例如在进行查询数据库、在另一线程上阻塞或调用 Web 服务等阻塞调用之前保持对对象的持有。在调用过程中,可以发生一次或多次收集,并由此使得开销低的第 0 代对象提升至更高一代,从而再次导致更高的内存使用率和收集成本。还有一种情况,它与事件处理程序和回调一起发生并且更难理解。我将以 ASP.NET 为例,但同样类型的问题也会发生在任何应用程序中。考虑一下执行一次开销很大的查询,然后等上 5 分钟才可以缓存查询结果的情况。查询是属于页面查询,并基于查询字符串参数来进行。当一项内容从缓存中删除时,事件处理程序将进行记录,以监视缓存行为。(参见图 4)。看上去正常的代码实际上隐含着严重的错误。所有这些 ASP.NET Page 实例都变成了“永世长存”的对象。OnCacheItemRemoved 是一个实例方法,CacheItemRemovedCallback 委托中包含了一个隐式的“this”指针,这里的“this”即为 Page 实例。该委托被添加至 Cache 对象。这样,就会产生一个从 Cache 到委托再到 Page 实例的依赖关系。在进行垃圾收集时,可以一直从根引用(Cache 对象)访问 Page 实例。这时,Page 实例(以及在呈现时它所创建的所有临时对象)至少需要等待五分钟才能被收集,在此期间,它们都有可能被提升至第 2 代。幸运地是,有一种简单的方法能够解决该示例中的问题。请将回调函数变为静态。Page 实例上的依赖关系就会被打破,从而可以像第 0 代对象一样以很低的开销来进行收集。

总结
我已经就 .NET 应用程序中能够导致内存泄漏或内存消耗过度的各种问题进行了讨论。虽然 .NET 可减少您对内存方面的关注程度,但是您仍必须关注应用程序的内存使用情况,以确保应用程序高效正常运行。虽然应用程序被托管,但这并不意味着您可以依靠垃圾收集器就能解决所有问题而将良好的软件工程实践束之高阁。虽然在应用程序的开发和测试阶段,您必须对其内存性能进行持续不断的监视。但是这样做非常值得。要记住,只有让用户满意才称得上是功能良好的应用程序。

from:http://hi.baidu.com/gotosky/blog/item/aa8717d52d246dc251da4ba0.html

内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: 
相关文章推荐