UML软件工程组织

在托管代码中重新发现丢失的内存优化艺术
作者:Erik Brown
本页内容
类型大小调整 类型大小调整
单元素 单元素
池机制 池机制
数据流 数据流
性能监视 性能监视
CLR 分析器 CLR 分析器
小结 小结

内存是所有程序都需要的一种资源,然而明智的内存用法正在变成丢失的艺术。为 Microsoft ® .NET Framework 编写的托管应用程序依靠垃圾回收器来分配和清理内存。对于很多应用程序而言,花费 3% 到 5% 的 CPU 时间来执行垃圾回收 (GC) 是一个公平的折衷方案,这样就无须担心内存管理问题。

但是,对于 CPU 时间和内存都是宝贵资源的应用程序而言,尽量减少花费在垃圾回收方面的时间可以大大提高应用程序的性能和健壮性。如果应用程序可以更有效地使用可用内存,则垃圾回收器的运行频率就会降低,并且运行的时间也会缩短。因此,请不要在应用程序中考虑垃圾回收器做什么或者不做什么,而要直接考虑内存用法。

大多数生产计算机都具有数量巨大的 RAM,并且从全局来看,诸如使用短整数而不是常规整数之类的优化可能似乎没有多大意义。在本文中,我将改变您的看法。我将考察类型大小调整、各种设计技术以及如何分析程序的内存利用。我的示例将重点讨论 C#,但是该讨论同样适用于 Visual Basic ® .NET、托管 C++ 以及您能够想到的其他任何面向 .NET 的语言。

我假设您了解有关垃圾回收工作方式的基础知识,包括相关的概念,如生成、处置模式和弱引用。

类型大小调整

内存用法最终取决于程序中的程序集所定义和使用的类型,因此让我们首先分析一下系统中各种类型的大小。

图 1

图 1 显示了 System 命名空间中定义的核心 .NET 值类型的大小(字节),以及它们等效的 C# 类型。我使用不安全的代码和 C# sizeof 运算符来验证这些值类型在托管内存中的大小。对于其中一些类型(包括 bool 和 char),使用 Marshal.SizeOf 方法而不是 sizeof 运算符会产生不同的值,这是由于 Marshal.SizeOf 计算封送处理类型的非托管大小,并且这些类型不是直接复制到本机结构中的(这意味着它们在托管代码和非托管代码之间传递时,可能需要转换)。稍后将对此进行详细讨论。

结构(值类型)的大小被计算为其字段大小的总和,外加由于将这些字段与其自然边界对齐而增加的任何开销。引用类型的大小是其字段大小向上舍入到下一个 4 字节边界,外加 8 字节的开销。(要了解您的引用类型使用多少空间,您可以度量堆大小在分配它们时的变化,或者可以使用稍后讨论的 CLR 分析器工具。)这意味着所有引用类型都至少占用 12 字节,因此在 C# 中,长度小于 16 字节的任何对象作为结构可能更有效一些。当然,如果您需要存储类型的引用,则结构会有问题,因为频繁的装箱可能耗尽内存和 CPU 周期。因而,谨慎地使用结构是很重要的。

由于字段对齐可能影响类型的大小,因此类型内部的字段组织在其最终大小方面扮演重要的角色。类型的布局以及具有该布局的字段的组织受到应用于类型的 StructLayoutAttribute 的影响。默认情况下,C#、Visual Basic .NET 和 C++ 编译器都将 StructLayoutAttribute 应用于结构,以指定 Sequential 布局。这意味着字段按照它们在源文件中的顺序布置在类型中。但是,在 .NET Framework 1.x 中,对 Sequential 布局的请求不会被即时编译器 (JIT) 遵守,即使该请求是由封送拆收器提出的。在 .NET Framework 2.0 中,JIT 确实为值类型的托管布局实施了 Sequential 布局(如果指定的话),尽管前提是没有引用类型字段成员。因而,在下一个版本的 Framework 中,类型的大小调整可能会更加重要。在所有版本中,对 Explicit 布局(其中,由开发人员指定每个字段的字段偏移量)的请求同时被 JIT 和封送拆收器遵守。

我之所以进行这一区分,是因为类型的封送布局通常与该类型的堆栈或 GC 堆布局不同。封送类型的布局必须与它的非托管等效类型的布局匹配。但是,托管布局只在由 JIT 编译的托管代码中使用。因此,JIT 能够基于当前平台优化托管布局,而无须关注外部依赖项。

请考虑以下 C# 结构(为简单起见,我已经避免为下列成员指定任何访问修饰符):

struct BadValueType
{
    char c1;
    int i;
    char c2;
}

就像非托管 C++ 中的默认封装一样,整数在四字节边界上布局,因此尽管第一个字符使用两个字节(托管代码中的 char 是 Unicode 字符,因而占据两个字节),但该整数向上移动至下一个 4 字节边界,并且第二个字符使用随后的 2 个字节。得到的结构在用 Marshal.SizeOf 度量时是 12 个字节(当用在我的 32 位计算机上运行的 .NET Framework 2.0 上的 sizeof 度量时,也是 12 个字节)。如果我将其重新组织为如下所示的结构,则对齐方式将如我所愿,从而得到 8 字节结构:

struct GoodValueType
{
    int i;
    char c1;
    char c2;
}

另一个值得注意的问题是较小的类型使用较少的内存。这似乎显而易见,但是很多项目使用标准整数或十进制值,即使并不需要它们。在我的 GoodValueType 示例中,假定整数值永远不会大于 32767 或小于 -32768,我可以通过使用短整数进一步降低该类型的大小,如下所示:

struct GoodValueType2
{
    short i;
    char c1;
    char c2;
}

正确地对齐该类型和进行大小调整可以将其从 12 字节减小到 6 字节。(Marshal.SizeOf 会为 GoodValueType2 报告 4 字节,但那是因为 char 的默认封送处理是作为 1 字节值进行的。)如果您予以关注的话,您会对结构和类的大小可以降低的幅度感到惊讶。

正如前面提到的那样,非常重要的一点是意识到结构的托管布局可能与非托管布局极为不同,尤其是在 .NET Framework 1.x 中。封送处理的布局可能不同于内部布局,因此在使用 sizeof 运算符时,我已经描述的类型可能(实际上是非常可能)报告不同的结果。例如,我迄今为止已经说明的全部三个结构在 .NET Framework 1.x 中都具有 8 字节的托管大小。您可以使用不安全的代码和指针运算,通过 JIT 分析其中一个类型的布局:

unsafe
{
    BadValueType t = new BadValueType();
    Console.WriteLine("Size of t: {0}", sizeof(BadValueType));
    Console.WriteLine("Offset of i:  {0}", (byte*)&t.i - (byte*)&t);
    Console.WriteLine("Offset of c1: {0}", (byte*)&t.c1 - (byte*)&t);
    Console.WriteLine("Offset of c2: {0}", (byte*)&t.c2 - (byte*)&t);
}

在 .NET Framework 1.x 中,运行该代码会产生以下输出:

Size of BadValueType: 8
Offset of i:  0
Offset of c1: 4
Offset of c2: 6

然而,在 .NET Framework 2.0 中,相同的代码将产生以下输出:

Size of BadValueType: 12
Offset of i:  4
Offset of c1: 0
Offset of c2: 8

尽管较新版本的 Framework 增加该类型的大小似乎是一个倒退,但 JIT 现在遵守指定的布局实际上是令人期待的行为,并且是一件好事情。如果您宁愿让 JIT 自动确定最佳布局(从而产生与 1.x JIT 当前生成的输出相同的输出),则可以用 StructLayoutAttribute 显式标记您的结构,以指定 LayoutKind.Auto。只是需要记住,对于在 .NET Framework 1.x 上运行的不与非托管代码进行任何互操作的纯粹托管应用程序而言,通过对字段进行手动排序以获得更好的对齐方式而努力节约的内存可能是难以捉摸的。

图 2

图 2 说明了其他一些注意事项。显示的 Address 类表示美国地址。该类型为 36 字节长:每个成员 4 字节,外加与它的引用类型开销相对应的 8 字节(请注意,C# 中的 sizeof 运算符只适用于值类型,因此我再次依赖于 Marshal.SizeOf 所报告的值)。对向医生和医院进行的支付行为进行管理的大型医疗应用程序可能需要同时处理成千上万个地址。在这种情况下,最大限度减小该类的大小可能很重要。该类型内部的排序还不错,但是请考虑 AddressType(参见图 2)。

图 3

尽管默认情况下枚举被存储为整数,但您可以指定要使用的整型基类型。图 3 将 AddressType 枚举定义为短整型。同时,通过将 IsPayTo 字段更改为 byte,我已经将每个 Address 实例的非托管大小减小了 10% 以上(从 36 字节到 32 字节),并且至少将托管大小减小了 2 字节。

最后,字符串类型是引用类型,因此每个字符串实例都引用一个附加的内存块,以存放实际的字符串数据。在 Address 类型中,如果我忽略各种美国其他类型的领土,则 state 字段具有 50 个可能的值。这里,使用枚举可能值得考虑,因为它可以消除对引用类型的需要,并且将值直接存储到类中。枚举的基类型可以是 byte 而不是默认的 int,从而导致字段需要 1 字节而不是 4 字节。尽管这是一种可行的替代方案,但它确实会使数据显示和存储复杂化,因为每次访问或存储整数值时,都必须将其转换为用户或存储机制能够理解的某种形式。这种情况揭示了计算方面的更常见的折衷方案中的一种:用速度换取内存。以一些 CPU 周期为代价来优化内存用法通常是可能的,反之亦然。

这里,一种替代选择是使用 Interned 字符串。CLR 维护一个名为“Intern pool”的表,该表包含程序中的文字字符串。这可以确保在代码中重复使用相同的常量字符串时,可以利用相同的字符串引用。System.String 类提供了 Intern 方法,以确保字符串位于“Intern pool”中,并且返回对它的引用。图 3对此进行了说明。

在我结束对类型大小调整的讨论之前,我还希望提一下基类。派生类的大小等于基类加上派生实例定义的其他成员(以及对齐所必需的任何额外空间 — 如前所述)的大小。因此,派生类型中未使用的任何基本字段都会浪费良好的内存。基类可以很好地定义常见功能,但是您必须确保所定义的每个数据元素都是真正需要的。

接下来,我将讨论一些用于有效管理内存的设计和实现技术。程序集需要的内存主要取决于该程序集所做的工作,但是程序集实际使用的内存受到应用程序从事其各种任务的方式的影响。在设计和实现应用程序时,这是一个需要记住的重要特点。我将分析单元素、内存池和数据流的概念。

单元素

应用程序的工作集是 RAM 中当前可用的内存页的集合。初始工作集是应用程序在启动期间消耗的内存页。在应用程序启动期间执行的任务和分配的内存越多,应用程序准备的时间就越长,初始工作集就越大。这对于桌面应用程序而言尤其重要,因为用户通常盯着启动画面以等待应用程序进行准备。

单元素模式可以用来尽可能久地延迟对象的初始化。以下代码显示了在 C# 中实现该模式的一个方式。静态字段存放单元素实例,该实例由 GetInstance 方法返回。静态构造函数(它由 C# 编译器隐式生成,以执行所有静态字段初始值设定项)被保证在第一次访问该类的成员之前执行并且初始化静态实例,如以下代码所示:

public class Singleton
{
    private static Singleton _instance = new Singleton();
    public static Singleton GetInstance()
    {
        return _instance;
    }
}

单元素模式可以确保应用程序通常只使用类的单个实例,但是仍然允许根据需要创建备用实例。这可以节约内存,因为应用程序可以使用一个共享实例,而不是让不同的组件分配它们自己的私有实例。使用静态构造函数可以确保在应用程序的某个部分需要该共享实例之前,不会为该实例分配内存。这在支持很多不同类型功能的大型应用程序中可能很重要,因为只有在实际使用该类时,才会分配对象的内存。

该模式和类似的技术有时称为“惰性初始化”,原因是在实际需要之前不执行初始化。在很多情况下,当初始化可以作为针对对象的第一个请求的一部分发生时,惰性初始化很有用。在静态方法足可以满足需要的情况下,不应当使用它。换句话说,如果您要创建单元素以便访问该单元素类的一批实例成员,则请考虑通过静态成员公开相同的功能是否更合理,因为那样将不需要实例化该单元素。

池机制

一旦应用程序启动并运行,内存利用就将受到系统需要的对象的数量和大小的影响。对象池机制可以降低应用程序所需的分配的数量,从而降低应用程序所需的垃圾回收的数量。池机制相当简单:对象被重新使用,而不是让它被垃圾回收器回收。对象存储在某种类型的列表或数组(称为“池”)中,并且根据请求分发给客户端。如果对象的实例被反复使用,或者如果对象的构建具有开销较大的初始化方面,以至于重新使用现有实例要比处置一个现有实例并且从头创建一个全新的实例更好一些,则该机制尤其有用。

让我们考虑一个可以有效使用对象池的方案。假设您要为一家大型保险公司编写一个系统,以便将患者信息存档。医生在白天收集信息,并且在每个晚上将信息传输到中心位置。代码可能包含完成如下工作的循环:

while (IsRecordAvailable())
{
    PatientRecord record = GetNextRecord();
    ... // process record
}

在该循环中,一个新的 PatientRecord 在每次执行循环时返回。GetNextRecord 方法的最显而易见的实现是在每次调用时创建一个新对象,从而需要分配和初始化该对象,最终对该对象进行垃圾回收,以及终结该对象(如果该对象具有完成器的话)。在使用对象池时,分配、初始化、回收和终结只发生一次,从而减少了内存使用和所需的处理时间。

在某些情况下,可以重新编写代码,以便用如下代码利用该类型上的 Clear 方法:

PatientRecord record = new PatientRecord();
while (IsRecordAvailable())
{
    record.Clear();
    FillNextRecord(record);
    ... // process record
}

在该代码片段中,创建了单个 PatientRecord 对象,并且 Clear 方法重置了内容以便可以在循环内部重新使用它。FillNextRecord 方法使用现有对象,从而避免了重复分配。当然,该代码片段每次执行时,您仍然要付出分配、初始化和回收一次的代价(尽管如此,这仍然要比每次执行循环时付出相应的代价更好一些)。如果初始化的代价高昂,或者从多个线程中同时调用该代码,则这一重复创建的影响仍然可能成为问题。

对象池的基本模式如下所示:

while (IsRecordAvailable())
{
    PatientRecord record = Pool.GetObject();
    record.Clear();
    FillNextRecord(record);
    ... // process record
    Pool.ReleaseObject(record);
}

在应用程序启动时,创建了一个 PatientRecord 实例或者创建了实例池。代码从池中检索到一个实例,从而避免了内存分配、构建和最终的垃圾回收。这可以大大节约时间和内存,尽管它要求程序员显式管理池中的对象。

.NET Framework 为 COM+ 程序集提供了对象池,以作为其 Enterprise Services 支持的一部分。对该功能的访问是通过 System.EnterpriseServices.ObjectPoolingAttribute 类提供的。Rocky Lhotka 撰写了一篇有关该功能的好文章:Everyone Into the Pool。COM+ 自动提供池支持,因此您无须记住显式检索和返回对象。另一方面,程序集必须在 COM+ 内部操作。

图 4

为了汇集任何 .NET 对象,我认为针对本文编写一个通用的对象池会很有趣。我的对应于该类的接口显示在图 4 中。ObjectPool 类为任何 .NET 类型提供了汇聚。

在汇聚类型之前,首先必须注册它。注册可以标识创建委托,以便在需要对象的新实例时调用。该委托只是返回刚刚实例化的对象,而将构建逻辑留待提供该委托的客户端完成。像 Enterprise Services ObjectPooling 属性一样,它还接受要在池中保持活动的对象的最低数量,允许该池具有的对象的最高数量以及等待可用对象的时间长度的超时值。如果超时为零,则调用方将总是等待,直到空闲对象可用为止。在实时情况或其他情况(如果无法很快获得对象则或许需要替代操作)下,非零超时会很有用。在注册调用返回之后,该池就可以提供所请求的最低数量的对象。可以用 UnregisterType 方法终止给定类型的汇聚。

注册之后,GetObject 和 ReleaseObject 方法可以分别从该池中检索对象以及将对象返回到该池中。ExecuteFromPool 方法除了接受所需类型以外,还接受委托和参数。Execute 方法用该池中的对象调用给定的委托,并且确保在该委托完成之后将检索到的对象返回到该池中。这会增加委托调用的开销,但是使您无须手动管理该池。

在内部,类负责维护所汇聚的全部对象的哈希表。它定义了 ObjectData 类以存放与每个类型相关的内部数据。这里没有显示该类,但是该类负责维护注册信息并记录类型的使用信息,而且维护所汇聚对象的队列。

图 5

ReleaseObject 方法在内部使用私有的 ReturnToPool 方法,用给定对象重新填充该池,如图 5 所示。Monitor 类用于锁定该操作。如果可用对象的数量低于最低数量,则对象的引用被放置到队列中。如果已经分配了最低数量的对象,则对象的弱引用被放置到队列中。如果需要,则通知等待线程选取刚刚进入队列的对象。

在此使用弱引用可以使除最低数量对象以外的对象尽可能久地保持活跃状态,但是使它们可供 GC 根据需要使用。ObjectData 的 inUse 字段用于跟踪提供给应用程序的对象,而 inPool 字段则用于跟踪池中有多少实际引用。InPool 字段忽略任何弱引用。

在创作池时需要实现的最重要的功能之一是适当的对象生存期策略。弱引用构成了这样的一个策略的基础,但是还有其他一些机制,并且所要使用的策略取决于环境。

图 6

对于 GetObject 方法,内部的 RetrieveFromPool 方法显示在图 6 中。Monitor.TryEnter 方法用来确保应用程序不会为锁等待太久。如果在超时期间无法获得锁,则向调用方返回 null。如果锁被占有,则调用 DequeueFromPool 方法以从该池中检索对象。请注意该方法如何用 do-while 循环来处理队列中可能存在的弱引用。

重新观察一下 RetrieveFromPool 代码,如果在队列中找不到项,则会通过 AllocateObject 方法分配一个新对象,前提是可用对象的数量低于最大数量。一旦达到最大数量,WaitForObject 方法就会等待对象,直到到达创建超时。请注意在调用 WaitForObject 之前如何调整要等待的时间,以便将为获取锁而花费的时间考虑在内。这里没有显示 WaitForObject 代码,但是可在本文的下载中得到。

对于在检索超时发生时应当发生的事情,有两个选择:返回 null 或者引发异常。返回 null 的缺点是,它强迫调用方在每次从池中获得对象时都检查是否为 null。引发异常可以避免该项检查,但是使超时的开销变得更加高昂。如果预料不会超时,则引发异常可能是一种更好的选择。我决定返回 null,因为当预料不会超时的时候,可以跳过该检查。当预料会超时的时候,检查是否为 null 的成本低于捕获异常的成本。

图 7

图 7 显示了 ExecuteFromPool 方法的代码,但移除了错误检查和注释。该代码使用私有方法从池中检索对象,并且调用所提供的委托。Finally 块确保了即使发生异常,也会将对象返回到池中。

对象池机制有助于减小在堆上进行的分配的数量,因为可以汇聚应用程序中最常见的对象。这可以消除基于 .NET 的应用程序中托管堆大小常见的锯齿模式,并且减少了应用程序为执行垃圾回收而花费的时间。稍后,我将考察一个使用 ObjectPool 类的示例程序。

请注意,托管堆在分配新对象方面非常有效,而垃圾回收器在收集大量生存期较短的小型对象方面非常有效。如果对象的使用频率不高,或者对象的创建或销毁成本不高,则对象池可能不是正确的策略。与任何性能决策一样,分析应用程序是掌握代码中真正瓶颈的最佳方式。

数据流

在管理大块数据时,有时应用程序只是需要很多内存。对象池只是有助于减少类分配所需的内存以及对象创建和销毁所需的时间。它并未真正关注某些程序必须处理很多数据以执行其工作这一事实。

当经常需要大量数据时,您所能做到的只是尽可能完美地管理内存,或者压缩数据或采用其他方式使其尽可能紧凑。(同样,这里会出现内存和速度之间的传统折衷,因为压缩减少了内存消耗,但该压缩需要消耗 CPU 周期。)当临时需要数据时,您或许能够使用数据流来减少所利用的内存的数量。数据流是通过每次使用一部分数据,而不是一次性地使用全部或大部分数据实现的。请比较一下 System.Data 命名空间中的 DataSet 类和 DataReader 类。尽管您可以将查询的结果直接加载到 DataSet 对象中,但大型查询结果将消耗大量内存。DataSet 还要求两次访问内存:一次用于填充表,一次用于以后读取这些表。DataReader 类可以递增地加载同一查询的结果,并且一次向应用程序提交单个行。当实际上并不需要整个结果集时,这将是理想的,因为它能够更有效地使用可用内存。

String 类提供了很多无意中消耗大量内存的机会。最简单的示例是字符串串联。以递增方式串联四个字符串(每次向新字符串中添加一个字符串)将在内部产生七个字符串对象,因为每个添加操作都会产生一个新的字符串。System.Text 命名空间中的 StringBuilder 类无须每次分配新的字符串实例即可将字符串连接在一起;这一效率大大改进了内存的利用。C# 编译器也可以在此方面提供帮助,因为它将同一代码语句中的一系列字符串串连操作转换为对 String.Concat 的单个调用。

String.Replace 方法提供了另外一个示例。请考虑一个读取并处理发送自某个外部源的很多输入文件的系统。这些文件可能需要进行预处理,以便将它们转换为适当的格式。为了便于讨论,假设我有一个系统,它必须将单词“nation”的每个实例替换为“country”,将单词“liberty”的每个实例替换为“freedom”。这可以很容易地用以下代码片段完成:

using(StreamReader sr = new StreamReader(inPath))
{
    string contents = sr.ReadToEnd();
    string result = contents.Replace("nation", "country");
    result = result.Replace("liberty", "freedom");
    using(StreamWriter sw = new StreamWriter(outPath))
    {
        sw.Write(result)
    }
}

这可以完美地工作,代价是需要创建三个长度与文件相同的字符串。Gettysburg Address 大约是 2400 个字节的 Unicode 文本。U.S. Constitution 是 50,000 个字节以上的 Unicode 文本。您可以看到这会有什么后果。

现在,假设每个文件为大约 1MB 的字符串数据,并且我必须并发地处理多达 10 个文件。在我们的简单示例中,读取和处理这 10 个文件将消耗大约 10MB 的字符串数据。垃圾回收器要经常分配和清理该内存,其数量是相当巨大的。

图 8

通过流式传输文件,我们可以每次考虑一小部分数据。每当我找到 N 或 L,我都会寻找上述单词并根据需要替换它们。示例代码显示在图 8 中。我使用该代码中的 FileStream 类来说明如何在字节级别操作数据。如果您愿意的话,则可以对其进行修改,并且改而使用 StreamReader 和 StreamWriter 类。

在该代码中,ProcessFile 方法接收两个流并每次读取一个字节,同时查找 N 或 L。当找到其中一个时,CheckForWordAndWrite 方法分析该流,以查看随后的字符是否匹配所需的单词。如果找到匹配项,则将替换单词写入输出流。否则,将原来的字符放到输出流中,并且将输入流重置到原始位置。

该方法依靠 FileStream 类来适当地缓冲输入和输出文件,以便代码可以逐个字节地执行必要的处理。默认情况下,每个 FileStream 都使用 8KB 的缓冲区,因此该实现所使用的内存要远远少于前面读取并处理整个文件的代码。即使如此,对于输入流中的大多数字符,该过程都会对 FileStream.ReadByte 进行函数调用,并且对 FileStream.WriteByte 进行函数调用。您或许能够通过每次将一系列字节读取到缓冲区中找到更加令人高兴的方法,从而减少方法调用。同样,分析器可以成为您的朋友。

.NET 中构建流类的目的是使多个流可以在公共基础流上协同工作。很多派生自 Stream 的类都包含一个采用现有 Stream 对象的构造函数,从而使一连串的 Stream 对象可以对传入的数据进行操作,并且对该流产生一连串的修改或转换。有关示例,请参阅 CryptoStream 类的 .NET Framework 文档,它说明了如何加密来自传入的 FileStream 对象的字节数组。

既然我已经分析了一些与内存利用相关的设计和实现问题,那么对测试和调整应用程序进行简要讨论将是适当的。几乎所有应用程序都一定会具有各种性能和内存问题。发现这些问题的最佳方式是显式度量这些项目,并且在问题暴露时对它们进行跟踪。Windows® 性能计数器以及 .NET CLR 分析器或其他分析器是实现这一目标的两个非常好的方式。

性能监视

Windows 性能监视器不会解决性能问题,但是它确实可以帮助您识别应当在哪里寻找这些问题。在 Chapter 15 — Measuring .NET Application Performance 可以获得与内存利用和其他性能规格相关的性能计数器的详尽列表。

性能调整在理论上是一项迭代任务。在标识了一组性能规格并且建立了可以应用这些规格的测试环境之后,就可以在该测试环境中运行应用程序。性能信息是使用性能监视器收集的。结果将被分析以产生一些建议进行改善的领域。应用程序或配置被基于这些建议进行修改,然后该过程将重新开始。

这一测试、收集、分析和修改系统的过程同样恰当地适用于性能的所有方面,包括内存利用。系统修改可能包括重新编写代码的某个部分、更改系统内部的应用程序的配置或分发以及其他更改。

CLR 分析器

CLR 分析器工具非常适合于内存利用分析。它可以分析正在运行的应用程序的行为,并且提供有关分配内存的类型、为它们分配的内存的长度、每次垃圾回收的详细信息以及其他与内存相关的信息的详细报告。您可以从 Tools & Utilities 下载该免费工具。

该分析工具具有很高的干扰性,因此它不适合于常规的性能分析。但是,对于分析托管堆,它给人的印象非常深刻。为了查看其功能的小型示例,我编写了一个小型的 PoolingDemo 程序,以使用我在前面讨论的 ObjectPool 类。为了防备您认为池机制只适用于大型对象或开销较大的对象,该演示定义了一个 MyClass 对象,如下所示:

class MyClass {
    Random r = new Random();
    public void DoWork() {
        int x = r.Next(0, 100);
    }
}

该程序使您可以在非池测试和池测试之间进行选择。非池测试的代码完成以下工作:

public static void BasicNoPooling()
{
    for (int i = 0; i < Iterations; i++)
    {
        MyClass c = new MyClass();
        c.DoWork();
    }
}

在我的桌面计算机上,一百万次迭代需要大约 12 秒钟才能完成。池代码避免了在循环内部分配 MyClass 对象:

public static void BasicPooling(){ // Register the MyClass type Pool.RegisterType(typeof(MyClass), ...); for (int i = 0; i < Iterations; i++) { MyClass c = (MyClass)Pool.GetObject(typeof(MyClass)); c.DoWork(); Pool.ReleaseObject(c); } Pool.UnregisterType(typeof(MyClass));}

在该代码中,我使用了静态 Pool 属性来调用 ObjectPool.GetInstance。对于一百万次迭代,该池测试需要大约 1.2 秒钟才能完成,这大约比非池代码快 10 倍。当然,我的示例的设计目的是强调与获得和释放对象实例的引用相关联的成本。MyClass.DoWork 几乎肯定由 JIT 编译器内联,而每次迭代节约的时间(一百万次以上迭代节约 10 秒钟)很小。尽管如此,该示例仍然说明了对象池如何消除一定数量的开销。在这种开销很重要或者创建或终结对象的时间很长的情况下,对象池可能证明是有益的。


图 9使用对象池的时间线视图

将迭代次数减少到 100,000 次并且对该代码运行 CLR 分析器可以产生一些有趣的结果。图 9 显示了使用对象池时的时间线视图,而图 10 显示了没有使用对象池时的时间线视图。该视图显示了托管堆的时间线(不同的类型由不同的颜色表示),并且包含每次垃圾回收的计时。在图 9 中,池机制产生了相当平坦的堆,并且只在应用程序退出时执行一次垃圾回收。在图 10 中,没有使用池机制,堆必须恢复由每个 Random 类分配的数据。红色表示整数数组,它是批量数据。不使用对象池时的时间线视图显示,非池测试执行了 11 次垃圾回收。


图 10 未使用对象池时的时间线视图

CLR 分析器还可以按类类型或者随着时间的流逝显示分配视图,标识每个方法所分配的字节数量,并且显示在整个测试周期中执行的方法序列。CLR 分析器下载(可在 MSDN?Magazine Web 站点获得)包含一些内容相当广泛的文档,其中一个部分包含示例代码,以说明常见的垃圾回收问题以及它们在各种 CLR 分析器视图中是如何表现的。

小结

我敢肯定,现在您正在以不同的方式考虑代码中的内存利用 — 哪里很好,以及哪里还有待改进。我已经在这里讨论了一系列的问题 — 从类型的大小调整到可以帮助您发现代码中内存问题的工具。我讨论了以下内容的性能和内存好处:汇聚频繁使用的对象,而不是依靠 .NET 运行库来分配对象,然后对对象进行垃圾回收,并且我将流式传输视为减少处理大型对象所需内存数量的一种方式。剩下的工作就留待您来完成了。


版权所有:UML软件工程组织