星辉

星月同辉 e路随行
.net/vs2005/c#/web/ajax
  博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

[转]垃圾回收器基础与性能提示

Posted on 2007-11-24 23:10  star163  阅读(188)  评论(0编辑  收藏  举报

垃圾回收器基础与性能提示

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

Rico Mariani

Microsoft Corporation

摘要:.NET 垃圾回收器为高速的分配服务提供了很好的内存使用机制,并且不会带来长期碎片的问题。本文解释了垃圾回收器的工作原理,然后讨论了在垃圾回收环境中可能遇到的某些性能问题。

适用于:

Microsoft .NET 框架

本页内容
简介 简介
简化模型 简化模型
回收垃圾 回收垃圾
性能 性能
终结 终结
小结 小结

简介

为了了解如何成功利用垃圾回收器,以及在垃圾回收环境中运行时可能遇到的性能问题,那么了解垃圾回收器如何工作的基础知识,以及这些内部操作对正在运行的程序会产生怎样的影响就显得十分重要。

本文分为两个部分:首先,我将使用简化模型概括地讨论公共语言运行库 (CLR) 的垃圾回收器的性质,然后,我将讨论这种结构对性能的潜在影响。

简化模型

出于解释性目的,请考虑下面的托管堆的简化模型。注意,这并不是 实际实现的模型。


1. 托管堆的简化模型

下面是该简化模型的规则:

所有可进行垃圾回收的对象都分配在一个连续的地址空间范围内。

堆被划分为代 (generation)(稍后介绍此概念),以便只需查找堆的一小部分就能清除大多数垃圾。

代中的对象大体上均为同龄。

代的编号越高,表示堆的这一片区域所包含的对象越老 — 这些对象就越有可能是稳定的。 最老的对象位于最低的地址内,而新的对象则创建在增加的地址内。(在上面的图 1 中,地址是递增的。)

新对象的分配指针标记了内存的已使用(已分配)内存区域和未使用(可用)内存区域之间的边界。

通过删除死对象并将活对象转移到堆的低地址末尾,堆周期性地进行压缩。这就扩展了在创建新对象的图表底部的未使用区域。

对象在内存中的顺序仍然是创建它们的顺序,以便于定位。

在堆中,对象之间永远不会有任何空隙。

只有某些可用空间是已提交的。需要时,操作系统会从“保留的”地址范围中分配更多的内存。

回收垃圾

需要了解的最容易的一种回收是完全压缩垃圾回收,所以我首先讨论它。

完全回收

在完全回收中,我们必须停止执行程序,并到 GC 堆中找到所有根。这些根以各种形式出现,但大多数明显是堆栈和指向堆中的全局变量。从根开始,我们访问每个对象,并沿途追溯包含在每个被访问对象内的每个对象指针,指针用于标记这些对象。以这种方式,回收器将发现每个可达到的活的 对象。而其他对象(不可达到 对象)现在会被宣告死亡。


2. GC 堆中的根

一旦找出了不可达到的对象,我们就需要回收空间以便随后使用;在这里,回收器的目标是要将活的 对象向上移动,并清除浪费的空间。在执行过程停止的情况下,回收器可以安全地移动所有这些对象,并修复所有指针,以便所有对象在新的位置上被正确链接。幸存的对象将被提升到下一代的编号(就是说,代的边界得到更新),并且执行过程可以恢复。

部分回收

遗憾的是,每次都执行完全垃圾回收确实开销太大,所以,现在可以讨论如何在回收过程中使用代来帮助我们解决问题。

首先让我们考虑一种假想的情形(此例中,我们特别幸运)。假设最近执行了一次完全回收,并且堆得到了很好的压缩。程序继续执行,并且发生了某些分配操作。实际上,发生了非常多的分配,在发生足够多的分配之后,内存管理系统决定是进行回收的时候了。

这时,我们很幸运。假设自从上一次回收以后,在我们运行的所有时间里,我们根本没有对任何较老的对象执行写操作,而只是对新分配的(第零代 (gen0))对象执行了写操作。如果真是这样发生的,情况就会比较简单,因为我们可以从整体上简化垃圾回收过程。

我们可以不假设通常的完全回收,而只是假设所有较老的对象 (gen1, gen2 仍然是活着的 — 或者至少它们中活着的对象的数量足以使这些对象不值得关注。此外,由于它们都没有被写入过(还记得我们是多么幸运吗?),所以没有指针从较老的对象指向较新的对象。因此,我们可以做的事情就是像通常情况下一样查看所有根,如果有任何根指向旧对象,只需忽略这些对象。而对于其他根(指向 gen0 的根)我们照常继续进行,追溯所有指针。一旦我们发现有内部指针指回较老的对象,我们就忽略它。

完成该过程后,我们就已经访问完 gen0 中所有活的对象,但没有访问较老的代中的任何对象。然后,可以照常宣告 gen0 对象的死亡,并且只是移动该内存区域,而不干扰较老的对象。

现在情况确实很好,因为我们知道大多数死空间可能在更年轻的对象中,而这有很大好处。很多类都会为它们的返回值、临时字符串和其他各种实用工具类(比如枚举器和其他东西)创建临时对象。只要看一下 gen0 就能通过只查找非常少的对象,很容易找回大多数死空间。

遗憾的是,我们还不够幸运,无法使用该方法,因为至少有一些较老的对象肯定会发生更改以便指向新的对象。如果发生这种情况,就不能只是忽略它们。

使代与写入屏障配合工作

要使上面的算法实际工作,我们必须知道哪些较老的对象已经修改。为了记住“脏”对象的位置,我们使用了称为牌桌 (card table) 的数据结构;为了维护该数据结构,托管代码编译器生成了所谓的写入屏障 (write barrier)。这两个概念是基于代的垃圾回收获得成功的关键。

牌桌可以用多种方式实现,但最容易的方式是将其考虑为位数组。牌桌中的每个位代表堆中的一个内存范围,比如说是 128 个字节。程序每次将对象写入某个地址时,写入屏障代码必须计算哪个 128 字节块被写入,然后在牌桌中设置相应的位。

有了这一机制,我们现在可以重新访问回收算法。如果我们正在执行一次 gen0 垃圾回收,我们可以使用上面讨论的算法(忽略指向较老代的任何指针),但一旦我们完成该操作,那么我们还必须查找位于牌桌中被标记为已修改的块中的每个对象中的每个对象指针。我们必须像对待根一样对待这些指针。如果我们同样地考虑这些指针,那么我们将准确无误地只回收 gen0 对象。

如果牌桌总是满的,则该方法根本不会对我们有帮助,但在实践中,来自较老一代的指针中只有相当少的一部分实际上会被修改,所以该方法有极大的保留价值。

性能

既然我们有了如何进行操作的基本模型,现在让我们考虑可能引起错误并使该模型性能降低的某些问题。这样可以让我们更好地了解我们应当尝试避免什么样的事情,以便让回收器获得最佳的性能。

太多的分配

这确实是产生错误的最基本原因。使用垃圾回收器分配新的内存确实是很快的。您可以在上面的图 2 中看见,通常情况下所有需要发生的事情就是移动分配指针,以便在“已分配”的一侧为新对象创建空间 — 它并不会比这快得多。但垃圾回收迟早总是会发生的(所有事情都是一样的),并且晚发生好于早发生。所以当您创建新的对象时,需要确保该操作的确是需要的和合适的,即使只创建一个对象的速度很快。

这听起来可能像是显而易见的建议,但实际上您很容易忘记您编写的一小行代码会触发很多分配。例如,假设您编写了一个某种类型的比较函数,并且您的对象有关键字字段,而您想要以给定顺序按该关键字进行不区分大小写的比较。现在,在这种情况下您无法只是比较整个关键字字符串,因为第一个关键字可能非常短。您可能会想到使用 String.Split 将关键字字符串分割成若干片段,然后使用标准的区分大小写的比较方法按顺序比较每一个片段。这听起来很棒,是不是?

好,随后我们将看到,这并不是个好的想法。因为 String.Split 将创建一个字符串数组,这意味着原来在关键字字符串中的每个关键字都有一个新的字符串对象,再加上该数组也有一个对象。注意!如果在某种上下文中这样做,就会有非常多的比较操作,现在,您的两行比较函数就创建了数量非常多的临时对象。垃圾回收器突然因为您而负载大增,甚至使用最智能的回收方案也会有很多垃圾需要清理。最好编写一个根本不需要分配内存的比较函数。

太大的分配

如果使用传统分配器,例如 malloc(),程序员编写的代码通常尽可能少地调用 malloc(),因为他们知道分配的开销相当大。这种方式转换为以块进行分配的做法,通常是猜测性地分配我们可能需要的对象,以便我们可以进行总数更少的分配。然后从某种分配池对预先分配的对象进行手动管理,从而有效地创建一种高速度的自定义分配器。

在托管世界中,由于下面几个原因,这种做法的吸引力要少很多:

首先,执行分配的开销非常小 — 因为不需要像传统分配器那样搜索可用的内存块;所有需要发生的操作只是需要移动在可用的和已分配的区域之间的边界。分配的开销很小意味着使用池来管理内存分配的最有吸引力的理由不再存在。

其次,如果您确实要选择预分配方式,当然会使所产生的分配量比立即需要方式所需的分配量更多,这反过来会强制执行额外的垃圾回收操作,而这在其他方式下可能是不需要的。

最后,垃圾回收器将无法回收您手动回收的对象的空间,因为从全局角度来看,所有这些对象(包括当前没有使用的对象)仍然是活的。您可能会发现,随时待用的方式会让很多内存被浪费,但正在使用中的对象则不会。

这并不是说预分配方式总是糟糕的想法。例如,您可能希望通过这样做强制将某些对象一开始就分配在一起,但您可能发现,与在非托管代码中相比,将它作为一种常规策略不那么有吸引力。

太多的指针

如果您创建的数据结构有非常多的指针,那么您将有两个问题。第一,将有很多对象写入(参见下面的图 3),第二,当回收该数据结构的时间到来时,您将使垃圾回收器追溯所有这些指针,如果需要,还要随着对象的到处移动全部更改这些指针。如果您的数据结构的生命周期很长,并且不会有很多更改,那么,当完全回收发生时(在 gen2 级别),回收器只需要访问所有这些指针。但如果您创建的此类结构的生命周期短暂(就是说,作为处理事务的一部分),那么您将支付比通常情况下大出很多的开销。


3. 指针太多的数据结构

指针太多的数据结构还会有与垃圾回收时间不相关的其他问题。前面已经讨论过,当对象被创建时,它们会按分配顺序连续分配内存。例如,如果从文件还原信息,从而创建了大型、可能很复杂的数据结构,那么这是一件好事。即使您有完全不同的数据类型,所有对象仍然会在内存中紧靠在一起,这样会帮助处理器快速访问这些对象。但是,随着时间的流逝以及数据结构被修改,新的对象将有可能需要附加到旧的对象上。这些新对象的创建时间非常晚,所以在内存中不再靠近原始对象。甚至在垃圾回收器真地进行内存压缩时,对象仍然不会在内存中重新排列,它们只是“滑”到一起,以删除浪费的空间。由此导致的混乱可能在一段时间后变得非常糟糕,以致于您可能倾向于为您的整个数据结构制作一份全新的副本,并全部打好包,然后让回收器在适当的时候废弃那个旧的无序的数据结构。

太多的根

垃圾回收器在执行回收时当然必须给予根以特殊的对待 — 它们总是必须被依次枚举,并加以充分考虑。gen0 回收可以快到只要您不认为是根发生泛滥的程度。如果您要创建一个在其本地变量中有很多对象指针的深层递归函数,实际结果将是开销很大的。导致该开销的因素不仅在于必须考虑到所有这些根,而且在于这些根可能要在不是非常长的时间里使其保持存活状态的 gen0 对象的数量相当巨大(讨论在下面)。

太多的对象写入

再一次引用前面的讨论,请记住托管程序每次修改对象指针时,还会触发写入屏障代码。这可能很糟糕,有两个原因:

第一,写入屏障的开销可以与您首先要尝试的操作的开销相比拟。例如,如果您以某一种枚举器类执行简单的操作,您可能发现您需要在每一个步骤中,将某些关键指针从主回收过程移动到枚举器中。这实际上是您可能想避免的事情,因为,由于写入屏障的因素,实际上这会使复制这些指针的开销增加一倍,并且您可能必须在每个循环中对枚举器一次或多次这样做。

第二,如果您事实上写入的是较老的对象,则触发写入屏障造成的恶果是原来的两倍。当您修改较老的对象时,实际上是创建了当下一次垃圾回收发生时需要检查的额外的根(上面已经讨论过)。如果您修改的旧对象过多,实际上就会抵消通常由于只回收最年轻一代而带来的速度提高。

当然,除了这两个原因以外,在任何种类的程序中不执行太多写入操作的常见原因也同样适用。所有事情都是同样的,内存使用(实际上,读取或写入)得越少越好,以便更节约地使用处理器缓存。

太多的生命周期较长的对象

最后,也许基于代的垃圾回收器的最大缺陷是创建了很多对象,而这些对象既不完全是临时的,也不完全是生命周期很长的。因为它们不会被 gen0 回收过程(最廉价的回收)清理(因为它们仍然是必要的);而且它们甚至可能会在 gen1 回收后幸存(因为它们仍然在使用中),但是在这之后不久它们就会死去,所以这些对象可能导致很多麻烦。

麻烦的是,一旦对象已经到达 gen2 级别,那么只有完全地回收才能除去该对象,而完全回收的代价非常高,以致于只要有合理的可能性垃圾回收器就会尽可能延迟执行这样的回收。所以,有很多“生命周期较长”的对象所造成的结果是 gen2 将往往以可能很危险的速度不断增长;它可能不会几乎像您想像的那样快地得到清理,而且,当它真的被清理时,肯定还会超过您预期的高昂代价。

要避免出现这些种类的对象,最佳的防备措施有以下几点:

分配尽可能少的对象,适当注意正在使用的临时空间的数量。

使生命周期较长的对象的大小保持最小。

使堆栈上的对象指针尽可能最少(它们是根)。

如果您做了这些事情,您的 gen0 回收很可能是高度有效的,并且 gen1 将不会非常快地增长。结果,gen1 回收的频率可以大大减少,当它变得很谨慎地执行 gen1 回收时,您的中等长度生命周期对象将已经死亡,并且可以在这个时候开销较低地恢复这些对象。

如果事情顺利,那么在稳定状态的操作期间,您的 gen2 大小根本就不会增加!

终结

既然我们已经用简化的分配模型讨论了几个主题,我想使事情变得复杂一点,以便我们可以讨论一个更重要的现象,这就是终结器 (finalizer) 和终结 (finalization) 的开销。简单说,终结器可以出现在任何类中 — 它是可选成员,垃圾回收器承诺在回收应死而未死的对象的内存之前要调用该对象的终结器。在 C# 中,使用 ~Class 语法指定终结器。

终结如何影响回收

当垃圾回收器第一次遇到应死而未死但仍需要终结的对象时,它必须在这个时候放弃回收该对象的空间的尝试。而是将对象添加到需要终结的对象列表中,而且,回收器随后必须确保对象内的所有指针在终结完成之前仍然继续有效。这基本上等同于说,从回收器的观察角度来看,需要终结的每个对象都像是临时的根对象。

一旦回收完成,适当命名的终结线程 将遍历需要终结的对象列表,并调用终结器。该操作完成时,对象再一次成为死对象,并且将以正常方式被自然回收。

终结和性能

有了对终结的基本了解,我们已经可以推导出某些非常重要的事情:

第一,需要终结的对象其存活时间比不需要终结的对象长。实际上,它们可以活得长得多。例如,假设在 gen2 的对象需要被终结。终结将按计划进行,但对象仍然在 gen2,所以,直到下一次 gen2 回收发生时才会重新回收该对象。这的确要用非常长的时间,事实上,如果顺利的话,它将活很长时间,因为 gen2 回收的开销很高,所以我们希望 它们很少发生。需要终结的较老的对象可能必须等待即使没有数百次也有几十次的 gen0 回收,然后才能回收它们的空间。

第二,需要终结的对象会导致间接损失。由于内部对象指针必须保持有效,因此,不仅立即需要终结的对象将停留在内存中,而且该对象直接和间接引用的所有东西也都将保留在内存中。如果由于有一个需要终结的对象而导致一个大型对象树被固定住,那么,像我们刚才讨论的一样,整个树就有可能长时间停留在内存中。因此,节约使用终结器十分重要,并将它们放在有尽可能少的内部对象指针的对象中。在刚才提到的示例树中,通过将需要终结的资源移动到单独的对象中,并在树的根中保持对该对象的引用,可以很容易避免这个问题。通过这个小小的更改,结果只有一个对象(希望是很小的对象)会继续停留在内存中,并且终结的开销将最小化。

最后,需要终结的对象会为终结器线程创建工作。如果终结过程很复杂,则一个并且是唯一的一个终结器线程将花费很多时间来执行这些步骤,这会导致工作积压,并且因此会导致更多的对象停留在内存中,等待终结。因此,终结器做尽可能少的工作是非常重要的。还要记住,尽管所有对象指针在终结期间保持为有效,但有可能这些指针会指向已经终结并且因此不再那么有用的对象。通常,最安全的办法是避免在终结代码中追溯对象指针,即使这些指针是有效的。安全、简短的终结代码方式是最佳选择。

IDisposable 和 Dispose

在很多情况下,对于以其他方式总是需要被终结的对象来说,通过实现 IDisposable 接口来使这样的对象避免该开销是有可能的。该接口为回收那些其生命周期被程序员们众所周知的资源提供了备用方法,实际上发生这种情况的机率相当高。当然,如果您的对象只是使用唯一的内存,因此根本不需要终结或处置,那么这仍然是更好的情形;但如果需要终结,并且在很多情况下对对象进行显式管理既容易又实用,那么实现 IDisposable 接口就是避免、至少是减少终结开销的好方法。

在 C# 中,该模式可以是很有用的:

class X:  IDisposable
{
   public X(…)
   {
   … initialize resources … 
   }

   ~X()
   {
   … release resources … 
   }

   public void Dispose()
   {
// this is the same as calling ~X()
        Finalize(); 

// no need to finalize later
System.GC.SuppressFinalize(this); 
   }
};

在这里,通过手动调用 Dispose,就不再需要回收器使对象继续存活,也不需要调用终结器。

小结

.NET 垃圾回收器为高速的分配服务提供了很好的内存使用机制,并且不会带来长期碎片的问题,但为此所执行的操作可能使性能远远低于最佳状态。

为了使分配器以最佳状态工作,您应当考虑诸如下面这些做法:

同时分配所有(或尽可能多)的内存用于给定的数据结构。

删除在复杂性方面几乎无需代价就可以避免的临时分配。

最大程度地减少对象指针的写入次数,尤其是对较老对象的写入。

减少数据结构中的指针密度。

有限制地使用终结器,然后尽可能多地只对“叶子”对象使用它。如果需要,应当分割对象来帮助进行该操作。

通过定期审阅关键数据结构,并使用分配分析器这样的工具来对内存使用情况进行分析,将对保持内存使用机制的有效性,并让垃圾回收器工作在最佳状态大有帮助。