使用.NET Profiler API检查并优化程序的内存使用

1/5/2008来源:Java教程人气:5313


  运行在 Windows 下面的程序分配内存以便表现所需要的、不同类型的资源。可以将这些分配当作用来封装程序所需要的内存和其他任何资源状态的对象。
  
  应用程序正确运行时,系统将释放被使用的资源和内存,以便让系统中的其他程序使用。但有时候,假如应用程序出现错误,则资源状态或内存(或者这二者)都不会被正确释放,这就会造成资源或内存泄漏。这些错误可能是很难识别的。垃圾回收器 (GC) 负责确保程序所分配的、用于完成任务的内存能够在不需要开发人员关注它的情况下被释放。
  
  对垃圾回收了解得越多,就越能更好地构造程序与之配合使用。.NET 中的对象是从称为托管堆的一片内存中分配来的。堆被描述为托管是因为您向它申请内存后,垃圾回收器会负责执行清理工作。这似乎需要很多开销,因为垃圾回收器必须跟踪在 .NET 公共语言运行库 (CLR) 中所分配的每个对象,但实际上它工作得很有效率。
  
  对象可以是小型对象,可以包含少量整数或更大的数据,也可以包含数据库连接和很多状态信息。对象可以是独立的,也可以在内部包含或使用其他对象。GC 的工作是确定什么时候应当回收对象,以便释放内存供其他程序使用,当它认为它已被装满时就会对可以删除的对象作标记,然后从托管堆中将它们删除。当垃圾回收器试图分配新的对象、却发现托管堆没有更多的可用内存时,垃圾回收器就会认为它已被装满。GC 试图分配内存但确定它已被装满时,它将尝试清理已为您的应用程序分配的某些内存,以便为新对象腾出空间。
  
  GC 以略微不同的方式看待您的对象,并在决定什么时候回收它认为不再有用的对象时考虑到这些对象的差异。它这样做的一个方法是,它有一组根对象,用来确定哪些对象可以被回收。假如对对象的引用大体上属于如下分类中的某一个,则该引用就被看作是根:全局或静态对象指针、线程的堆栈上的所有局部变量和参数对象指针、或包含托管堆中的对象的指针的任何 CPU 寄存器。假如对象的引用是根引用,那么它可能有或可能没有与它关联的、还会在垃圾回收后幸存的子对象。GC 首先找到根对象,然后沿着引用找到被根引用的其他对象,以便避免回收这些对象。
  
  如图 1 所示,托管堆中有四个被分配的对象:(S)mall、(L)arge、(F)inalized 和 (R)eferenced。假设每个对象通过其主要特征(例如,小型对象都不会包含引用或其他组合)来标识自己。在堆中分配这些对象时,它们将相互紧邻地放在内存中。我也有一个位于 (G)lobal 范围的根引用,它包含对 Z 的引用。
  
 使用.NET Profiler API检查并优化程序的内存使用(图一)

  GC 开始垃圾回收时,它首先假设所有对象都是不必要的,直到这些对象被证实是需要的为止。对象基本上通过它“熟悉”谁或引用了谁,或谁引用了它或熟悉它,来证实自己是必要的。对于 GC,根引用为谁熟悉谁提供了起点。GC 从根对象开始沿着对象层次结构检查引用情况,以确定对象是否是可到达的,或是否有可能被另一个对象使用。假如对象被证实是可到达的,则它不是该垃圾回收周期的处理对象。假如对象被证实无法从任何引用到达它,则 GC 将把该对象标记为可回收,然后它会被丢弃。GC 使用“标记和压缩”方法,这意味着一旦 GC 确定对象是垃圾,则 GC 的另一个部分将删除无法到达的对象,并将压缩堆中的空间以确保分配将继续非常快速地进行。
  
  GC 以代的方式看待回收周期中所涉及的对象。每当对象被认为是可到达的时,它就会被提升到下一代。这意味着,引用您的对象的对象越多,或您的对象的操作范围越大,它的存活时间就越长。GC 当前最多有三代,从 0 到 2。第 0 代通常填充较小、短期使用的对象,并且回收它们的次数最多。这意味着,假如您有小型或很少使用的对象,则它们将被频繁地回收。第 1 代和第 2 代是寿命更长和被更频繁访问的对象的储存库,因此被回收的频率更低。GC 中一个基本假设是,您的程序中有更小、寿命更短的对象,更频繁地清理它们对您有好处。理解这一点很重要,因为您设计系统的方式会对您使用多少内存和占用内存多长时间有巨大的影响,这是由于您的工作集将是大型的工作集。内存使用量越大,应用程序性能将降低得越多。
  
  85,000 字节以下的对象被认为是小型对象,并且从托管堆的主要部分直接分配。超过 85,000 字节的对象从托管堆的非凡部分(称为大型对象堆)分配。托管堆对待小型和大型对象的方式有两个主要差异。首先,小型对象在被压缩时将移到托管堆内;而大型对象则不是这样。其次,大型对象总是被当作第 2 代的一部分,而小型对象通常被当作第 0 代的一部分。假如您分配了很多短寿命的大型对象,这将造成第 2 代被更频繁地回收。由于从第 0 代到第 2代越往后的回收成本越高,这将有损应用程序的性能。
  
  我想讨论的垃圾回收的最后一个方面是终结 (finalization) 的概念。当对象被 GC 回收时,终结帮助开发人员释放他们在其对象中使用的资源。对象需要实现 Finalize 方法才能完成该操作。当对象要被销毁时,GC 将调用 Finalize 方法,以便答应对象清理它的内部资源和状态。在 C# 和托管 C++ 中,Finalize 方法实际上伪装在析构函数的语法 (~Object) 中,这里的 Finalize 方法与纯 C++ 中的 Finalize 方法之间的重大差异是,在 C# 和托管 C++ 中,只有当 GC 清理对象时才调用该方法,而在纯 C++ 的析构函数中,当对象脱离范围时才会调用该方法。将 Finalize 方法添加到您的对象中意味着它将总是被 GC 调用,但要小心,因为将 Finalize 方法添加到对象中时,该对象将总是会在对第一代的垃圾回收后幸存下来。因此,所有终结对象的寿命会更长。由于试图让 GC 尽可能有效地执行清理,因此,只有当您有非托管资源需要清理或者在对象创建成本高昂的非凡情况下(对象池),才应当使用终结。
  
  让我们返回图 1 中的原始示例,该示例有一个托管堆,其中包含四个对象和一个根引用。假如在这个时候发生垃圾回收(这是由于这时不满足启动垃圾回收的条件,而开发人员手动干预造成的),结果是 (S)mall 对象将被当作垃圾回收。
  
  大型对象将在该垃圾回收后幸存下来,因为大型对象被指派为第 2 代。被终结的对象被 GC 注重到,并且将调用 Finalize 方法,但是对象本身仍将保留下来,直到进行下一次垃圾回收为止(在某些情形下可能会更长)。包含根引用 G 的对象将保留下来;因为它是根引用,是可到达的。
  
  现在,让我们假设下一次发生的垃圾回收针对的是第 0 到第 2 代(可以通过调用 System.GC.Collect 方法并将 2 作为参数来完成该操作)。(L)arge 对象将在第 2 代清理期间被回收,而 (F)inalized 对象在第 0 代回收期间被回收,这是因为 Finalize 已被调用并且已在回收开始之前结束操作。只有包含全局引用的对象仍然存在,因而会在应用程序生存期内保留下来。
  
  良好的内存使用率
  GC 负责处理内存泄漏,但它不能防止内存保留。作为开发人员,您可以控制您的对象的生存期。假如可以减少应用程序的工作集,则性能将有所提高。假如您的应用程序被设计为有很多对象长时间存活,则可能会有内存泄漏。即使最后清理了内存,仍然会有损性能,所以知道您的对象存活多长时间是值得的。
  
  GC 可以提供很大帮助,但它只能处理我讨论过的一种原始类型的泄漏。资源泄漏仍然是个问题,但假如将非托管资源包装在终结类中,GC 仍然可以帮助您确保正确处置它们。最好对对象实现 Close 或 Dispose 方法,以便在使用完对象时资源可以尽可能早得到清理,而不用等待 GC 来清理它们(在您停止使用对象后,等待 GC 清理它们可能需要很长时间)。假如您对使用非托管资源的类实现了 Finalize,并且正在使用托管堆,则可以相当安全地避免真正的泄漏。当然,这并不意味着您应当让应用程序的工作集很庞大,因为这仍然会有损性能。
  
  PRofiler API 概述
  为了说明应用程序使用了多少内存,以及对象存在了多久,我开发了一个称为 MemoryUsage 的应用程序。MemoryUsage 有两个不同的部分。第一部分编写为 C# 应用程序,它将启动要监视的进程,并在目标进程中设置一个环境变量,以指示 CLR 应当加载 .NET 分析器 (profiler)。第二部分编写为基于 C++ 的 .NET 分析器,该分析器名为 MemProfiler,CLR 将通过环境变量中的信息加载它。.NET 分析器是使用作为 CLR 的一部分提供的 Profiler API 来编写的,它答应分析器作为被监视的进程的一部分运行,并在发生某些事件时接收通知。当应用程序执行时,它为您提供各种通知。为了从 CLR 接收这些通知,您要提供一个 Profiler API 中指定的回调接口 (ICorProfilerCallback),然后,当各种事件发生时,CLR 将调用这个回调接口的方法(参见图 2)。
  
 使用.NET Profiler API检查并优化程序的内存使用(图二)

  下面是需要注重的主要分析器回调方法:RuntimeSuspendStarted、RuntimeSuspendFinished、RuntimeResumeStarted、ObjectAllocated、ObjectsAllocatedByClass、MovedReferences、RootReferences 和 ObjectReferences。
  
  假如不熟悉 Profiler API,可以阅读 Profiler.doc(位于 Visual Studio .NET 安装目录下面的 \FrameworkSDK\Tool Developers Guide\docs 文件夹中),来了解某些更深入的信息。
  
  使用分析器时有几件事情要考虑到,包括线程安全和同步,以及分析器对性能的影响。Profiler API 实际上答应您将它作为 CLR 的一部分运行,这样,因为多个线程将调用您的分析器,所以您必须知道存在同步问题。Microsoft 提供的 Profiler API 规范声明:回调不会被序列化。这就需要由开发人员自己来正确保护他的代码,方法是创建线程安全的数据结构,并在一旦需要防止多个线程并行访问代码时锁定分析器代码。
  
  我需要使对对象跟踪系统以及在我