勇哥注:
以下是机译版本,可以对照英文原版看。
对.net程序调试有兴趣童鞋,不要错过这份文档,是非常好看的。
法律信息
这是一份初步文件,可能会在此处描述的软件最终商业发布之前进行重大更改。
本文档中包含的信息代表 Microsoft Corporation 在发布之日对所讨论问题的当前看法。由于 Microsoft 必须响应不断变化的市场条件,因此不应将其解释为 Microsoft 的承诺,并且 Microsoft 无法保证发布之日后提供的任何信息的准确性。
本白皮书仅供参考。MICROSOFT 对本文档中的信息不作任何明示、暗示或法定的保证。
遵守所有适用的版权法是用户的责任。在不限制版权权利的情况下,不得将本文档的任何部分复制、存储或引入检索系统,或以任何形式或通过任何方式(电子、机械、影印、录音或其他)传输,或用于任何目的。目的,未经 Microsoft Corporation 的明确书面许可。
Microsoft 可能拥有涵盖本文档主题的专利、专利申请、商标、版权或其他知识产权。除非 Microsoft 的任何书面许可协议中明确规定,否则提供本文档并不授予您对这些专利、商标、版权或其他知识产权的任何许可。
Ó 2003, 2007微软公司。版权所有。
Microsoft 是 Microsoft Corporation 在美国和/或其他国家/地区的注册商标。
此处提及的实际公司和产品的名称可能是其各自所有者的商标。
内容
概述
CLRProfiler 是一种工具,可用于分析托管应用程序的行为。像任何此类工具一样,它具有特定的优势和劣势。
强调
· CLRProfiler 是一个工具,专注于分析垃圾收集器堆中发生的事情:
o 哪些方法分配哪些类型的对象?
o 哪些对象幸存下来?
o 堆上有什么?
o 是什么让对象保持活力?
· 另外:
o 呼叫图功能可让您查看谁给谁打电话的频率。
o 哪些方法、类、模块被谁引入
· 该工具可以分析应用程序、服务和 ASP.NET 页面。
· 被分析的应用程序可以控制分析:
o 您可以添加也可用作时间标记的注释。
o 您可以打开或关闭分配和呼叫记录。
o 您可以触发堆转储。
· 生成 的日志文件是自包含的——您不需要保存符号文件等以便以后分析日志文件。
· 还有一个命令行界面,允许以批处理模式生成日志文件,并允许您生成文本文件报告。
弱光
· CLRProfiler 是一个侵入式工具;在被分析的应用程序中看到 10 到 100 倍的速度减慢并不罕见。因此,它不是找出时间花在哪里的正确工具——为此使用其他分析器。
· 日志文件会变得很大。默认情况下,每次分配和每次调用都会被记录下来,这可能会消耗千兆字节的磁盘空间。但是,可以通过应用程序或在 CLRProfiler UI 中选择性地打开和关闭分配和调用日志记录。
· CLRProfiler 不能“附加”到已经运行的应用程序。
新版本的变化
自上一个版本以来,发生了很多变化,包括:
· 分析或加载日志文件后,新的“摘要”页面 为您提供有关分析应用程序行为的概览。从摘要页面,您可以打开最流行的视图。
· 现在有更多的命令行选项,使您无需在 GUI 中单击鼠标即可生成简单的报告。这主要用于自动测试。
· CLRProfiler 现在还跟踪 GC 句柄,因此可用于查找 GC 句柄泄漏。
· CLRProfiler 已更新以支持泛型。
· CLR 和 CLRProfiler 之间的接口已得到增强,因此 CLRProfiler 现在拥有关于垃圾收集堆的更准确信息 - 例如,它现在知道代之间的边界在哪里,或者对象何时死亡,之前它必须使用试探法来猜测这些信息。
· 堆图视图现在可以有选择地显示对象实例或实例组的所有引用路径 - 这在跟踪内存泄漏时有时很有用。
· 日志文件格式已得到增强,以传达上述附加信息 - 有关详细信息,请参阅“日志文件格式”部分。
· CLRProfiler 现在也适用于 x64 和 IA64 系统。
· CLRProfiler 对分析 ASP.NET 应用程序和托管服务的支持得到了改进,因此在大多数情况下,即使不在 SYSTEM 帐户下运行,分析也能正常工作。
内部概览
· 该工具使用 CLR 公开的公共分析接口。这些通过加载一个 COM 组件(由“profilerOBJ.dll”实现)来工作,然后在发生重要事件时调用该组件 - 一个方法被调用,一个对象被分配,垃圾收集被触发,等等。
· COM 组件将有关这些事件的信息写入日志文件(名称如“C:\WINDOWS\Temp\pipe_1636.log”)。
· GUI(CLRProfiler.exe - Windows 窗体应用程序)分析日志文件并显示各种视图。
CLRProfiler 用户界面
以下按钮和复选框出现在 CLRProfiler 主窗体上:
· 启动应用程序 会弹出一个打开 对话框,让您启动应用程序。因为您经常需要多次分析同一个应用程序,所以此按钮将在第一次使用后启动同一个应用程序。如果您想分析不同的应用程序,请使用文件/配置文件应用程序 ,如下所述。
· 终止应用程序让您终止您的应用程序。它还会使生成的日志文件加载到 CLRProfiler 中。
· Show Heap 现在 使应用程序执行堆转储并将结果显示为“Heap Graph”,将在后面的部分中讨论。
· 在剖析活跃 复选框允许您打开剖析和关闭选择。您可以这样做以节省时间(例如在应用程序启动期间),或有选择地进行分析。例如,如果您想查看单击某个按钮时 Windows 窗体应用程序中发生的情况,您可以清除此框,启动您的应用程序,然后选中该框,单击您的按钮,然后再次清除该框。另一种用法是在开始分析 ASP.NET 应用程序时关闭它,加载特定页面,然后打开它以查看在稳定状态下为该特定页面分配的内容。
· 该简介:分配 和简介:呼叫 复选框让您关闭某些类型的日志记录。例如,如果您对调用图或调用树视图不感兴趣,您可以通过关闭Profile: Calls使您的应用程序在分析器下运行得更快(并节省大量磁盘空间)。
文件菜单
该文件 菜单非常简单:
· 打开日志文件 让您打开并分析您从早期运行中保存的日志文件。
· 配置文件应用程序 让您启动和配置一个普通的应用程序。
· Profile ASP.NET 允许您启动和分析 ASP.NET 应用程序。
· Profile Service 允许您启动和分析托管服务。
· 将配置文件另存为 可让您保存当前的配置文件以供日后使用。
· 设置参数 让您设置命令行参数和工作目录。
· 退出 (或单击关闭 按钮)让您退出 CLRProfiler。
命令行界面
如上所述,除了使用File 菜单中的Profile Application 命令,您还可以使用命令行开关启动 CLRProfiler 以在“批处理模式”下生成日志文件。要分析它们,您可以交互式地启动 CLRProfiler 并通过Open Log File 命令加载日志,或者您可以从命令行生成简单的文本报告,如后面部分所述。以批处理模式生成日志文件的命令行用法是:
CLRProfiler [-o logName][-na][-nc][-np][-p exeName [args]]
开关的含义如下:
· -o 命名输出日志文件。
· -p 命名要执行的应用程序。
· –na 告诉 CLRProfiler 不要记录分配。
· –nc 告诉 CLRProfiler 不要记录调用
· -np告诉CLRProfiler开始与分析功能,(有用时,该应用程序将关闭分析上有趣的代码段)
您可以通过传递-? 到 CLRProfiler。以下屏幕截图在示例中演示了这一点。
不要被不同选项的数量所拖延 - 其他命令行开关用于从日志文件生成各种文本报告。这将在后面的部分中讨论。
编辑菜单
在编辑 菜单只有一个入口:字体 ,您可以更改用于所有视图的字体。例如,有时需要将字体放大以进行演示,以便最后一行的人可以阅读屏幕上的内容。
Views
该意见可达从视图菜单和内容 菜单需要大量的解释; 以下简单演示应用程序的配置文件演示了几乎所有视图。
演示应用程序是一个字和行计数器,以非常简单的方式用 C# 代码编写,如下所示。
using System; using System.IO; class Demo1 { public static void Main() { StreamReader r = new StreamReader("Demo1.dat"); string line; int lineCount = 0; int itemCount = 0; while ((line = r.ReadLine()) != null) { lineCount++; string[] items = line.Split(); for (int i = 0; i < items.Length; i++) { itemCount++; // Whatever... } } r.Close(); Console.WriteLine("{0} lines, {1} items", lineCount, itemCount); } }
这只是打开一个文本文件 Demo1.dat,初始化行和项目(例如单词)的计数器,然后迭代每一行,将其拆分为多个部分并适当地增加计数器。最后,它关闭文件并报告结果。
演示为这个例子读取的文本文件包含 2,000 行,每行简单地重复“0123456789”十次。因此,有 2,000 行和 20,000 个“单词”。这加起来正好是 222,000 个字符。
编译演示程序(例如使用“csc Demo1.cs”)后,启动 CLRProfiler。
单击“启动应用程序” 按钮或从“文件” 菜单中选择“配置文件应用程序”会打开一个“打开” 对话框,您可以在其中选择 Demo1.exe。
应用程序运行,CLRProfiler 读取结果日志文件(在执行过程中简要显示进度条),并最初显示摘要:
概括
摘要为您提供了有关该程序的一些有趣的统计数据,并允许您通过单击按钮进行进一步调查。这将显示详细视图之一。
标有“堆统计”的部分给出了有关程序对象分配和保留行为的统计信息:
· 分配的字节只是程序分配的所有对象大小的总和。这还包括 CLR 代表程序分配的一些对象。
· 重 定位字节是垃圾收集器在程序运行期间移动的对象大小的总和。这些是在程序运行时被压缩的寿命更长的对象。
· 最终堆字节数是程序运行结束时垃圾收集堆中所有对象的大小之和。这可能包括一些不再被引用但尚未被垃圾收集器清理的对象。
· 最终确定的对象只是最终确定的对象的数量,即实际运行的终结器。这与通过 显式调用其Dispose方法或作为 C# using 语句的一部分来清理对象相反。
· 最终确定的关键对象是上述的一个子类别。.NET Framework 2.0 版允许您将某些终结器标记为特别重要以运行,例如封装重要系统资源的对象的终结器。
标记为“Histogram”(直方图)的按钮会显示已分配、重新定位等对象的直方图视图。
这些直方图的工作方式将在下面更详细地解释,“分配图”、“按年龄划分的直方图”和“对象”显示的视图也是如此按地址”按钮。
标记为“Garbage Collection Statistics(垃圾收集统计)”的部分提供了有关程序运行期间发生的垃圾收集的统计信息。
.NET CLR 中的垃圾收集器是分代的,这意味着许多垃圾收集器只考虑堆上最新的对象。这些被称为第 0 代集合并且速度非常快。
第 1 代收集考虑堆的较大部分,因此速度稍慢,而第 2 代收集(也称为“完整收集”)考虑完整堆,如果堆很大,则可能需要大量时间。
因此,与第 1 代和第 0 代集合相比,您希望看到数量相对较少的第 2 代集合。
最后,“induced collections(诱导收集)”是在垃圾收集器之外触发的收集,例如通过从应用程序调用 GC.Collect 。可通过“Time Line(时间线)”按钮访问的视图将在下面详细说明。
“Garbage Collector Generation Sizes (垃圾收集器代大小) ”一节给出了各种垃圾收集器代的大小。一个额外的变化是有一个特殊的区域用于大型对象,称为“Large Object Heap 大型对象堆”。请注意,这些数字是 程序运行的平均值,可能无法反映运行结束时的情况。
“GC Handle Statistics GC 句柄统计信息”部分列出了已创建、销毁的 GC 句柄数量,以及在程序运行结束时尚存的数量。
如果最后一个数字特别大,您可能有 GC 句柄泄漏,您可以通过单击数字旁边的“分配图”按钮进行调查。
最后,“Profiling Statistics 分析统计”部分总结了与分析运行本身有关的事件:
· “Heap Dumps 堆转储” 仅显示由分析器(通过单击“立即显示堆”按钮)或被分析的应用程序(通过调用 CLRProfiler API 中的 DumpHeap() 方法)触发的堆转储数量。
· “Comments 评论”显示应用程序中的代码(通过调用CLRProfiler API 中的LogWriteLine 方法)添加到日志文件中的评论数量。
可通过按钮访问的“Heap Graph”和“Comments”视图再次解释如下。
除了单击摘要中的按钮之一,您还可以通过从“View” 菜单中进行选择来调出其中一个视图,如下面的屏幕截图所示。
下面的屏幕截图显示了第一个列出的视图,直方图分配类型(也可以通过单击“分配的字节”行上标有“直方图”的按钮从摘要视图访问)的示例。
直方图分配类型
Histogram Allocated Types
在此视图中,条形图出现在左窗格中。每个类别的对象大小都由一个竖条表示,竖条按类型细分,用颜色表示。
解释颜色和提供统计数据的图例出现在右侧窗格中。在这种情况下,统计数据是针对从头到尾运行的完整程序,但您也可以从时间线视图(本文稍后讨论)获得特定时间间隔的相同视图。
该视图提供了几条信息:
· 程序 的总分配量超过 2 兆字节 – 大约是数据文件大小的 10 倍。
· 总数的 50% 以上由两种大小的字符串对象组成 - 小(用于单词)和较大的(用于线条)。
还有一个谜——大黄条(System.Int32 [] 数组)从何而来?
您可以单击黄色栏并选择Show Who Allocated,如下面的屏幕截图所示。生成的图形也将在本文后面显示。但要澄清这个谜团:int 数组是由 String.Split() 的内部工作分配的。
您还可以在此图中执行以下操作:
· 您可以通过单击顶部的单选按钮之一来更改任一方向的分辨率。更改垂直比例只会使条形变高或变小,而更改水平比例会使对象大小类别变小或变大。
· 您可以单击左窗格中的栏之一。这将选择该条,使其变黑,并使所有其他条消失。右侧窗格中发生并行操作。这可以帮助您确定您所指向的类型(如果您有很多类型,颜色可能难以区分),它还允许您调用特定内容的快捷菜单项。
· 您可以单击右窗格中的条目。这将选择该类型,使其变黑,并使所有其他类型变暗。左窗格中发生并行操作。这使得该类型对各种尺寸等级的贡献脱颖而出。如上所述,您可以调用该特定类型的快捷菜单项。
· 您可以将鼠标指针放在左窗格中的特定栏上。这会显示一个包含其他详细信息的工具提示,如下面的屏幕截图所示。
下一节将讨论与直方图分配类型非常相似的视图,称为直方图重定位类型。
直方图重定位类型
什么是重定位类型?它们只是垃圾收集器决定移动的对象。你为什么要了解他们?
好吧,垃圾收集器只会移动对象,如果它们在垃圾收集中幸存下来。因此,一般来说,重定位的对象是那些在垃圾收集中幸存下来的对象。
这不是 1:1 的对应关系——垃圾收集器实际上并没有移动所有幸存的对象——但它足够接近以供使用。(这取决于垃圾收集器决定压缩内存的时间;本文不详细介绍。)
以下屏幕截图显示了 Histogram Relocated Types 视图中的演示应用程序示例。
这里要注意的重要一点是,所有数字都小得多——例如,当程序分配超过 2 兆字节的内存时,垃圾收集器移动的内存不到 20 千字节。
这很好——这意味着垃圾收集器不需要花费大量时间来移动内存。
重定位最多的类型集与分配最多的类型略有不同。虽然字符串在两个集合中都很重要,但我们看到 System.Byte[] 数组更频繁地重定位,而 System.Int32[] 不太经常重定位。因此,我们可能会猜测 System.Byte[] 数组的寿命往往很长,而 System.Int32[] 数组在此特定应用程序中的寿命特别短。
按地址的对象
有时,查看特定时刻堆上实际内容的图片会很有趣。
这就是 Objects by Address 视图提供的内容。默认情况下,您会在程序运行结束时获取堆的状态,但您也可以在 Time Line 视图中选择一个时刻(下面讨论),并获取此时的状态。
此视图可以帮助您对应用程序的实际功能产生一些直觉。一些应用程序运行在不同的阶段,这些通常在堆中反映为由不同对象组成的层。那些看起来不同,你可以在你的堆上做一些“考古”。就像在真正的考古学中一样,底层是较旧的。
以下屏幕截图显示了 Objects by Address 视图中的演示应用程序。
首先,在左侧窗格中,垃圾收集器存储对象的每个连续地址范围都会出现一个垂直条。您通常会看到其中至少两个,因为大对象有一个单独的堆。
在每个条形中,地址从左到右,从下到上增加。条内的每个像素因此对应于特定地址。存储在每个地址的对象类型决定了像素的颜色。与上面讨论的直方图视图类似,颜色与右侧窗格中的各种统计信息一起列出。
顶部的单选按钮可让您控制屏幕上一个像素代表多少字节,以及绘制每个地址范围栏的宽度(以像素为单位)。这对于获取特定地址范围内的广泛概述或详细信息非常有用。
在每个条形的左侧,您会看到列出的堆地址(地址中的点只是为了便于阅读,以防万一),而在右侧,您会看到每个垃圾收集器代的限制。在此示例中,您可以看到第 0 代(最年轻的一代)主要由 System.String 和 System.Int32 [] 对象组成。然而,其中很少有人在垃圾收集中幸存下来并升级到第 1 代。右侧的小条是所谓的大对象堆,从技术上讲,它不是一个代,而是与第 2 代一起收集。它是在下面的屏幕截图中由字母“LOH”表示。
如果您将鼠标指针放在左窗格中的一个栏上,则会出现一个工具提示,为您提供有关对象地址、大小和年龄的详细信息。
在下面的屏幕截图中,选择了左侧窗格中左侧栏的一部分 - 您可以通过拖动鼠标来完成此操作。然后右窗格为您提供有关选择中对象的统计信息。
您还可以右键单击以显示一个快捷菜单,您可以使用该菜单深入了解所选对象的详细信息,如下面的屏幕截图所示。
使用快捷菜单,您可以:
· 找出哪些方法分配了您选择的对象。该展会是谁分配命令得到的只是这些对象的分配图(本文稍后讨论)。
· 按类型和大小获取所选对象的直方图,类似于上面讨论的直方图分配类型视图。
· 获取所有对象的列表作为文本文件(适合导入 Microsoft Excel 的 .csv 文件)。对于每个对象,您可以获得地址、大小、类型、年龄和分配它的调用堆栈。
您还可以单击右侧窗格中的条目。这将选择一种类型,使其在两个窗格中更改颜色。这使您能够看到这种类型的对象在堆中的位置,如下面的屏幕截图所示。
在此示例中,选择了 System.Int32 []。相同的快捷菜单项适用。因此,您可以找出谁在您选择的地址范围内分配了所有 System.Int32 [] 对象。
按年龄分类的直方图
Histogram by Age
此视图允许您查看对象的存活时间。在演示应用程序的情况下,该模式几乎是理想的——在程序启动时分配了一些长期存在的对象,垃圾收集器很快清除了大量生命周期很短的对象,如下面的屏幕截图所示.
类似于上面提到的其他类型的直方图视图:
· 如果将鼠标指针放在左窗格中的特定栏上,您将获得更多信息。
· 您可以单击以选择左侧或右侧窗格中的项目。
· 您可以获得一个快捷菜单,允许您获取有关所选对象的更多信息。
以下屏幕截图显示了选定区域的快捷菜单。
同样,与其他直方图视图一样,您可以更改垂直比例 (KB/像素) 和水平或时间比例 (秒/条)。
分配图
Allocation Graph 视图以图形方式显示哪些对象被分配,以及导致分配的调用堆栈。以下屏幕截图显示了演示应用程序示例的初始结果。
在该图中,调用者位于被调用者的左侧,分配的对象位于分配它们的方法的右侧。盒子的高度和连接它们的线的宽度与分配的总空间成正比(即字节数,而不是对象)。标记为 <root> 的框表示运行程序的公共语言运行时。这表明运行时调用了演示的主程序,该程序又调用了另外两个负责大部分分配的方法,即 String::Split 和 StreamReader::ReadLine。
顺便说一下,为了防止名称过长,CLRProfiler 在许多视图中去掉了前导命名空间和类名称。例如,它不显示System.String::Split 而是只显示String::Split。您可以将鼠标悬停在节点上以发现全名 - 例如,缩写为StreamReader::ReadLine 的 内容实际上是System.IO.StreamReader::ReadLine。
要查看更多内容,您必须向右滚动一点。以下屏幕截图显示了降低的细节级别(使用右上角的一组单选按钮),以便专注于基本信息。
现在您可以看到分配了哪些类型,以及分配它们的方法。例如:
· 许多字符串从 String::InternalSubstring 分配,从 String::InternalSubStringWithChecks 调用,后者又从 String::InternalSplitKeepEmptyEntries 调用。
· 另一种常见模式是 StreamReader::ReadLine 调用 String::CtorCharArrayStartLength(字符串构造函数的辅助函数),它再次分配许多字符串。
· 最后,String::Split 直接分配 Int32[] 数组。
以下屏幕截图演示了此视图中的其他几个有用功能:
· 您可以通过在屏幕上拖动节点来重新排列节点。这有时有助于解开一个复杂的图形——在这个例子中,已经移动了几个框,以便连接它们的线不会相互交叉。
· 您可以将鼠标指针放在节点上并获得更详细的信息——在这种情况下,工具提示显示 String::CtorCharArrayStartLength 的签名,并且它来自 Mscorlib.dll。
· 您可以通过单击来选择节点。这会突出显示节点本身以及通向其他节点的所有线。
快捷菜单为您提供更多可能性:
· 您可以将图修剪到选定的节点(或多个节点)及其调用者和被调用者。这对于简化图形很有用,以防它太混乱。
· 同样,您可以选择所选节点(或多个节点)的调用者和被调用者,也可以选择所有节点。
· 您可以将数据作为文本复制到剪贴板。然后,您可以将信息粘贴到您喜欢的编辑器中。
· 您还可以过滤要显示的节点。在简化复杂图形方面,过滤甚至比修剪更有用;本文稍后将对此进行更详细的讨论。
· 您可以通过名称找到特定的例程。这有其缺陷,因为有时由于细节的抑制,例程不显示。
· 您可以缩放到一个节点,即只显示该节点及其连接的节点。除了使用快捷菜单,您还可以双击节点。
· 您可以找到“有趣”的节点。用于选择这些的算法将它们定义为“具有大量连接的大节点”。
以下屏幕截图演示了修剪后的图。图中剩下的是一个选定的顶点和它直接或间接连接的其他顶点。
您可以通过选择 <root> 节点并 从快捷菜单中再次选择“修剪到调用者和被调用者”来撤消修剪。这将显示 <root> 节点及其连接的所有内容,即所有内容。
有时获取文本输出很有用 - 这就是将文本复制到剪贴板的目的。对于下面的文本输出,StreamReader::ReadLine 被选中并作为文本复制到剪贴板:
System.IO.StreamReader::ReadLine String (): 501 kB (21.99%) Contributions from callers: 501 kB (21.99%) from Demo1::Main static void () Contributions to callees: 60 kB (2.63%) to System.Text.StringBuilder::.ctor void (String int32) 21 kB (0.93%) to System.Text.StringBuilder::Append System.Text.StringBuilder (wchar[] int32 int32) 4.2 kB (0.18%) to System.Text.StringBuilder 4.0 kB (0.18%) to System.IO.StreamReader::ReadBuffer int32 () 412 kB (18.07%) to System.String::CtorCharArrayStartLength String (wchar[] int32 int32)
此输出分为三个部分:
· 首先,例程本身按其完整名称和签名列出,然后是它对程序总分配的贡献。
· 然后,调用者按贡献递减的顺序列出。这不是他们自己分配的,而是他们通过调用选定的例程做出的贡献。
· 最后,列出了所选例程的被调用者。
如果您选择多个节点,您会得到缩写输出:
<root> : 2.2 MB (100.00%) Demo1::Main static void (): 2.2 MB (99.41%) System.String::Split String[] (wchar[] int32 System.StringSplitOptions): 1.7 MB (75.75%) System.IO.StreamReader::ReadLine String (): 501 kB (21.99%) System.String::InternalSplitKeepEmptyEntries String[] (int32[] int32[] int32 int32): 852 kB (37.36%) System.String::CtorCharArrayStartLength String (wchar[] int32 int32): 412 kB (18.07%) System.String::InternalSubStringWithChecks String (int32 int32 bool): 742 kB (32.56%) System.String::InternalSubString String (int32 int32 bool): 742 kB (32.56%) System.String : 1.2 MB (54.45%) System.Int32 [] : 875 kB (38.39%)
“细节级别”仍然适用,因此此输出仅显示在图中也可见的顶点。
如上所述,过滤 对于简化复杂图形很重要。
您可以过滤类型和方法。例如,如果您只想找出谁分配了所有 System.Int32 [] 数组,您可以输入以下屏幕截图中显示的过滤器。
单击“确定”后,您将获得以下屏幕截图中显示的简化图形。
另一方面,如果您想查看直接从 Demo1::Main 分配的所有内容,请使用以下屏幕截图中显示的过滤器。
请注意,Show Callees/Referenced Objects 复选框也已被清除。这会导致视图仅显示直接从 Demo1::Main 分配的对象,而不是从 Demo1::Main 调用的方法分配的所有对象。
在以下屏幕截图中,详细信息级别已更改为 0(所有内容),以便您可以实际看到从 Demo1::Main 分配的所有内容:
System.Char [] 对象实际上是从 String.Split 的重载分配的,它被内联了。IO.StreamReader 对象是由主程序自己分配的,System.Int32 对象是为底部的 Console.WriteLine 语句分配的装箱整数。
为了让您不必为简单的情况手动填写过滤器表单,您还可以选择一个节点并选择Filter to callers & callees。
缩放是另一个对复杂图形有用的有趣功能。假设您发现了一个有趣的方法或类型。在大图中,它所连接的节点可能相距很远。Zoom 允许您快速查看节点的连接 - 您选择该节点并 从快捷菜单中选择Zoom to Node(或者,甚至可以更快地双击该节点)。以下屏幕截图显示了应用于 StreamReader::ReadLine 的缩放功能。
您可以让 CLRProfiler 为您挑选出最有趣的节点,而不是尝试自己理解复杂的图形。如上所述,它选择那些对应于许多分配并且具有许多连接的那些。CLRProfiler 为您找到五个最复杂的节点,并为每个节点打开一个缩放窗口。您可以将这些视为该工具建议您将注意力集中在哪些方面。
以下屏幕截图显示了从演示应用程序的快捷菜单中选择查找有趣节点的结果。
在这个例子中,CLRProfiler 选择主程序作为最复杂的东西。CLRProfiler 打开的下一个窗口显示在以下屏幕截图中。
因此,拆分字符串也很有趣。这是有道理的——如果你想减少这个应用程序分配的内存量,你可能想要完全摆脱拆分字符串。
CLRProfiler 打开的第三个窗口神秘地标记为 <bottom>。这是因为在图的右端有一个虚拟节点,所有类型都连接到该节点。此节点及其连接出于内部目的存在于 CLRProfiler 中,但它们未显示在屏幕上。以下屏幕截图显示了排名第三的窗口。
尽管如此,您可以将此窗口作为查看程序分配的所有类型的提示。
第四个窗口告诉您要特别注意谁分配了所有字符串,如下面的屏幕截图所示。
CLRProfiler 打开的最后一个窗口,在下面的屏幕截图中,实际上并不是很有趣——它只显示了 <root> 节点。
据推测,CLRProfiler 刚刚用完这个小示例的有趣内容。
装配图
函数图
模块图
类图
这四种观点都非常相似。它们允许您查看在哪些程序集、函数、模块或类中提取了哪些方法。
例如,以下屏幕截图显示了演示应用程序的模块图。
这意味着该演示在 Mscorlib.dll 中执行了 82 KB 的代码。大多数被 Demo1::Main 拉入,运行时(<root> 节点)拉入其余部分以初始化一切。
技术说明:报告的数字是由 JIT 编译器翻译的方法的实际机器代码大小(总和)。在 JIT 编译器编译稍有不同的代码以供分析器使用的意义上,它们并不完全准确——它向入口和出口序列添加特殊代码以通知 CLRProfiler 调用堆栈中的更改。因此,报告的数字有些夸大,特别是对于短程。
堆图
Heap Graph
堆图显示垃圾收集堆中的所有对象及其连接。要获得堆图,您需要触发堆转储。您可以通过单击 主 CLRProfiler 窗体中的“立即显示堆”按钮手动执行此操作,也可以通过 CLRProfiler API 从正在分析的应用程序中以编程方式执行此操作。在这两种情况下,都会触发垃圾回收,清除不再需要的任何对象,并生成剩余对象的完整列表。
简单的演示程序不适合演示 Heap Graph 视图,因此以下示例分析 CLRProfiler.exe 本身。以下屏幕截图显示了 Heap Graph 视图中的结果。
该图背后的想法是 <root> 节点代表所有垃圾收集根——静态、活动方法的局部变量、垃圾收集句柄等。
从 <root> 节点,该视图显示与根子类别的连接,例如 GC 句柄、堆栈上的局部变量、终结队列等。
技术说明:finalize 队列引用的对象( 在图中用Finalizer表示)是程序不再可访问的对象,但仍需要运行 finalizer。因为终结器可以复活这些对象,所以它们仍然显示在堆图中,即使它们中的绝大多数即将死亡并且它们的内存即将被回收。
GC 根依次连接到可从垃圾收集根直接访问的对象组。从这些对象可以到达其他对象,依此类推。
在每种情况下,并不是每个对象都单独显示——而是根据它们的“签名”组合在一起,签名由每个对象自己的类型、指向它的类型以及它指向的类型组成。如果您确实想查看单个实例,请 从快捷菜单中选择“显示实例”。签名显示在对象的类型名称下。
与显示图形的其他视图一样,类型名称和签名是缩写的 - 因此,视图仅显示Forms.MenuItem而不是System.Windows.Forms.MenuItem。将鼠标光标悬停在节点上会显示完整的类型名称和签名。
每个框的高度对应于每组对象保持活动的内存总量。与框关联的文本提供了更详细的统计信息,包括该组中有多少对象,以及它们占用的空间有多大,不包括它们指向的对象。例如,在右上角的System.Object []数组下,有文本“47 kB (29.75%) (1 object, 4.0 kB (2.51%))”。这意味着这组对象仅包含一个占用 4 kB 内存的对象(占总数的 2.51%)。此外,该对象引用了另外 43 kB 的对象,因此总数为 47 kB(几乎占总数的 30%)。
为了防止图形变成混乱的线条,堆图视图仅显示从根到每个对象的一条可能路径,而不是所有可能的路径。它选择最小长度之一 - 您可以确定没有更短的,但可以有许多其他长度相同或更长的。
尽管如此,在这种情况下,图表在这种情况下相当混乱和复杂。部分问题是库组件中存在一些与应用程序本身无关的混乱。
以下屏幕截图演示了过滤以将图形缩小到仅以 CLRProfiler 开头的类型,但包括此类类型引用的其他对象。
应用过滤器会得到更简单的图片,如下面的屏幕截图所示。
在这张图片中,<root> 引用了一个 GC 句柄,该句柄又引用了 CLRProfiler.Form1,即 CLRProfiler 的主要形式。
这个对象引用了一个完整的其他列表,包括:
· 三个按钮对象(Forms.Button)。这些是您在主窗体上看到的按钮。
· 两个复选框,加上另一个复选框。它们显示在两个组中,因为其中两个包含在标记为Profile:的组框中。它们与主窗体本身上的Profiling active复选框略有不同。
· 三个菜单项。它们对应于您在主窗体上看到的三个菜单标题:File、Edit 和View。
堆图在许多方面的工作方式与分配图相似——您可以移动节点,可以修剪图,可以复制到剪贴板,可以缩放到节点,还可以找到有趣的节点。
事实上,快捷菜单还有更多可能,如下面的屏幕截图所示。
为堆图启用了以下快捷菜单项(但对于其他类似的图则变暗):
· Show Who Allocated 让您看到哪些调用堆栈分配了(选定的)对象。请注意,堆图中设置的过滤器仍然适用,因此如果您选择的对象不满足过滤器,您可能会得到一个空图。在这种情况下,只需在拥有空分配图后更改过滤器即可。
· 显示新对象 让您可以看到哪些对象现在在堆转储中,而前一个对象不存在。这对于泄漏检测很有用——如果您单击Show Heap now,执行一些操作,然后 再次Show Heap 现在,您可以看到在两个堆转储之间分配的哪些对象仍然处于活动状态。本文稍后将更详细地介绍泄漏检测技术。
· Show Who Allocated New Objects 让您可以看到负责分配新对象的调用堆栈。
· Show Objects Allocated between 和Show Who Allocated Objects between 与通过 CLRProfiler API 设置标记一起有用,并在该部分进一步讨论。
· Show Individual Instances 允许您更改堆图视图使用的分组算法,以便每个对象都有自己的组,让您分别查看每个对象实例。这往往仅在与过滤有关的情况下才有用,否则需要查看的对象太多了。
· 显示直方图 为您提供堆中所有对象的直方图,类似于直方图分配类型和直方图重定位类型下讨论的直方图。直方图遵循您使用Filter...或Filter to Callers & Callers设置的过滤器,此外,如果选择了任何节点,它也仅限于这些节点。
· 如果选择了节点,则启用显示引用。在这种情况下,它显示了从 GC 根到一组对象的所有引用路径。
以下是选择Show Individual Instances时所得到的示例屏幕截图- 如您所见,对象不再组合在一起,而是单独显示:
Show References也很有趣,可以快速演示 - 在这里我选择Forms.CheckBox 项并使用Show References:
这带来了以下观点:
这表明总共有五个对两个Forms.CheckBox 对象的引用:两个直接来自CLRProfiler.Form1,另外两个间接通过Forms.PropertyStore 对象和所有附加到这些对象,最后一个通过Forms.LayoutEventArgs。
您还可以首先打开Show Individual Instances,选择一个单独的对象,然后使用Show References查看其他哪些对象使其保持活动状态。
例如,在单个实例视图中选择Forms.CheckBox对象之一并选择Show References会导致此视图:
为了支持泄漏检测,还内置了另一种机制。Heap Graph 视图会自动保留一定数量的历史记录,并使用该历史记录为节点着色。具体来说,新对象贡献的每个顶点的部分以鲜红色显示,而早期堆转储中已经存在的部分以淡红色显示,以白色结束。这在追踪内存泄漏部分有更详细的说明。
调用图
Call Graph
此视图可让您查看哪些方法调用哪些其他方法以及调用频率。
对于演示应用程序,调用图视图显示在以下屏幕截图中。
调用图中框的高度与方法获得的调用次数或方法及其被调用者最终进行的调用次数成正比,以较大者为准。
在这个例子中,Demo1::Main 得到一个调用(并不奇怪),但它和它的被调用者最终进行了 295,716 次调用。您可能想知道为什么 <root> 节点(即系统)显示更多的调用。原因是,根据右上角单选按钮组中的详细程度设置,上图抑制了一些细节。如果您将详细程度设置为显示所有内容,结果将类似于以下屏幕截图。
因此,在这个例子中,实际上发生了更多的事情,但这完全取决于您希望看到多少细节。
您可以使用此图来检查您对某些例程被调用频率的直觉。例如,对于输入文件中的每一行,StreamReader::ReadLine 被调用一次。在这种情况下,由于输入文件正好有 2000 行,您会期望 StreamReader::ReadLine 被调用 2000 次。事实上,它被调用了 2001 次——最后有一个不成功的调用终止了循环。
调用图和前面讨论的分配图有非常相似的特性——你可以拖动节点,可以选择,可以修剪,可以过滤,可以将数据作为文本复制到剪贴板,可以缩放,可以查找有趣的节点。由于这些功能的行为方式完全相同,因此此处不对其进行任何详细讨论。
时间线
Time Line
此视图向您展示了垃圾收集器堆中随时间变化的情况。对于演示,初始时间线视图显示在以下屏幕截图中。
此视图中的水平轴是时间轴,以秒为单位标记。发生的垃圾收集也有刻度线。在这种情况下,只发生了第 0 代收集并显示为红色。第 1 代和第 2 代集合将分别显示为绿色和蓝色。
纵轴显示地址。它被分成连续的地址范围。与 Objects by Address 视图非常相似,您通常会看到至少两个地址范围——一个用于普通堆,另一个用于大对象堆。在具有更多处理器或应用程序占用大量堆空间的计算机上,您可能会看到更多。
如果垃圾收集器将特定类型的对象存储在特定地址的特定时间,则图表将显示与该时间间隔对应的像素和与该类型对应的颜色的地址。
右窗格包含解释左窗格中使用的颜色的图例。
以下屏幕截图显示了您可以在此视图中执行的更多操作。
您可以使用顶部的单选按钮根据自己的喜好调整垂直和水平比例。
将鼠标指针放在图表中的特定点上将为您提供有关存储在那里的内容、地址和您指向的时间点的更多详细信息。
此外,您可以拖动以选择时间间隔。如果这样做,右侧窗格中的图例会添加有关在该时间间隔内分配的类型的统计信息。
传说中说“估计大小……”是因为这个视图不会跟踪垃圾收集器分配、移动和清理的每个对象——相反,它只是跟踪它们的样本。
以下屏幕截图中显示的快捷菜单可让您了解有关在选定时间间隔内分配的对象的更多信息。
快捷菜单命令分别指向该时间间隔的分配图、直方图分配类型和直方图重定位类型。
将选择设置为标记...允许您将选择设置为应用程序记录的时间标记。
Show Time Line for Selection让您只看到在选定时间间隔内分配的对象的命运。
除了通过拖动鼠标选择时间间隔之外,您还可以通过单击鼠标左键来选择一个瞬间。然后右键单击会显示一个包含不同方法的快捷菜单,如下面的屏幕截图所示。
在这种情况下,Show Who Allocated其含义略有变化 - 不是显示谁在选定的时间间隔内分配了对象,而是显示了谁分配了此时实时存在的对象。
您还可以按地址显示对象、按大小显示的直方图和按年龄显示的直方图视图。
最后,Show Heap Graph可让您调出所选时间点之前最后一次堆转储的 Heap Graph视图。
另请注意,当您及时选择时刻时,右侧窗格会更新以显示当时堆的组成。同样,统计数据是基于堆中对象样本的估计值。
单击右窗格中的类型突出显示该类型并淡化其他类型,因此您可以更轻松地在左窗格中查看该类型的实例,如下面的屏幕截图所示。
注释
此视图显示应用程序通过 CLRProfiler API 记录的评论。在处理此 API 的部分将进一步讨论。演示应用程序未使用此 API,因此禁用该视图。
调用树视图
调用树视图为您提供程序执行的基于文本的、按时间顺序排列的分层视图。
对于演示应用程序,最初此视图并未显示很多内容,如下面的屏幕截图所示。
展开标记为NATIVE FUNCTION (UNKNOWN ARGUMENTS)的节点将 显示以下屏幕截图中显示的视图。
现在,您将更多地了解此视图的工作原理:
· 分配以绿色列出,并给出了它们的类型和大小。
· 方法调用以黑色列出,它们可以展开以显示方法内部发生的事情。
· 对函数的第一次调用以斜体显示。
· 最重要的方法以粗体显示。可以使用 “选项”菜单上的“排序选项”命令来设置“重要”的定义方式。
以下屏幕截图显示了扩展的“Demo1::Main() 节点以供进一步说明。
展开的节点显示了主程序内部发生的事情:
· 分配了一个 System.IO.StreamReader 对象。
· System.IO.StreamReader 的类构造函数被调用。调用是斜体的,因为它是本次运行中的第一次发生。这会导致 17 个进一步调用、10 个对象中的 492 字节分配以及 17 个函数被 JIT 编译。此信息包含在标记为Calls (incl)、Bytes (incl)、Objects (incl)和New functions (incl) 的列中。
· 接下来,System.IO.StreamReader 的构造函数被调用,也是本次运行中的第一次。
· System.IO.StreamReader::ReadLine() 被第一次调用。这是循环的第一次迭代。
· 分配了一个 System.Char[] 对象。这不是在我们演示的源代码中编程的,因此它必须是 String.Split() 的内联副本,在内部执行此操作。
· System.String::Split(wchar[], int...) 第一次被调用。
· 在此之后,解析循环的常规执行开始,每次迭代包括:
o 对 System.IO.StreamReader::ReadLine() 的调用,这会导致另一个调用来分配和构造一个字符串,每次迭代等于 236 字节和一个字符串对象。
o System.Char [] 对象的分配,来自 String.Split() 的内联。
o 对 System.String::Split(wchar[], int...) 的调用,导致总共 141 次调用并分配了 12 个对象,总共 884 字节。
· 这会持续 2000 次迭代。
同时,当您浏览此树时,右侧窗格会显示您如何到达特定点(本质上是调用堆栈),并显示相关的摘要信息,如下面的屏幕截图所示。
当您选择 System.IO.StreamReader::ReadLine 的第二次调用时,您会被告知该函数被调用了 2001 次,它分配了 513,206 个字节,它引起了 8,941 次调用,依此类推。它是从 Demo1::Main 调用的,它只调用了一次。
您可能想知道此视图中的选项卡(标有神秘数字)。每个线程有一个调用树。即使您的应用程序是单线程的,仍然存在终结器线程,其任务是终结对象。以下屏幕截图显示了如果单击终结器线程的选项卡会发生什么。
看看这里实际上非常有指导意义,因为您可以知道在程序运行期间执行了多少终结器。
在查看 菜单呈现出让人感兴趣的摘要信息:
· 所有函数都 提供有关程序执行期间调用的函数的统计信息——它们被调用的频率、它们分配了多少、它们调用了多少其他函数,等等。
· 所有对象都 提供有关分配的对象的统计信息。
以下屏幕截图显示了 “调用树”视图中的“选项”菜单。
在选项 菜单中,您可以自定义调用树视图,以您的需求:
· 选择列 可让您设置要在视图中显示的列。
· 排序选项 可让您确定树的排序方式。默认值是按执行顺序排列,但也可能有许多其他条件。此外,您还可以确定哪些条目以粗体突出显示。
· 过滤 使您可以抑制程序集加载或分配事件。
· 过滤功能 让您缩小调用树的范围以包括或排除某些功能。
· 在堆栈窗口中显示子树 允许您展平特定子树中发生的所有内容。
· 复制堆栈视图 将右窗格中的文本复制到剪贴板,以防您想将信息粘贴到您喜欢的编辑器中。
以下屏幕截图显示了“调用树”视图的快捷菜单。
调用树视图的快捷菜单可让您完成更多任务:
· 你有不同的查找形式——你可以在对话框中输入你的搜索字符串,或者你可以找到对你选择的方法(或对象的另一个分配)的另一个调用(向前或向后搜索)。
· 可以直接在快捷菜单中设置过滤器。之后您必须 从快捷菜单中选择Regenerate Tree才能查看结果。
常见的垃圾收集问题以及它们如何反映在视图中
Common garbage collection problems and how they are reflected in the views
分配过多的程序
Programs that allocate too much
有时问题很简单——你的程序分配了太多内存。如果内存是短暂的,这可能并不明显 - 然后垃圾收集器会快速清理它,并且您的应用程序运行得比它需要的慢。
本节包含一个针对此类问题的简单演示,并将浏览视图并查看问题是如何出现的。
演示应用程序的源代码如下。
// Demo program: building long strings using string type. using System; class test { public static void Main() { int start = Environment.TickCount; for (int i = 0; i < 1000; i++) { string s = ""; for (int j = 0; j < 100; j++) { s += "Outer index = "; s += i; s += " Inner index = "; s += j; s += " "; } } Console.WriteLine("Program ran for {0} seconds", 0.001*(Environment.TickCount - start)); } }
这真的很简单——内循环通过重复追加来构建一个长字符串,然后将字符串包裹在外循环中,这样它就可以运行足够长的时间来进行合理准确的计时。
在 2.4 GHz Opteron 机器上运行这个程序会得到以下结果:
C:\CLRProfiler>string_append.exe
程序运行了 1.141 秒
这并不是很慢,但可以更快吗?
您可以在 CLRProfiler 下运行此演示应用程序并浏览视图。
运行后自动出现摘要视图 - 它看起来像这样:
嗯,这个表格上的一些数字非常极端 - 它说我们有将近 2 GB 的分配,还有超过三千个 0 代垃圾收集。
大量的集合可能是大量分配的结果,所以让我们首先通过单击分配字节行中的直方图 按钮来查看直方图分配类型。
起初并没有出现太多。但视图确实显示了这一点:
· 分配的内存总量几乎是 2 GB。这意味着当程序不在分析器下运行时,它每秒分配的方式超过 1 GB。这实际上是垃圾收集器相当可观的表现。
· 分配的内容几乎完全由字符串组成 – 99.86%。其他一切都可以忽略不计。
以下屏幕截图显示了向右滚动的左窗格。
不仅分配了许多字符串,而且其中许多是长 字符串。这是因为每次附加到字符串时,.NET Framework 都会分配一个更长的字符串并将两个组件复制到其中。
以下屏幕截图显示了 Histogram Relocated Types 视图。
这看起来类似于 Histogram Allocated Types 视图,但请注意总数要小得多 – 大约 15 MB,或者比分配的数量小 100 多倍。
这意味着数据确实是短暂的。
毫不奇怪,在这里您几乎完全处理字符串。
Objects by Address 视图未在本节中显示,因为它在此特定示例中实际上并未进行太多演示。
Histogram by Age 视图确认字符串往往是短暂的,如下面的屏幕截图所示。
事实上,大多数字符串都非常 短暂,正如您在时间分辨率增加时所看到的,如下面的屏幕截图所示。
这是因为虽然分配了大量内存,但垃圾收集器会非常快速地清理它。
以下屏幕截图显示了分配图视图:
这个例子中几乎所有的内存都是从 String::Concat() 的两个重载分配的。
以下屏幕截图显示了时间线视图。
注意指示“gc #3344”的数据,这意味着这个小程序实际上造成了 3,344 次垃圾回收!
以下屏幕截图显示了增加的时间分辨率,以更详细地显示数据。
垃圾收集一直在发生(在分析器下每隔几毫秒,没有它甚至更快)。而且——正如你已经知道的——程序分配的几乎所有东西都是一个字符串。
您可能一直都知道,解决这个特定问题的方法非常简单——在构建长字符串时使用 StringBuilder 类而不是 String。
如果您修改了演示应用程序的源代码来执行此操作,它将类似于以下示例。
// Demo program: building long strings using StringBuilder type. using System; using System.Text; class test { public static void Main() { int start = Environment.TickCount; for (int i = 0; i < 1000; i++) { StringBuilder sb = new StringBuilder(); for (int j = 0; j < 100; j++) { sb.Append("Outer index = "); sb.Append(i); sb.Append(" Inner index = "); sb.Append(j); sb.Append(" "); } string s = sb.ToString(); } Console.WriteLine("Program ran for {0} seconds", 0.001*(Environment.TickCount - start)); } }
这段代码现在看起来不太优雅,但速度要快得多。在前面描述的 2.4 GHz Opteron 计算机上,它打印以下内容:
C:\CLRProfiler>stringbuilder_append.exe
程序运行了 0.156 秒
因此,它比原来快了大约 7 倍——对于这样一个简单的改变来说还不错。
CLRProfiler 使用与演示应用程序的第一个版本相同的视图,应该能够告诉您一些关于这种改进速度是如何产生的。
与往常一样,摘要视图会自动出现:
我们没有分配 1.7 GB,而是减少到大约 20 MB。垃圾收集的数量从超过 3000 次下降到只有 40 次。这两种情况都是 80 倍!
以下屏幕截图显示了在直方图分配类型视图中修改后的演示应用程序的结果。
毫不奇怪,我们仍然分配了很多字符串。现在我们还看到了一些StringBuilder 实例(与字符串数量相比的一些 - 仍然分配了 1,010 个 StringBuilder)。
直方图重定位类型视图显示相同的模式,如下面的屏幕截图所示:
回想一下,之前的总量大约是 30 兆字节——现在它刚刚超过 200 千字节。
Histogram by Age 视图实际上看起来与之前的非常相似,如下面的屏幕截图所示。
这些物体的寿命仍然很短,这并不奇怪。
当然,Allocation Graph 视图现在显示了非常不同的分配方法,如下面的屏幕截图所示。
最后,时间线视图将较小的分配总量反映为较少数量的垃圾回收(40 个而不是 3,344 个),如下面的屏幕截图所示。
以下屏幕截图再次使用增加的时间分辨率向您展示更多细节。
现在您实际上可以看到分配和单个垃圾回收的效果——内存使用量稳步上升,直到垃圾回收开始并再次清理内存,产生非常典型的锯齿状模式。
记忆太久
Holding on to memory for too long
另一个常见问题是保持记忆时间过长。不一定永远(这将是一个泄漏,本文稍后将介绍这种类型的问题),但比真正需要的时间要长得多。
落入这个陷阱的一种流行方式(尽管还有其他方式——缓存、慢速 I/O 等)是使用终结器。终结器必须运行,它仍然需要对象。因此,具有终结器的对象必须至少在一次额外的垃圾回收中幸存下来,即使它不再能从程序中访问。
为了说明本章的内容,以下(有点荒谬)示例使用终结器分配了许多对象。
// 终结器性能风险的演示程序。
// Demo program for the performance perils of finalizers. using System; using System.Drawing; class test { public static void Main() { int start = Environment.TickCount; for (int i = 0; i < 100*1000; i++) { Brush b = new SolidBrush(Color.Black); // Brush has a finalizer string s = new string(' ', i % 37); // Do something with the brush and the string. // For example, draw the string with this brush - omitted... } Console.WriteLine("Program ran for {0} seconds", 0.001*(Environment.TickCount - start)); } }
此示例仅分配了 100,000 个 SolidBrush 对象,并混有一些字符串。它实际上并没有对画笔做任何事情,但尽管如此,观察发生的事情还是很有启发性的。
在前面描述的计算机上,在 CLRProfiler 之外运行这个小程序,会产生以下结果:
C:\CLRProfiler>画笔
程序运行了 0.407 秒
这对于 100,000 次迭代来说还不算太糟糕,但是接下来您可以在 CLRProfiler 下分析程序。
像往常一样,第一个屏幕截图显示了摘要视图:
请注意,与之前的演示程序相比,重新定位的字节现在占已分配字节的百分比要高得多。此外,我们现在拥有大量的第 1 代集合,以及更大的第 1 代大小。
直方图分配类型为我们提供了正在分配哪些类型的概览 - 结果并不令人惊讶:
这里要注意两点:
· 总分配量约为 9 兆字节。
· 程序主要分配SolidBrush(都是一种大小)和String 对象(大小不一);这就是主循环所做的。
现在将其与 Histogram Relocated Types 视图显示的内容进行对比,如下面的屏幕截图所示:
再次强调两点:
· 将近 4 兆字节的对象被重定位——约占总分配量的 40%,远高于您在其他示例中看到的。
· 重新定位的对象几乎都是SolidBrush 类型,这意味着这些对象存活时间更长。
SolidBrush 对象仍然存在意味着它们也被提升到更高的世代。事实上,这就是 Objects by Address 视图显示的内容,如下面的屏幕截图所示。
请注意第 1 代和第 2 代垃圾收集器几乎完全由 SolidBrush 对象组成。
毫不奇怪,SolidBrush 对象的这种存活时间更长的模式也反映在 Histogram by Age 视图中,如下面的屏幕截图所示。
事实上,这里有许多非常老的 SolidBrush 对象(年龄为 2-2.5 秒)在某种程度上是一个意外——碰巧在这次运行中,许多 SolidBrush 对象在运行的早期就设法升级到第 2 代,从那以后就没有第二代系列了。
这并不是什么大问题——垃圾收集器会在某个时候进行第 2 代收集并清除它们。真正的问题是 SolidBrush 对象不断地存活更长时间。您可以通过增加时间分辨率来看到这一点,如下面的屏幕截图所示。
以下屏幕截图显示了本示例的另一个有趣视图,即时间线视图。
在这里,您可以看到双锯齿图案 - 第 0 代系列摆脱了字符串,而画笔则保留了下来。过了一会儿,第 1 代系列负责清理画笔。以下屏幕截图显示了增加的分辨率以更详细地显示此内容。
在此屏幕截图中,选择了以下循环之一:在开始时,有一个第 1 代集合(标记为“gc #12 (gen 1#4)”),之后每个第 0 代集合(标记为“gc #13”) thru “gc #14” ) 去掉了字符串,但压缩了幸存的 SolidBrush 对象。最后,另一个第 1 代集合(“gc #15 (gen 1#5)”)清除了在此期间运行终结器的 SolidBrush 对象。
您可能想知道为什么这个视图显示分配的画笔和字符串的不规则模式,而演示应用程序总是分配一个画笔,然后是一个字符串。这仅仅是因为此视图用于确定用于某个像素的颜色的算法。从概念上讲,视图只是将像素坐标转换为地址和时间,并将像素的颜色基于当时堆中对象的类型和地址。屏幕分辨率有限,因此视图只能显示一个示例。
最后,查看调用树视图,很明显有多少终结器必须运行,如下面的屏幕截图所示。
要获得此视图,您可以单击顶部的线程选项卡,直到找到终结器线程。
请特别注意NATIVE FUNCTION (UNKNOWN ARGUMENTS) 显示为总共触发了 798,370 个调用。换句话说,这是完成所有 SolidBrush 对象所需的方法调用次数。
那么如果你遇到这样的问题怎么办?
这当然并不意味着您完全摆脱终结器。(尽管如果您有无用的 终结器,请摆脱它们。)
相反,您应该做两件事:
· 如果您使用终结器实现对象,请考虑实现Dispose模式。这使对象的用户可以选择尽早清理对象。然后,您的 Dispose 方法应通知垃圾收集器不再需要终结(通过 GC.SuppressFinalize)。终结器仍然存在,以防此类对象的用户忘记调用 Dispose。
· 如果您要分配这样的对象,您应该自己调用 Dispose,或者,如果您使用 C# 编程,则使用 using 语句自动执行此操作,即使在您的代码中间抛出异常也是如此。
在这种情况下,您只是 SolidBrush 对象的用户,因此第二种选择适用。该示例是用 C# 编写的,因此解决问题的一个明显方法是使用 using 语句重写它:
// 终结器性能风险的演示程序。
// Demo program for the performance perils of finalizers. using System; using System.Drawing; class test { public static void Main() { int start = Environment.TickCount; for (int i = 0; i < 100*1000; i++) { using (Brush b = new SolidBrush(Color.Black)) // Brush has a finalizer { string s = new string(' ', i % 37); // Do something with the brush and the string. // For example, draw the string with this brush - omitted... } // After the using statement, Dispose will automatically be called, // thus the finalizer does not have to run. } Console.WriteLine("Program ran for {0} seconds", 0.001*(Environment.TickCount - start)); } }
编译后,首先运行它,看看它是否对速度有任何影响:
C:\CLRProfiler>brush
Program ran for 0.313 seconds
回想一下,之前是 0.407 秒,所以现在快了近 25%。
让我们通过 CLRProfiler 视图再次运行应用程序,以注意发生了什么变化。
摘要视图已经显示出差异 - 重定位的字节数和第 1 代的大小都下降了很多:
分配的字节数保持不变,因此 Histogram Allocated Types 视图也没有改变:
但是,直方图重定位类型视图显示了显着差异 - 请查看以下屏幕截图:
事实上,这里有两个重要的事情需要注意:
· 原来的近 4 兆字节的对象被重新定位,现在减少到大约 11 KB。
· 以前成群结队地幸存下来的 SolidBrush 对象现在甚至不可见。是的,他们确实还在,但他们的贡献已经微不足道了。
Objects by Address 视图反映了相同的效果,如下面的屏幕截图所示。
所以现在大量提升到第 1 代和第 2 代的 SolidBrush 对象刚刚消失。(第 0 代顶部的对象层是由最终的 Console.WriteLine 语句分配的东西 - 请记住,按地址视图对象默认为您提供堆的最终状态)。
毫不奇怪,在以下屏幕截图中,按年龄划分的直方图视图显示 SolidBrush 对象的生命周期要短得多。
事实上,对于这个屏幕截图,时间分辨率几乎已经增加到最大值,并且仍然显示很少的 SolidBrush 对象可以存活。
显然,这种改进也必须显示在时间线视图中,如下面的屏幕截图所示。
下面的屏幕截图再次增加了分辨率,以表明垃圾收集的模式现在确实大不相同。
现在有非常频繁的第 0 代集合。这些清理了几乎所有分配的内容,并且第 1 代和第 2 代集合变得非常罕见。
最后,SolidBrush 的终结器不再运行的事实也反映在调用树视图的以下屏幕截图中。
另请注意,来自终结器线程的调用总数已从近 100 万次下降到仅 53 次。
追踪内存泄漏
您可能听说过垃圾收集器可以消除内存泄漏。
这在某种意义上是正确的——不能再发生的是你分配了一些内存,完全忘记它,永远不会释放它。
垃圾收集器会发现您没有对象的引用,因此它可以清理对象并回收内存。
您 仍然可以做的是分配一些对象,记住某处的引用,但永远不要再次引用该对象。
这可能完全没问题——你的程序可能已经存储了一些东西,仍然需要保持它,但只是还不需要再次引用它。
另一方面,您可能还有一个列表、一个缓存或一个不断增长的数组,它们会记住新信息但从不放弃旧数据。这种情况是另一种内存泄漏,对于长时间运行的应用程序来说确实是一个问题。
这种类型的问题非常重要,以至于 CLRProfiler 内置了特殊的机制来帮助您找出是否存在此类问题,并帮助您查明原因。
为这个演示提供的程序简单地说明了这个问题——它会泄漏,但它只泄漏一点点,您可以在不注意泄漏的情况下使用该程序。
这个程序有点复杂,所以通过几个简单的步骤来解释。
首先,这个例子是关于计算斐波那契函数的。以下代码示例显示了此函数在 C# 中的简单递归实现。
// 斐波那契的递归版本 - 慢。
// Recursive version of Fibonacci - slow. static int Fibo(int i) { if (i <= 1) return i; else return Fibo(i-1) + Fibo(i-2); }
事实上,这个函数是非常递归的,因此非常慢。争论越大,它变得越慢。在前面描述的计算机上计算 Fibo(40) 需要几秒钟,而 Fibo(45) 几乎需要一分钟。而且它越来越慢。
如果你稍微调查一下,你会发现缓慢的原因很简单,因为程序不断地一次又一次地计算相同的部分结果。
因此,加快速度的一种简单方法是缓存结果,如以下示例所示。
// 仍然是递归的,但记住以前的结果 - 快得多。
// Still recursive, but remembers previous results - much faster. static int Memo_Fibo(int i) { int result; if (!fibo_memo.FindResult(i, out result)) { if (i <= 1) result = i; else result = Memo_Fibo(i-1) + Memo_Fibo(i-2); } // This call leaks memory, // because it always adds to the list. fibo_memo.Enter(i, result); return result; }
您可以简单地查看缓存以确定您是否已经使用此参数计算了斐波那契。如果是这样,您只需返回先前的结果;否则,你计算它。当然,如果你计算它,你将它输入到缓存中。
以下示例显示了缓存的工作原理。没什么特别的——一个线性列表,有搜索和输入新信息的方法。
// 列表以记住先前参数和结果的关联。
// List to remember association of previous arguments and results. class ListEntry { int argument; int result; ListEntry next; public ListEntry(int argument, int result, ListEntry next) { this.argument = argument; this.result = result; this.next = next; } public bool FindResult(int argument, out int result) { if (this.argument == argument) { result = this.result; return true; } else if (next != null) { return next.FindResult(argument, out result); } else { result = 0; return false; } } } ListEntry memoList; public Memo() { memoList = null; } void Enter(int argument, int result) { memoList = new ListEntry(argument, result, memoList); } bool FindResult(int argument, out int result) { if (memoList != null) { return memoList.FindResult(argument, out result); } else { result = 0; return false; } }
最后,驱动它的测试代码看起来像下面的例子。
public static void Main() { while (true) { Console.WriteLine("Press any key to continue"); Console.ReadLine(); for (int i = 0; i < 40; i++) Console.WriteLine("Memo_Fibo({0}) = {1}", i, Memo_Fibo(i)); } }
接下来,使用 CLRProfiler 分析此示例。
在 CLRProfiler 下启动应用程序。当它出现时,它会提示您“按任意键继续”。此时,您可以通过单击“立即显示堆”来请求堆转储。
这种观点并没有告诉你太多,也不应该告诉你;测试代码甚至还没有运行,所以这个堆转储只是给你一个基线来与以后的快照进行比较。
按Enter 一次,然后 再次单击Show Heap以获取另一个堆转储,如下面的屏幕截图所示。
该视图仍然没有显示太多内容,即使您可以预期程序的先前结果缓存中有一些新对象。显而易见的是,在上一次堆转储时已经存在的所有内容都以淡红色显示。
新物体以鲜红色显示,但在这种情况下它们的贡献相对较小。
但是,您可以 从快捷菜单中选择“显示新对象”,如下面的屏幕截图所示。
现在有 5.3 KB 的新对象。下面的屏幕截图向右滚动了一点,因此您可以看到它们是什么。
Text.SBCSCodePageEncoding 和 Globalization.NumberFormatInfo 项与 Console.ReadLine 和 Console.WriteLine 语句有关,您现在可以忽略它们。
更有趣的是标记为 Memo.ListEntry 的项目——它们是您用来记住结果的链表元素。由于 CLRProfiler 对对象进行分组的方式,实际上有三个框对应于这些列表元素:一个用于列表中的第一个元素(头元素),一个用于最后一个(尾)元素,一个用于中间的所有元素. 在上面的屏幕截图中,显示了第一个元素和中间的元素,但没有显示尾部元素(有两个原因——它被细节设置抑制,但即使不是,它也不会出现在屏幕上无需向右滚动)。
从某种意义上说,这一切都是空运行。您可以预期第一次运行循环会分配一些列表元素以供记住结果。准确地说,您可以预期大约有 40 个这样的元素——毕竟,测试循环最多运行 40 个。但是,您会看到 114 个加上一个头部元素和一个尾部元素。这表明确实有问题。
技术说明:您可能希望对自己的程序进行类似的计算。尝试为给定的测试用例确定您期望的特定类型对象的实例数量,然后尝试确定您的计算是否与 CLRProfiler 显示的内容一致。
暂时忽略预期的列表元素数量与 CLRProfiler 报告的数量之间的不一致。而是重复循环并让程序再次计算相同的结果。您可能不希望有额外的列表元素,因为不应将新结果输入到缓存中。
不幸的是,您仍然会获得新的列表元素,大约有 40 个,如下面的屏幕截图所示。
要找出这些新元素的来源,请单击图中最右侧的框,选中后,使用快捷菜单中的Show Who Allocated命令来显示分配堆栈跟踪。以下屏幕截图显示了结果。
从这个屏幕截图中,您可以看到 Memo::Memo_Fibo 方法分配了这些对象。以下示例显示了该方法的源代码;如果所有缓存查找都成功,为什么还要分配任何对象?
static int Memo_Fibo(int i) { int result; if (!fibo_memo.FindResult(i, out result)) { if (i <= 1) result = i; else result = Memo_Fibo(i-1) + Memo_Fibo(i-2); } // This call leaks memory // because it always adds to the list. fibo_memo.Enter(i, result); return result; }
问题是fibo_memo.Enter(i, result) 无论调用fibo_memo.FindResult是否 成功都会被执行 。也许程序员打算 Memo::Enter 不输入重复的副本,但为了确保这一点,它必须再次搜索缓存。因此,当程序员实现它时,为了效率而消除检查的负担似乎更好。但是现在,检查重复项的负担在调用方法上,并且没有更新。
您可能想知道为什么Memo::Enter 没有出现在分配图中。Memo::Enter 非常简单,以至于 JIT 编译器实际上可以内联扩展它。
现在也很清楚如何解决内存泄漏-正义之举的声明fibo_memo.Enter(我,结果)的内部如果语句来,如下面的代码示例。
static int Memo_Fibo(int i) { int result; if (!fibo_memo.FindResult(i, out result)) { if (i <= 1) result = i; else result = Memo_Fibo(i-1) + Memo_Fibo(i-2); // This version does not leak memory, // because it only adds to the list // if it does not find the argument. fibo_memo.Enter(i, result); } return result; }
接下来,通过相同的测试运行更正后的程序。
以下屏幕截图显示了第一次测试迭代后的新对象。
之前那个盒子里有 114 个 ListEntry 对象,现在有 38 个。显然内存泄漏也对测试的第一次迭代产生了影响。
以下屏幕截图显示第二次迭代没有产生新对象。
还有其他技术可以找出相同的东西。这里展示的技术是最敏感的,也就是最适合发现小泄漏的技术。
如果泄漏更大,它会以其他方式让自己知道。为了模拟这一点,您可以通过多次迭代来运行不正确的版本。
最明显的泄漏可能是通过时间线视图,如下面的屏幕截图所示。
在这里您可以看到 MemoList 条目堆积在堆上。
在此视图中选择时间间隔并 从快捷菜单中选择“显示分配的人”很容易。您仍将获得完整的分配图(适用于所有类型),但您可以一直向右滚动,单击 Memo.ListEntry,然后右键单击并选择Prune to callers & callees。以下屏幕截图显示了生成的图形。
同样,您会得到一个很好的指向导致泄漏的代码的指针。
另一个好方法是打开 Objects by Address 视图,如下面的屏幕截图所示。
这表明几乎所有的 Memo.ListEntry 对象都集中在第 1 代,因此很容易选择一大堆对象,然后再次选择Show Who Allocated,如下面的屏幕截图所示。
结果将您带回到同一个项目,Memo::Memo_Fibo。
CLRProfiler API
在某些情况下,您希望能够从应用程序内部控制分析。
例如,您可能希望在启动时关闭它,然后在特定例程中打开它。或者您可能希望从应用程序内触发堆转储。或者您可能希望将自己的一些输出放入日志文件中。
所有这些事情都可以通过直接与加载到您的进程进行分析的分析 DLL (ProfilerOBJ.dll) 通信来完成。
为了让它更方便一些,它上面有一个非常薄的管理层——事实上,它的整个源代码可以在下面的示例中显示。(公共方法和属性用红色标记。)
using System;
using System.Runtime.InteropServices;
public class CLRProfilerControl
{
[DllImport("ProfilerOBJ.dll", CharSet=CharSet.Unicode)]
private static extern void LogComment(string comment);
[DllImport("ProfilerOBJ.dll")]
private static extern bool GetAllocationLoggingActive();
[DllImport("ProfilerOBJ.dll")]
private static extern void SetAllocationLoggingActive(bool active);
[DllImport("ProfilerOBJ.dll")]
private static extern bool GetCallLoggingActive();
[DllImport("ProfilerOBJ.dll")]
private static extern void SetCallLoggingActive(bool active);
[DllImport("ProfilerOBJ.dll")]
private static extern bool DumpHeap(uint timeOut);
private static bool processIsUnderProfiler;
public static void LogWriteLine(string comment)
{
if (processIsUnderProfiler)
{
LogComment(comment);
}
}
public static void LogWriteLine(string format, params object[] args)
{
if (processIsUnderProfiler)
{
LogComment(string.Format(format, args));
}
}
public static bool AllocationLoggingActive
{
get
{
if (processIsUnderProfiler)
return GetAllocationLoggingActive();
else
return false;
}
set
{
if (processIsUnderProfiler)
SetAllocationLoggingActive(value);
}
}
public static bool CallLoggingActive
{
get
{
if (processIsUnderProfiler)
return GetCallLoggingActive();
else
return false;
}
set
{
if (processIsUnderProfiler)
SetCallLoggingActive(value);
}
}
public static void DumpHeap()
{
if (processIsUnderProfiler)
{
if (!DumpHeap(60*1000))
throw new Exception("Failure to dump heap");
}
}
public static bool ProcessIsUnderProfiler
{
get { return processIsUnderProfiler; }
}
static CLRProfilerControl()
{
try
{
// If AllocationLoggingActive does something,
// this implies ProfilerOBJ.dll is attached
// and initialized properly.
bool active = GetAllocationLoggingActive();
SetAllocationLoggingActive(!active);
processIsUnderProfiler =
active != GetAllocationLoggingActive();
SetAllocationLoggingActive(active);
}
catch (DllNotFoundException)
{
}
}
}
此代码提供以下内容:
· LogWriteLine 方法将注释放入日志。
· DumpHeap 触发堆转储的方法。
· 一个读/写属性,AllocationLoggingActive。
· 一个读/写属性,CallLoggingActive。
· 只读属性 ProcessIsUnderProfiler。
这很简单,但你可以用它做一些有趣的事情。
请注意,即使在没有分析器的情况下运行,也可以使用所有方法和属性。它们不会做任何事情,但它们也不会使您的应用程序崩溃。
您可以通过使用/target:library 开关调用 csc 将这一小段源代码编译为托管 DLL 。
示例字数统计演示程序已更改以在以下代码示例中利用这一点。
using System; using System.IO; class Demo2 { public static void Main() { StreamReader r = new StreamReader("Demo1.dat"); string line; int lineCount = 0; int itemCount = 0; CLRProfilerControl.LogWriteLine("Entering loop"); CLRProfilerControl.AllocationLoggingActive = true; CLRProfilerControl.CallLoggingActive = true; while ((line = r.ReadLine()) != null) { lineCount++; string[] items = line.Split(); for (int i = 0; i < items.Length; i++) { itemCount++; // Whatever. } } CLRProfilerControl.AllocationLoggingActive = false; CLRProfilerControl.CallLoggingActive = false; CLRProfilerControl.LogWriteLine("Exiting loop"); r.Close(); Console.WriteLine("{0} lines, {1} items", lineCount, itemCount); CLRProfilerControl.DumpHeap(); } }
在编译此代码(将适当的/r: 选项传递给 csc)并在 CLRProfiler 下运行它后,请注意“摘要”表单现在显示两个注释,并且 现在启用 了“视图”菜单上的“注释”命令。当您选择它时,您的屏幕应类似于以下屏幕截图。
这些只是程序输出到日志文件的内容。这很有用;例如,您可以在日志文件中添加关于您测试的场景、软件版本等的评论。
但这并不是您对日志文件注释所能做的全部——它们还在时间线视图中显示为细绿色垂直线,在它们被记录时的正确时间位置。以下屏幕截图显示了时间线视图中的日志文件注释。
当您将鼠标指针放在注释上时,注释的实际文本会显示在工具提示中,如上所示。
您可以在启动此应用程序时关闭分析,因为该应用程序会在适当的时候重新打开它。清除 第一个 CLRProfiler 表单中的Profiling Active复选框并再次运行该程序。以下屏幕截图显示了结果。
第一条绿线之前的运行部分不再显示任何分配,第二条绿线之后的部分也不再显示(在该行之后没有添加新对象,但已经存在的对象仍然存在)。事实上,对于处理分配或调用信息的每个视图都是如此——您现在只会看到实际启用这种日志记录时的分配和调用信息。
这样,您就可以获得应用程序特定部分的调用图或分配图。
由于小演示应用程序还请求了堆转储,这也包含在日志文件中。您可以在 Heap Graph 视图中调出堆图,如下面的屏幕截图所示。
也许您真正感兴趣的是循环是否真的泄漏了任何对象。要检查,您可以询问在“进入循环”和“退出循环”两个注释之间分配的对象,这些对象仍然存在于堆中。
选择快捷菜单项Show Objects Allocated between,如下面的屏幕截图所示。
这又会打开一个选择范围对话框,让您可以及时选择各种时刻,包括标记,如下面的屏幕截图所示。
事实上,事实证明这个小应用程序确实泄漏了:
这很奇怪 - 谁分配了这个System.Byte[] 数组? 在本地菜单上选择Show Who Allocated为我们提供了答案:
System.Char 类型的类构造函数正在分配这个Sytem.Byte[]数组,大概是作为一个查找表来加速进一步的操作。所以我们猜测这只发生在我们演示程序循环的第一次迭代中,但不会发生在后续迭代中。
为确保这一点,我们可以在循环的第一次迭代后向日志文件添加另一个标记,然后调查在该 标记和循环结束之间泄漏的对象。
从命令行生成报告
Producing reports from the command line
我们最初没有考虑过 CLRProfiler 的一个有趣用法是在自动回归测试中。
在这种情况下,您要做的是在 CLRProfiler 下运行应用程序并生成一些报告作为基线。
稍后,您再次运行相同的应用程序,生成相同的报告并将它们与基线报告进行比较。要问的有趣问题可能是:
· 现在分配的总量不一样了吗?
· 现在是否有更多的对象被重新定位?
· 最终的堆大小是否不同?
· 是否有更多的物体存活?
· 在运行中的某个点是否有更多的对象存活?
对于回归测试,我们首先需要命令行参数来告诉 CLRProfiler 运行哪个程序,在哪里写入日志文件等等。
前面已经在命令行界面下描述了此语法:
CLRProfiler [-o logName][-na][-nc][-np][-p exeName [args]]
开关的含义如下:
· -o 命名输出日志文件。
· -p 命名要执行的应用程序。
· –na 告诉 CLRProfiler 不要记录分配。
· –nc 告诉 CLRProfiler 不要记录调用
· -np告诉CLRProfiler开始与分析功能,(有用时,该应用程序将关闭分析上有趣的代码段)
例如,如果我们想运行上一章的 Demo2.exe 应用程序,将结果存储在Demo2.log 中,并从 profiling off 开始,我们将使用以下命令:
C:\CLRProfiler>CLRProfiler -o Demo2.log -np -p Demo2.exe
我们得到以下输出:
CLR 对象分析器工具 - 关闭子进程的分析
UI传输的日志文件名是:Demo2.log
2000 行,20000 项
我们可以使用File/Open Log File...(将 Demo2.log 作为命令行参数传递也可以)将生成的 Demo2.log 加载到 CLRProfiler 中,但我们真的希望在没有人工干预的情况下生成报告。
有一组命令行选项可以生成这样的报告——例如,-a选项生成一个分配报告:
C:\CLRProfiler>CLRProfiler -a Demo2.log
Demo2.log 的分配摘要
类型名称,大小(),#Instances()
总计,2305474,28270
System.String,1264838,22055
System.Int32 [],896000,2000
System.String [],112000,2000
System.Char [],24000,2000
System.Byte [],4376,2
System.Text.StringBuilder,4260,213
这会生成逗号分隔的输出,您可以将其重定向到文件,从而生成适合导入 Excel 的 .csv 文件:
Demo2.log 的分配摘要 | ||
类型名称 | 尺寸() | #Instances() |
累计 | 2305474 | 28270 |
系统字符串 | 1264838 | 22055 |
System.Int32 [] | 896000 | 2000年 |
System.String [] | 112000 | 2000年 |
System.Char [] | 24000 | 2000年 |
System.Byte [] | 4376 | 2 |
System.Text.StringBuilder | 4260 | 213 |
有一个标题行描述了这是什么类型的报告,以及生成它的日志文件。然后你有一个标题行,描述列中的内容 - 它是类型名称、字节总数和为每种类型分配的实例总数。
如果您对两个时间点之间的分配量感兴趣,可以传入-b和-e选项。这些选项的参数要么是日志文件注释(标记)的全文,要么是一个浮点数,它被解释为运行期间的时间(以秒为单位)。例如:
C:\CLRProfiler>CLRProfiler -a -b "进入循环" -e 0.6 Demo2.log
进入循环(0.546 秒)和 0.6(0.6 秒)之间的 Demo2.log 分配摘要
秒)
类型名称,大小(),#Instances()
总计,139928,1659
System.String,74480,1292
System.Int32 [],52864,118
System.String [],6552,117
System.Byte [],4376,2
System.Char [],1416,118
System.Text.StringBuilder,240,12
请注意标题行在这种情况下如何反映附加参数。如果省略-b参数,则默认为运行的开始,类似地,-e参数默认为运行的结束。
其他类型的报告包括:
· -r 重 定位报告:查看垃圾收集器移动了哪些类型的对象。同样,您可以通过-b ?和-e参数将报告限制为一个时间间隔。
· -s 幸存对象报告。这会及时报告堆上的对象(使用可选的-t选项传入,默认为程序运行结束)。这包括垃圾收集器尚未清理的活动对象(仍被引用)和死对象。您还可以传入-b和-e选项,将报告限制为在给定时间间隔内分配的对象。
· -f 终结器报告:查看哪些对象排队到终结器线程。同样,您可以通过传递-b和 ? -e.
· -cf 关键终结器报告:仅查看排队的关键终结器。
· -sd Survivor 差异报告:查看两个时间点堆上对象的差异(通过-b和-e传入)。
· -h 堆转储报告:查看日志文件中记录的堆转储报告了哪些对象。与幸存对象报告的不同之处在于堆转储报告将只记录活动 对象,即那些仍然被引用的对象。不利的一面显然是它依赖于日志文件中存在的堆转储 - 它们必须是通过单击 CLRProfiler 中的Show Heap now按钮手动触发的,或者通过从应用程序调用 CLRProfiler API 中的 DumpHeap() 方法. 与其他类型的报告类似,-b和-e 选项允许您将报告限制为特定时间间隔内的堆转储。
· -c 评论报告:这只是列出日志文件中的所有评论(时间标记)及其时间。如果您使用注释记录有关特定测试运行的信息,则很有用。
一些 CLRProfiler 内部结构
环境变量
为了触发和控制分析,CLRProfiler 将一些环境变量传递给被分析的进程。下表列出了变量以及示例值和说明:
变量和值 | 解释 |
Cor_Enable_Profiling=0x1 | 由 CLR 触发分析。 |
COR_PROFILER = {8C29BC4E-1F57-461a-9B51-1200C32E6F1F} | 要加载的探查器 DLL 的 GUID。 |
OMV_SKIP=0 | 要跳过的初始对象分配数。 |
OMV_FORMAT=v2 | 要写入的日志文件格式的版本。 |
OMV_STACK=1 | 跟踪已分析应用程序的调用堆栈。 |
OMV_DynamicObjectTracking=0x1 | 允许打开和关闭分析。 |
OMV_PATH=C:\WINDOWS\Temp | 指示放置日志文件的位置。 |
OMV_USAGE=两者 | 跟踪分配和调用——其他合法值是“trace”(仅调用)和“对象”(仅分配)。 |
OMV_INITIAL_SETTING=0x3 | 反映配置文件:分配和配置文件:调用复选框的设置。 |
通过设置这些环境变量(除了 OMV_DynamicObjectTracking - 在这种情况下根本不要设置),您实际上可以在不运行 CLRProfiler 的情况下分析应用程序。您还需要在 ProfilerOBJ.dll 上运行 regsvr32。
将 OMV_PATH 设置为您选择的目录。日志文件将在该目录中创建为 Pipe.log。您可以稍后使用 “文件” 菜单上的“打开日志文件”命令将其加载到 CLRProfiler 中。
分析 ASP.NET 应用程序或服务时,CLRProfiler 将这些环境变量放入注册表的以下位置,以防 ASP.NET 应用程序或服务在 SYSTEM 帐户下运行:
· HKLM\SYSTEM\CurrentControlSet\Services\IISADMIN (ASP.NET)
· HKLM\SYSTEM\CurrentControlSet\Services\W3SVC (ASP.NET)
· HKLM\SYSTEM\CurrentControlSet\Services\ ServiceName (服务)
在每种情况下,都会在包含环境变量的位置创建一个注册表值“Environment”。
如果在不同的帐户下运行,环境变量会临时添加到帐户的用户环境变量中,并在应用程序或服务启动后立即删除。
如果 CLRProfiler.exe 在尝试启动 Internet 信息服务 (IIS) 或您的服务时崩溃或被终止,您可能必须删除这些环境变量。
日志文件格式
日志文件是一个简单的面向行的文本文件。每一行都以一个给出其类型的字符开头——有几行描述了函数、类型、分配、调用等。以下示例显示了来自典型日志文件的片段。
f 0 NATIVE FUNCTION ( UNKNOWN ARGUMENTS ) 0 0
h 0 0x01BE12FC 0x00000000 0
h 0 0x01BE11F8 0x00000000 0
n 1 0
h 1884 0x01BE12F8 0x00000000 1
h 1884 0x01BE11F4 0x00000000 1
...
m 0 C:\WIN64\assembly\GAC_32\mscorlib\2.0.0.0__b77a5c561934e089\mscorlib.dll 0x790C0000 1
y 1884 0x00243110 mscorlib
h 1884 0x01BE13FC 0x03811010 1
...
f 2 System.Security.PermissionSet::.cctor static void () 0x027A0070 86 0 1
n 2 0 2
f 3 System.Security.PermissionSet::.ctor void (bool) 0x027A00D8 56 0 2
n 3 4 2 3
f 4 System.Security.PermissionSet::Reset void () 0x027A0120 65 0 3
h 1884 0x01BE13F4 0x03812020 1
f 1 System.AppDomain::SetupDomain void (bool String String) 0x027A0178 312 0 1
...
m 1 C:\WIN64\assembly\GAC_32\mscorlib\2.0.0.0__b77a5c561934e089\sorttbls.nlp 0x027B0000 37
f 59 System.Collections.Hashtable::set_Item void (Object Object) 0x027A3128 47 0 37
...
i 546
z Entering loop
f 188 CLRProfilerControl::set_AllocationLoggingActive static void (bool) 0x027ABF40 61 3 60
f 189 CLRProfilerControl::set_CallLoggingActive static void (bool) 0x027ABF90 61 3 60
f 190 System.IO.StreamReader::ReadLine String () 0x027ABFE0 347 0 60
n 98 4 60 190
! 1884 0x28138e4 103
f 195 System.IO.FileStream::ReadCore int32 (unsigned int8[] int32 int32) 0x027AC560 263 0 100
n 104 16 100 195
...
i 570
t 2 0 System.String
n 114 13 2 236 113
! 1884 0x28148f0 114
n 115 14 114 10
…
以下是对每种类型的线路的简要说明:
· '!' 行描述分配。它们包括:
o 分配线程的 ID。
o 分配对象的地址。
o 调用堆栈的索引('n' 行)描述了被分配对象的类型、大小(以字节为单位)和分配时的调用堆栈。
· 'a' 行就像 '!' 行,但没有线程 ID。它们已经过时了。
· 'b' 行描述了 GC 代的边界。每次垃圾收集的开头和结尾都有一个“b”行。它们包括:
o 初始标志(0 或 1),指示这是 GC 的开始(1)还是结束(0)
o 指示此收集是由 GC (0) 还是应用程序 (1) 触发的标志。
o 正在收集的代 (0..2)
o 正在描述 GC 使用的地址范围。对于每个地址范围,都有以下信息:
§ 范围的起始地址
§ 当前范围的长度
§ 范围的保留长度
§ 该范围所属的GC代
· 'c' 行描述调用。它们由线程 ID 和新调用堆栈的调用堆栈 ID 组成。
· 'e' 行提供有关 GC 根的信息。每个“e”行包括:
o 根引用的对象的地址。
o 根类型(堆栈 = 1,终结器 = 2,句柄 = 3,其他 = 0)。
o 一组标志(Pinning = 1,WeakReference = 2,InteriorPointer = 4,Refcounted = 8)。
o 根 ID。rootID 可能是一个函数索引(即引用“f”行),它可能是一个 GC 句柄的地址,或者它可能只是 0,这取决于所描述的根类型。
· 'f' 行介绍函数。它们包括:
o 函数的 ID(后来用来指那个函数,即调用栈)。
o 函数的名称。
o 函数的签名。
o 函数在内存中的地址和长度。
o 包含它的模块的 ID(参见“m”行)。
o 第一次触及此函数的堆栈跟踪的 ID(参见“n”行)。
· 'g' 行表示垃圾收集。'g' 后面的数字是到目前为止第 0 代、第 1 代和第 2 代集合的计数,包括这个。
· 'h' 行描述了 GC 句柄的分配。它们包括:
o 分配线程的线程 id
o 正在分配的句柄的 id
o 最初存储在句柄中的对象的地址(这主要是零,表示尚未存储任何对象)。
o 负责分配的调用堆栈的调用堆栈 ID。
· 'i' 行表示程序启动后的毫秒数。
· 'j' 行描述了 GC 句柄的释放。它们包括:
o 释放线程的线程 id
o 被释放的句柄的 id
o 负责解除分配的调用堆栈的调用堆栈 ID。
· 'l' 行描述排队到终结器线程的对象。它们包括:
o 指示这是否是关键终结器的标志
o 正在排队的对象的地址
· 'm' 行描述正在加载的模块。它们包括:
o 模块的索引,供以后参考。
o 模块的名称。
o 它被加载的地址。
o 导致模块加载的调用堆栈。
· 'n' 行宣布调用堆栈。第一个数字是一个 ID(稍后用于指代调用堆栈,例如,用于分配)。第二个数字被分成两个标志(位 0 和位 1)和一个计数(通过将数字除以 4 得出)。如果标志和计数都为零,则该行的其余部分只是一个函数 ID 列表,引用 'f' 行。如果标志为零,但计数不是,这意味着当前调用堆栈与另一个调用堆栈具有长度为“count”的公共前缀。接下来列出该调用堆栈的 ID,然后是该堆栈不与另一个堆栈共享的函数 ID。最后,每个标志声明当前调用堆栈或引用的调用堆栈是否包含类型 ID 和大小——这些类型的调用堆栈用于分配。
ø N 10个1 8 16 1装置调用栈号10。这包括类型ID和大小。类型 id 是 8(这是指之前的“t”)。分配的对象大小为 16。实际分配堆栈由单个函数 ID 1 组成(这是指“f”行)。
ø ñ11个7 9 72 10装置:调用栈号11.标志指示为1的计数(因此这是一个前缀这个堆栈股与另一个的长度)。此调用堆栈包括类型 ID 和大小,与其共享前缀的另一个调用堆栈也是如此。类型 ID 是,大小是 72。它共享一个前缀的另一个调用堆栈是调用堆栈 10,它(剥离其类型 ID 和大小)由单个函数 ID 1 组成。
· 'o' 线描述对象。它们用于描述堆转储中的对象。它们包括:
o 所描述对象的地址。
o 它的类型 ID(指的是“t”行)。
o 它的大小(以字节为单位)。
o 此对象引用的其他对象的列表。
· 'r' 行描述根对象。它们用于启动堆转储。它们列出了根对象的地址。可以出现连续的“r”行。'r' 行被提供更多信息的 'e' 行取代。
· 's' 行是描述调用栈的另一种方式。它们已过时,被“n”行取代。
· 't' 行介绍类型。它们包括:
o 类型的 ID(稍后用于指代类型,例如,用于分配)。
o 指示类型是否可终结的标志(如果是,则为 1,否则为 0)。
o 类型的名称。
· 'u' 行描述重定位。这使 CLRProfiler 能够跟踪对象,即使垃圾收集器移动它们。它们包含旧地址、新地址和被移动内存的大小。这总是意味着旧地址范围中的所有对象都已移动到新地址范围。
· 'v' 行描述在垃圾收集中幸存下来但没有被移动的对象。它们类似于 'u' 行,只是它们不包含新地址。
· 'y' 行描述正在加载的程序集。它们由当前线程 ID、程序集的 ID 和程序集的名称组成。
· 'z' 行描述用户评论(通过 CLRProfiler API 记录)。该行的其余部分是注释。
一般来说,日志文件不是供人类使用的——上面的内容只是为了给你一个提示,以防你发现自己的问题只能通过手动查看日志文件来回答,或者如果你想写另一个解析日志文件的工具。
常问问题
以下是一些经常被问到的问题。
问:我可以从我的应用程序控制分析吗?
答:是的,请查看 CLRProfiler API。
问:我的分配图有向后的边——在某些情况下,顶点会导致超过 100% 的分配。
答:这是由递归引起的——一种直接或间接调用自身的方法。CLRProfiler 在一些简单的情况下消除了递归,但并不完全。
问:我的 Objects by Address 视图显示许多竖条,而我的时间线视图显示许多堆 – 这是否值得关注?
答:这可能表明您的应用程序消耗了大量堆空间,这可能是由于泄漏或过度固定所致。但是,在具有多个处理器的计算机上,如果应用程序在服务器 GC 上运行(例如对于 ASP.NET 应用程序),则每个处理器至少有一个或两个堆,因此在这种情况下,这没有多大意义。准确的答案是:将您看到的地址区域的数量除以处理器的数量。如果结果是一两个,就在正常范围内。不过,您可能有机会通过在 Time Line 视图或 Histogram by Age 视图中检查哪些对象在多次垃圾回收中幸存下来来减少堆空间。
问:CLRProfiler 可以附加到正在运行的应用程序吗?
答:没有。
问:我无法分析我的 ASP.NET 应用程序。
A: 尝试在“SYSTEM”而不是“machine”帐户下运行它。完成分析后,请务必将此设置更改回。
问:自从我尝试使用 CLRProfiler 对其进行分析后,我的 ASP.NET 应用程序运行缓慢或无法运行。
A:检查探查器的环境变量是否仍然设置 - 请参阅本文前面的环境变量部分以了解要检查的位置。
问:日志文件变大,我的应用程序在分析器下变得非常慢。
答: 如果您不需要调用图和调用树功能,一般或有选择地清除配置文件:调用复选框。 如果您只对堆转储感兴趣,您也可以清除Profile: Allocations复选框,或者如果您只关心特定时间的分配,则可以有选择地清除。例如,在分析 ASP.NET 应用程序时,很少对 ASP.NET 的启动进行分析——更有趣的是查看请求特定页面时发生的情况。您还可以 有选择地选中或清除Profiling active复选框。
问:CLRProfiler 似乎不适用于我的 64 位应用程序 - 即使应用程序已经启动,显示“等待应用程序启动公共语言运行时”的表单也一直存在。
答:在 64 位操作系统上,您需要确保使用为 x64/IA64 构建的 CLRProfiler.exe 和 profilerOBJ.dll 版本来分析 64 位应用程序。相反,您要确保使用为 Win32 构建的 CLRProfiler.exe 和 profilerOBJ.dll 版本来分析 32 位应用程序。
技术说明:只有 profilerOBJ.dll 特定于 CPU 架构 - CLRProfiler.exe 不是。但是,当您分析应用程序时,CLRProfiler 会加载 profilerOBJ.dll 以将其注册为 COM 组件。
如果 CLProfiler 作为 32 位进程运行,则这不起作用,但 profilerOBJ.dll 是为 x64/IA64 编译的,反之亦然。为避免混淆,最好将不同的风味保存在不同的目录中。
但是,对于分析日志文件,两种风格的 CLRProfiler 都适用于任何一种风格的日志文件。
要更改托管应用程序是作为 32 位还是 64 位进程运行,请使用"corflags /32bit+ myapp.exe" or "corflags /32bit- myapp.exe".

