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


    http://www.microsoft.com/china/MSDN/library/netFramework/netframework/Nfnetprofilerapi.mspx?mfr=true

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

    发布日期: 6/28/2004 | 更新日期: 6/28/2004

    Jay Hilyard

    本文假设您熟悉 C#、C++、COM 和 .NET

    下载本文的代码:NetProfilerAPI.exe(1,492KB)

    摘要:使用 .NET 的开发人员通常将跟踪内存泄漏视为低优先级的任务,因为公共语言运行库会负责垃圾回收工作。但很少有开发人员意识到的事情是,对象的寿命期和它们的大小以及其他对象已实例化的内容都会影响到对对象的清理方式。取决于特定的环境,这些组合可以对性能带来负面影响,尤其是在应用程序的生存期内。本文为开发人员使用 .NET Profiler API 查看内存使用情况并了解垃圾回收提供了一种方式。同时,它还构建了一个用来演示这些原理的示例应用程序。

    *
    本页内容
    良好的内存使用率 良好的内存使用率
    Profiler API 概述 Profiler API 概述
    使用 Profiler API 检测内存使用情况 使用 Profiler API 检测内存使用情况
    分析器调试提示 分析器调试提示
    小结 小结

    运行在 Windows 下面的程序分配内存以便表现所需要的、不同类型的资源。可以将这些分配当作用来封装程序所需要的内存和其他任何资源状态的对象。

    应用程序正确运行时,系统将释放被使用的资源和内存,以便让系统中的其他程序使用。但有时候,如果应用程序出现错误,则资源状态或内存(或者这二者)都不会被正确释放,这就会造成资源或内存泄漏。这些错误可能是很难识别的。垃圾回收器 (GC) 负责确保程序所分配的、用于完成任务的内存能够在不需要开发人员关注它的情况下被释放。

    对垃圾回收了解得越多,就越能更好地构造程序与之配合使用。.NET 中的对象是从称为托管堆的一片内存中分配来的。堆被描述为托管是因为您向它申请内存后,垃圾回收器会负责执行清理工作。这似乎需要很多开销,因为垃圾回收器必须跟踪在 .NET 公共语言运行库 (CLR) 中所分配的每个对象,但实际上它工作得很有效率。

    对象可以是小型对象,可以包含少量整数或更大的数据,也可以包含数据库连接和很多状态信息。对象可以是独立的,也可以在内部包含或使用其他对象。GC 的工作是确定什么时候应当回收对象,以便释放内存供其他程序使用,当它认为它已被装满时就会对可以删除的对象作标记,然后从托管堆中将它们删除。当垃圾回收器试图分配新的对象、却发现托管堆没有更多的可用内存时,垃圾回收器就会认为它已被装满。GC 试图分配内存但确定它已被装满时,它将尝试清理已为您的应用程序分配的某些内存,以便为新对象腾出空间。

    GC 以略微不同的方式看待您的对象,并在决定什么时候回收它认为不再有用的对象时考虑到这些对象的差异。它这样做的一个方法是,它有一组根对象,用来确定哪些对象可以被回收。如果对对象的引用大体上属于如下分类中的某一个,则该引用就被看作是根:全局或静态对象指针、线程的堆栈上的所有局部变量和参数对象指针、或包含托管堆中的对象的指针的任何 CPU 寄存器。如果对象的引用是根引用,那么它可能有或可能没有与它关联的、还会在垃圾回收后幸存的子对象。GC 首先找到根对象,然后沿着引用找到被根引用的其他对象,以便避免回收这些对象。

    如图 1 所示,托管堆中有四个被分配的对象:(S)mall、(L)arge、(F)inalized 和 (R)eferenced。假设每个对象通过其主要特征(例如,小型对象都不会包含引用或其他组合)来标识自己。在堆中分配这些对象时,它们将相互紧邻地放在内存中。我也有一个位于 (G)lobal 范围的根引用,它包含对 Z 的引用。

    a

    图1 托管堆

    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)。


    2 得到通知

    下面是需要注意的主要分析器回调方法: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 规范声明:回调不会被序列化。这就需要由开发人员自己来正确保护他的代码,方法是创建线程安全的数据结构,并在一旦需要防止多个线程并行访问代码时锁定分析器代码。

    我需要使对对象跟踪系统以及在我使用的分析器回调中的其他几项的访问实现同步,以便使信息正确出现,更重要的是,防止线程错误或死锁。

    分析操作对性能的影响将是很大的。由于其具有干扰性,运行基于 .NET 的、使用分析回调的应用程序会使应用程序性能大约降低 10 倍。因为在查找所有选项时所处理的信息量很大(本文随后详细介绍),影响甚至会更大。执行这种分析的目的是要了解发生了什么操作。执行该发现的代价是,当您查找选项时,应用程序会运行得更慢。

    .NET CLR 将通过环境变量 COR_PROFILER 装入仅一个分析器。这意味着,我无法处理已经加载了另一个分析器的项目。一旦为该 CLR 实例建立了分析器,您就会获得一个初始化回调。您可以在这里告诉 CLR 您想要接收什么通知,要这样做,需要调用 ICorProfilerInfo::SetEventMask 方法,并用一个 DWORD 作为参数,在该参数中包含来自 COR_PRF_MONITOR 枚举中的屏蔽值。应当在分析器中设置的标记显示在图 3 中。CLR 将 ICorProfilerInfo 接口传递给分析器,以便允许分析器向 CLR 查询是否有进一步的信息。我的分析器使用了它,以设置事件屏蔽,并基于对象 ID 检索对象的大小、从类 ID 检索类名称以及从对象 ID 检索类 ID。

    分析器实现由 CLR 提供的 ICorProfilerCallback 通知 API,以便准确发现在执行基于 .NET 的应用程序时,运行时中正在发生的事情。我可以在我的分析器中使用这个 API,以便查看某些运行时事件和垃圾回收通知。

    为了看到 GC 在执行垃圾回收时提供了什么通知,我需要注意几个运行时回调。RuntimeSuspendStarted 回调在被调用时将用 COR_PRF_SUSPEND_REASON 枚举值进行传递:

    HRESULT RuntimeSuspendStarted( COR_PRF_SUSPEND_REASON suspendReason )
    

    运行时挂起的原因存储在 suspendReason 中。

    在该上下文中一个需要注意的枚举值是 COR_PRF_SUSPEND_FOR_GC 值。遇到这个值时,意味着应用程序在一个垃圾回收请求之前正在被 CLR 挂起。该请求可以来自调用 System.GC.Collect 方法的托管代码,也可以来自 CLR 自己(当它确定应当运行 GC 时)。这表示我将从 CLR 接收关于被挂起的垃圾回收的回调。

    一旦 RuntimeSuspendFinished 回调被赋予分析器,CLR 将继续执行,并执行 GC 回调:

    HRESULT RuntimeSuspendFinished()
    HRESULT RuntimeResumeStarted()
    

    所有 GC 回调都将在分析器中发生 RuntimeSuspendFinished 和 RuntimeResumeStarted 回调之间的时间内发生。

    既然您知道垃圾回收器开始和结束的时间,就可以看到与内存使用和对象生存期相关的回调,例如 ObjectAllocated:

    HRESULT ObjectAllocated( ObjectID objectID, ClassID classID )
    

    这里,objectID 表示正在被分配的对象的对象标识符,而 classID 代表正在被分配的对象的类标识符。

    每当 CLR 在托管堆中分配对象时,都会调用 ObjectAllocated。C++ 程序员可以将 ObjectID 当作发生移动的这个指针(随后再说这件事)。调用 ObjectAllocated 时,将赋予分析器一个 ObjectID 和一个 ClassID。这两个标记允许您识别已经分配的 .NET 类型的实例。顺便说一句,如果对找出有关该对象的类的信息感兴趣,则可以在回调内使用 ICorProfilerInfo 接口通过以 classId 标记调用 GetClassIDInfo 方法来判断与类型有关的信息。应当知道,即使是很不重要的程序,该回调也会被调用很多次。由于调用它的次数很多,所以 Microsoft 想确保分析器编写者实际上需要这些通知,因此,在分析器初始化期间,您必须为 SetEventMask 调用指定如下标志:

    COR_PRF_ENABLE_OBJECT_ALLOCATED
    COR_PRF_MONITOR_OBJECT_ALLOCATED
    

    如果不同时指定这些标志,就不会获得任何 ObjectAllocated 回调。

    ObjectsAllocatedByClass 提供了足够的信息来判断自从上一次垃圾回收发生后,哪些 .NET 类型已分配了实例,以及每个 .NET 类型已发生了多少次分配:

    HRESULT ObjectsAllocatedByClass( ULONG cClassCount, 
                                     ClassID classIds[],
                                     LONG cObjects[] )
    

    变量 cClassCount 表示自从上一次垃圾回收后被报告为已分配的类的数目,classIds[] 代表这些类的类标识符,cObjects[] 表示为 classIds 数组中相应的 classId 序号所分配的对象实例的数目。

    这是查看对象创建过程的便捷方式,但它和 ObjectAllocated 之间的一个重要差异是,它不跟踪在大型对象堆中分配的对象。

    为了跟踪在托管堆中分配的对象,需要能够在它们移动时跟踪它们。MovedReferences 回调可以提供自从上一次垃圾回收后已经移动的“成批”对象,从而帮助您执行该操作:

    HRESULT Moved References(ULONG cMovedObjectIDRanges,
                            UINT oldObjectIDRangeStart[],
                            UINT newObjectIDRangeStart[],
                            ULONG cObjectIDRangeLength[])
    

    数组参数中的元素计数由 cMovedObjectRefs 表示。它表示在托管堆中发生移动的 ObjectID 范围数。数组 oldObjectIDRangeStart 包含了在被垃圾回收器移动之前原始对象标识符的范围起点,而 newObjectIDRangeStart 是已被垃圾回收器移动之后新对象标识符范围的起点数组。数组 cObjectIDRangeLength 包含被移动对象的 ID 的范围大小。按数组位置,它对应于 oldObjectIDRangeStart 和 newObjectIDRangeStart 数组。

    GC 往往成块地移动在回收操作后幸存下来的对象。这种情况下,MovedReferences 为您提供了在垃圾回收之后发生了移动的对象的范围数、对象的旧对象 ID 数、移动之后对象的新对象 ID 数、以及每个范围中有多少对象的计数。如图 4 所示,首先检查范围内的第一个对象 ID,并检查您感兴趣的对象,以确定它是否位于第一个对象 ID 和第一对象 ID 加上第一个范围的大小之间。如果它是,那么第一个对象 ID 就有了新对象 ID。要确定这个新对象 ID,需要取得您检查的原始对象 ID,再减去范围内第一个旧对象 ID,并加上范围内第一个新对象 ID。重复该过程,获得所跟踪的其余对象 ID。


    4 ID

    作为示例,看一看图 4,该图显示了从对象 2 到 8 的初始托管堆。一旦 GC 已经运行,它就会确定对象 2、4 和 6 是可回收的。这会导致对象 ID 映射从旧对象 ID 更改为新对象 ID。

    图 4 列出了一系列范围,以表示原始对象 ID 是什么,以及在回收操作之后新对象 ID 将是什么。在图 4 中,原始范围是包含原始 ID 为 3 的项目,更改后它的新 ID 是 1。按照这个模式,下一行是原始 ID 为 5 现在的 ID 为 2 的项目。最后,最后一行表示移动了两个项目:开始的原始 ID 为 7(所以移动了 7 和 8)和开始的新 ID 为 4(所以 7 成为 3,8 成为 4)。

    RootReferences 回调将 GC 在垃圾回收期间知道的根引用赋予分析器:

    HRESULT RootReferences( ULONG cRoots, ObjectID objectIds[] )
    

    变量 cRoots 表示垃圾回收器当前知道的根引用数,objectIds 表示根引用的对象标识符。

    RootReferences 可以被调用多次(这取决于在进行垃圾回收时 CLR 认为是“根”的对象数),因为这个数字可以大于 CLR 用来跟踪它们的内部存储。要注意的一个有趣的事情是,在对象 ID 数组中,您可以取回空的 ObjectID。如果您已在堆栈上声明了一个对象引用,就会发生这种情况。

    在垃圾回收已经完成之后,对于在托管堆中剩余的每个对象,都会调用一次 ObjectReferences 回调:

    HRESULT ObjectReferences( ObjectID objectId, 
                              ClassID classId, 
                              ULONG cObjectRefs, 
                              ObjectID objectRefIds[] )
    

    objectId 表示已经报告了其引用的对象的对象标识符。该对象实例的类标识符是 classId。对该对象实例所包含的其他对象的引用数由 cObjectRefs 表示,objectRefIds[] 代表该对象实例所引用的每个对象的对象标识符。

    通过返回将停止对每个对象的回调的、基于错误的 HRESULT (E_FAIL),可以中断该过程,直到下一次垃圾回收。ObjectReferences 为您提供了被作为第一个参数传递的原始对象 ID 所引用的所有对象的对象 ID。然后,您可以看到这两个引用回调以什么方式和什么时候被先后使用,并且分析器可以生成对象调用图,以呈现内存内的对象布局。

    引用是很重要的,因为如果您的对象引用了它,它就会在内存中停留更长时间。为了产生简化的工作集,应当从谁将包含对您创建的对象的引用和包含多久的角度来考虑这些对象。

    使用 Profiler API 检测内存使用情况

    既然了解了使用 GC 和 CLR 的 Profiler API 调用可以执行什么操作,下面让我们逐步分析我创建的 MemoryUsage 示例,以便阐明我在前面谈论过的某些事项。我决定使用这些回调来回答关于在 .NET 下的托管内存使用情况的如下问题:

    在所使用的每个 .NET 类型中,分配了多少个对象实例?

    每个类型的实例的大小是多少?

    当 GC 执行垃圾回收时它提供了哪些通知,您可以发现什么?

    GC 什么时候回收对象实例?

    为了回答这些问题,我创建了一些代码来引导您使用分析器采集有关垃圾回收和内存使用的信息。示例代码(可通过本文开头的链接得到)有三个主要部分:MemoryUsage 应用程序(C# 外壳程序,帮助启动目标进程),C# 控制台应用程序(名为 MemApp,这是要处理的目标应用程序),最后一个是基于 C++ 的 .NET 分析器(名为 MemProfiler)。

    MemoryUsage 只获取您要分析的、基于 .NET 的应用程序的路径,并使用正确的环境变量启动目标进程,这将允许 CLR 加载 .NET 分析器 (MemProfiler)。MemProfiler 负责在目标应用程序运行 (MemApp) 时将信息回收到一个日志中。一旦它结束,MemoryUsage 应用程序就会加载我从 GC 回调那里采集到的内存信息日志,以及我在分析器中设置的对象跟踪(参见图 5)。


    5 生成日志

    MemApp 是简单的 C# 应用程序,它会创建很多对象,然后间歇地强制执行垃圾回收,以便 MemProfiler 得到应用程序状态的通知。MemApp 中有趣的代码位于 MemClass.cpp 中。运行在上图中的这个类称为 MemClass,它包含 Main 方法,但没有其他成员。MemApp 中的 C# Main 函数会在不同情形下分配一批对象,以便以很多不同方式运行垃圾回收器,如图 6 所示。

    MemTarget 类被设计用于说明如果在其他对象内部使用对象引用,可能有很多会导致内存保留的项目。MemTarget 通过让一个数组成员包含对 MemInnerTarget 实例的引用,从而说明这一情形。MemInnerTarget 实例创建于 MemTarget 的构造函数中,然后将引用指派给数组,如图 7 所示。

    MemProfiler 有下面两个主源文件,大多数操作都发生在它们中:MemProfilerCallback.cpp 和 MemState.cpp。MemProfilerCallback 是分析器的 ICorProfilerCallback 接口的实现。MemState.cpp 包含对象跟踪系统的实现,我创建该系统是为了跟踪应用程序中的内存和对象的生存期。我使用两种类型的对象来包含我遇到的信息:ObjectInstance 和 ClassDetail。

    ObjectInstance 包含有关为每个类类型所分配的实际实例的信息,而 ClassDetail 包含有关所使用的 .NET 类型(类)的信息。我将这些对象存储在分别叫作 Objects 和 Classes 的两个 ATL 映射类中。如果您没有使用过 ATL 中的映射类,可以将它基本理解为每个条目都有一个查找密钥的集合。ObjectInstance 用于跟踪我记录的实例的很少几项信息。它包含:对象 ID 和对象的类 ID、该实例所引用的对象的历史、该实例是否是根引用、曾经作为该对象的有效对象 ID 的对象 ID 历史、对象大小以及对象是否被回收过。

    ClassDetail 包含类名、类 ID 和类已进行实例分配的次数。第一次遇到新对象 ID 时,我向跟踪系统添加一个新的 ObjectInstance;第一次遇到类 ID 时,我添加新的 ClassDetail。当我通过 MovedReferences 得到对象 ID 发生更改的通知时,所有 ObjectInstances 都会更新为新的对象 ID,这样,如果对象 ID 被 CLR 重用,它将被正确跟踪。

    因为分析器是 COM DLL,所以首先需要使用 regsvr32 从 MemoryUsage\bin 目录注册 MemProfiler.dll。完成该操作后,为了监视 MemApp 应用程序,请从 MemoryUsage\bin 目录启动 MemoryUsage 应用程序,并从菜单选择 File\Analyze。程序将提示您输入要运行的应用程序的路径,请选择 MemoryUsage\bin 目录,并选取 MemApp.exe。然后将启动 MemApp.exe 并通过正确的环境变量装入 MemProfiler,以便它可以监视 MemApp 的执行。

    为了查看程序所使用的对象的实例,我将使用 ObjectAllocated 回调。一旦我知道我已经分配了对象,我将保存有关该对象及其类型以及实例的大小的信息。我还会保存有关该 ClassID 的信息和它已被分配的次数。我通过调用 ICorProfilerInfo::GetObjectSize 来检索实例的大小,调用时,传递我从回调获得的 ObjectID。这将返回实例的大小(字节),这样我就知道 CLR 已经代表我使用了 n 个字节。通过保留所有这些内容的列表,您可以看出要确定 CLR 从托管堆中使用了多少内存相对地变得多么容易。现在,我知道每个类型分配了多少个实例,以及实例有多大。

    取决于受监视的程序所分配的内容,您可能只看见某些垃圾回收回调,但如果您有一个程序使用足够的内存来创建大量垃圾回收活动,您应当可以全部看见它们。在任何方式下,回调将按如下顺序发生:ObjectAllocated(很多次)、ObjectsAllocatedByClass、MovedReferences、RootReferences 和 ObjectReferences。

    执行应用程序时,请记住我在生成 MemProfiler 时提到的某些事情。如果您要使用在通过 MovedReferences 提供的范围内的对象 ID 来执行任何检查(例如,通过使用 ICorProfilerInfo::GetClassFromObject 和 ICorProfilerInfo::GetClassIdInfo 方法,获得与对象关联的名称),则需要使用旧对象 ID,这是因为 MovedReferences 回调的执行时间发生在对象被实际回收之前。GC 会计算出所有这些对象将在托管堆中如何重新安排,并在实际移动它们之前通知您。虽然 Profiler 文档说,对传递给 ICorProfilerInfo 的项目进行的错误检查可以是最低限度的,但如果向 ICorProfilerInfo 接口提供错误的对象或类 ID,您将立即会看到这会导致某些明显的故障,特此警告。如果是低内存程序,也不会触发 MovedReferences,如果运行 Hello World 类型的程序,那么它不太可能分配足够多的项目,并长时间包含它们,以致于不仅需要进行垃圾回收(可能强制执行)还需要移动可产生 MovedReferences 回调的对象 ID 块。

    您可以获得尚未在 ObjectAllocated 回调中加载的 .NET 类型(类)的类 ID。这意味着,如果您试图使用类 ID 从 ICorProfilerInfo 检索有关该类的信息,您会失败。如果遇到这个情况,通常在 ObjectAllocated 通知之后会出现针对该类的 ICorProfilerCallback::ClassLoad 通知(当相应的程序集加载时),之后,类 ID 就可以有效用于 ICorProfilerInfo。

    ObjectAllocatedByClass 不会跟踪大型对象堆的分配,但 ObjectAllocated 会。通过对这二者发出的通知进行比较,细心的人都应当能够了解到大型对象堆中有什么与正常托管堆相反的内容。

    由 MovedReferences 提供给分析器的信息表不仅指出了哪些对象 ID 已经更改,而且指出在新 ID 节中没有其代表的旧 ID 已被回收了。这就告诉您对象是在哪个垃圾回收(发生在 MovedReferences 回调期间)过程中被回收的。如果没有获得任何 MovedReferences 回调,如何才能知道对象已被回收?实际上,您可以看一看进入 ObjectAllocated 的对象 ID,如果得到重复的对象 ID,请检查类 ID。如果发现类 ID 不同,就可以知道对象实例被回收了,因为 CLR 将重用被回收的对象 ID,以便让其他项目使用。如果它已经移动了对象,它会通知您,而您可能已经处理了该对象。即使类 ID 是相同的,重复的对象 ID 则表示 CLR 将它当成了其他对象,所以您也应当知道对象是否已被回收。


    8 汇总报告

    应用程序应当已经执行完毕,现在可以看到分析器生成的汇总报告(参见图 8)。默认情况下,包含汇总报告的日志文件位于 MemoryUsage 可执行文件的旁边,您可以用不同的编辑器查看结果。如果稍微研究一下默认的汇总报告,就可以看到用来保存结果信息的日志文件的名称、报告的设置以及设置要求报告的事件。在默认情况下,唯一的标志集是 LOG_MEMORY_USAGE_SUMMARY,它指定在该应用程序中分配了哪些对象、实例数和所使用的类的不同个数。默认情况下,我显示运行时回调以给出一小部分上下文,因为您可以看见垃圾回收周期发生。在报告顶部,可以看到该日志文件的路径,以及在生成日志期间启用了哪些选项。图 9 列出了用于报告各种类型信息的标志,在运行目标应用程序之前,使用 MemoryUsage 中的 Tools | Options 菜单可以启用这些标志。

    MemoryUsage 中的 Options 对话框允许控制将报告的信息类型。默认日志文件命名为 output.log,并位于运行 MemoryUsage.exe 的目录中。通过在 Options 对话框中使用 Browse 按钮,并选择新的文件路径,可以将它更改为您喜爱的任何路径。

    分析器跟踪的默认信息包括摘要信息和某些分析器流程通知(运行时、垃圾回收和各种摘要统计信息)。如果想得到更详细的信息(例如,所分配的实际实例、大小、谁引用谁等等),只需启用下面的几个不同选项。

    LOG_OBJECT_ALLOCATED 标志用于启用对通过 ObjectAllocated 回调来报告的具体对象实例的报告。日志中的报告行内容如下所示:

    ObjectAllocated: System.OutOfMemoryException (0x00BA104C)
    

    该报告内容显示:您分配了一个 System.OutOfMemoryException 类的实例,其对象 ID 是 0x00BA104C。

    LOG_OBJECTS_ALLOCATED_BY_CLASS 标志用于启用对 CLR 提供给您的、自从上一次垃圾回收发生后有关所有已分配对象的信息的报告(按类划分)。在日志中,有关这方面的报告部分如下所示:

    ObjectsAllocatedByClass: 98 Classes
        ClassID: 0x79B595C4    Number of Allocations:    1
    
    (Many more of the ClassID lines)
    
    Total Objects Allocated since last GC:    2053
    

    该报告内容显示:每个类分配了多少、每个类的类 ID、该回调代表多少个类以及已分配的对象的总数。

    LOG_OBJECT_REFERENCES 标志用于启用对在 ObjectReferences 回调中设置为可用的信息的报告。基本对象及其引用的报告行内容如下所示:

    ObjectReferences: ObjectID 0x00BA110C    ClassID 0x79B52404    
    Number of references 1    ClassName: System.SharedStatics
    

    这一行显示了对象的类名称、它的对象和类 ID 以及它包含的引用数。

    启用 LOG_REFERENCED_OBJECTS 标志时,将在 ObjectReferences 回调中报告有关对象的实际信息。其中一行将用于所引用的每个对象,它的内容如下所示:

    ObjectReferences: ObjectID 0x00BA110C    ClassID 0x79B52404    
    Number of references 1    ClassName: System.SharedStatics
        References: ObjectID 0x00BA2E08    ClassID 0x79B5FBF4    
    Class System.Security.PermissionTokenFactory
    

    该标志还将启用由 LOG_MEMORY_USAGE_ALL 输出的 ObjectInstance 报告行输出,如下所示:

    ObjectInstance: objectId 0x00BDA530 with classId 0x7B3764FC;
    Size: 36 bytes;
    Allocated prior to GC 1; Survived until GC 4; Referenced 3 objects 
    and was a root reference [NOT COLLECTED]
    
            Referenced Object: objectId 0x00BB6274  count 4
            Referenced Object: objectId 0x00BDA554  count 4
            Referenced Object: objectId 0x00BDAC68  count 4
    

    如果启用 LOG_ROOT_REFERENCES 标志,将以如下方式说明垃圾回收周期的根引用:

    Root Reference: ObjectID 0x00BA1AAC    ClassID 0x02D4106A    Class 
    System.String[]
    

    这将详细列出根引用的对象、类 ID 和类名称。

    如果启用 LOG_MOVED_REFERENCES 标志,它将显示通过 MovedReferences 回调所接收的信息。由于只有当 GC 正在实际压缩和重新分配对象 ID 时才会触发 MovedReferences,所以默认情况下,在像 MemApp 这样的内存较低的应用程序中,它是不会出现的。为了生成图 10 中所示的示例信息,我在 MemApp 目标程序的 MemClass.cs 内 Main 的第一行中,把 MemTarget 数组的原始分配值从 100 项更改为 1000 项。MovedReferences 节将显示与总计数字一起更改的所有 ID 范围。同时还将显示发生移动的每个对象、它的类名称和哪一个表项目导致了该移动。

    默认情况下,LOG_MEMORY_USAGE_SUMMARY 标志是打开的,其输出将表示在执行期间发生的基本事件。您会看见 Initialize 回调后面是一系列运行时回调,还带有我指派给每次垃圾回收的数字。最后的汇总将报告所分配的对象总数、托管堆中使用了多少字节、有关对象的生存期统计信息以及使用了多少个不同的类。图 11 显示默认的摘要日志条目。

    注意,对象是在程序早期分配的(在发生垃圾回收 0 和 1 之前),并在程序中存活到非常晚的时候(垃圾回收 4 或 5)。它指出了我的目标应用程序中的内存保留情况(它应当这样,因为代码是专门为展示该行为而设计的)。

    LOG_MEMORY_USAGE_ALL 标志提供有关所分配的对象的所有细节、使用了每个类的多少个实例、每个实例的大小,以及对象实例的详细信息。下面显示了有关每个对象实例可用的详细信息,例如对象生存期、所创建的实例数和大小:

    ObjectInstance: objectId 0x00BDA530 with classId 0x7B3764FC;
    Size: 36 bytes;
    Allocated prior to GC 1; Survived until GC 4; Referenced 3 objects
    and was a root reference [NOT COLLECTED]
    
    Instance 0x00BA104C  64 bytes  Class System.OutOfMemoryException
    Instance 0x00BA108C  64 bytes  Class System.StackOverflowException
    Instance 0x00BA10CC  64 bytes  Class System.ExecutionEngineException
    
    Class: MemApp.MemTarget (0x0038512C) allocated 106 times
    Class: MemApp.MemInnerTarget (0x003854A8) allocated 1011 times
    

    该 LOG_OUTPUT_DEBUG_STRING 标志告诉分析器不仅要将该信息写入日志文件,而且要通过调用 OutputDebugString 将该信息写入调试流。

    通过在调试器下面运行 MemProfiler.DLL,并生成目标应用程序 MemApp.exe,可以在所生成的汇总报告中观察该信息。这是体验当发生垃圾回收时程序如何工作的好办法。

    分析器调试提示

    我提供的示例应用程序 (MemoryUsage) 执行为分析器设置正确环境的工作。您必须做的所有事情只是运行 MemoryUsage 并选择您的目标应用程序,以便看见摘要输出。如果您想通过在调试器下面运行分析器,以更好地感受分析器通知的工作过程,我将提供几条操作提示。如果您尚未在 .NET 环境下实际尝试创建或使用分析器,我想提供一点建议,它们将使您避免遇到由于在 Visual Studio .NET 下面尝试调试分析器而引起的一些挫折。

    第一件事是为您要使用的 CLR 指定一个分析器。要这样做,需要设置一些 CLR 将查找的环境变量。这些变量指出您的分析器应当被 CLR 使用。

    COR_ENABLE_PROFILING 环境变量用于在 CLR 中打开分析功能,它应当设置为 0x 1,指出您想要进行分析。既然已经表明您的分析意愿,然后必须通过指定 COR_PROFILER 环境变量并将它设置为等于该分析器的 CLSID,来告诉 CLR 应当装入哪个基于 COM 的分析器。

    下面是为我已提供的 MemProfiler 示例启用相应环境的示例:

    SET COR_ENABLE_PROFILING=0x1
    SET COR_PROFILER={04059918-5D57-4068-9FB8-F1AB0B24B3BC}
    

    既然有了正确的环境,然后应当能够执行调试,对吗?这里还有一个要考虑的事情 £- CLR(它将装入您的分析器)存活在哪里。由于您的基于 .NET 的应用程序是从 Visual Studio .NET 启动的,它会将环境一起传递给正在调试的应用程序。这意味着,如果 Visual Studio .NET 没有设置这些环境变量,它就不会将它们传递给您试图分析的、基于 .NET 的应用程序,因此永远不会执行您的初始断点。为什么会这样?Visual Studio .NET 充当了 CLR 宿主,所以,如果您只是一开始设置环境变量,然后就运行 devenv/useenv(以便使用当前环境设置),Visual Studio .NET 将装入您的分析器,并在它的 CLR 实例中监视分析器的操作。这会使调试变得很困难,所以我在启动之后添加环境变量,以便它们只是传递给正在调试的进程(在这里是 MemApp.exe)。

    为了确保您有正确的环境,应当使用如下步骤:

    1.

    打开 Visual Studio .NET 命令提示符(从 Visual Studio .NET Tools 子菜单打开)。

    2.

    运行如下命令,以启动 Visual Studio .NET:

    devenv
    

    3.

    使用 Task Manager 获得 devenv 进程的进程 ID。

    4.

    使用如下命令行运行 GSET 程序(在示例代码的 bin 目录中):

    Gset [devenv process id]
    

    GSET 是我在 NuMega 实验室的同事 (Yoshi Watanabe) 为修改已在运行的进程的环境而编写的工具。运行它时,您将看见进程的当前环境(请参见图 12)。可以使用 New 按钮将如下环境变量添加到开发环境中:

    COR_ENABLE_PROFILING      0x1
    COR_PROFILER        {04059918-5D57-4068-9FB8-F1AB0B24B3BC}
    

    5.

    加载 MemUsage.sln 解决方案,并将 MemProfiler 的调试目标更改为指向 \bin\Debug 目录中的 MemApp.exe。确保 MemProfiler 是启动项目。

    这将以正确的环境设置来设置 Visual Studio .NET IDE。现在,您用分析环境变量正确设置了调试环境,所以可以通过分析器调试了。


    12 修改进程

    此时,我会建议您在 Initialize 回调中放一个断点,并运行您想分析的应用程序。您应当在该断点处停下来,并且执行检查和实现很多很好的分析功能。

    小结

    在本文中,我想调查每个类型有多少个实例正在被使用,以及这些实例的大小,所以我跟踪了我在 ObjectAllocated 回调中看见的对象,然后使用 ICorProfilerInfo::GetObjectSize 调用来获得实例大小。通过保存我接收到其 ObjectAllocated 回调的每一项的该信息,在程序末尾,我就能够在汇总报告中显示这些信息。

    通过使用很少的运行时回调(具体是 RuntimeSuspendStarted、RuntimeSuspendFinished 和 RuntimeResumeStarted),我可以在它执行垃圾回收时看见垃圾回收通知的顺序。通过查看垃圾回收器回调,然后我可以查找为每个 .NET 类型分配的对象的统计信息、哪些对象在给定时间被当作根引用、哪些对象在给定时间引用了其他对象、以及最后实例在什么时候被垃圾回收器回收。应用程序所涉及的内存不只是在托管堆中的这一部分,还有更多,但知道垃圾回收如何工作将帮助您开发出更瘦、更有效的应用程序。

  • 相关阅读:
    时间操作
    2021.10.9数据结构实验课作业
    2021.9.28数据结构实验课作业
    2021.9.15数据结构实验课作业
    2021.9.7数据结构实验课作业
    苹果ios开发,CocoaPods安装方法-2021/9/11
    写组件库文档常用的技术
    document.execCommand 的用法
    手写JSON.stringify
    Object常用方法
  • 原文地址:https://www.cnblogs.com/xiaoL/p/1888840.html
Copyright © 2020-2023  润新知