位置:海鸟网 > IT > ASP.NET >

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

简介

如果想了解怎样有效利用利用垃圾回收器,以及怎样处理垃圾回收进行时可能遇到的性能问题,那么明白垃圾回收器的工作原理,以及内部操作对正在运行中程序的影响就显得十分重要。

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

简化模型

为了便于理解,我们根据实际情况搭建托管堆模型,并将其简化。

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

  • 所有可进行垃圾回收的对象都分配在一个连续的地址空间范围内。
  • 堆被划分为代(generation)(稍后介绍此概念),以便只需查找堆的一小部分就能清除大多数垃圾。
  • 代中的对象大体上均为同龄。
  • 代的编号越高,表示堆的这一片区域所包含的对象越老-是稳定对象的可能性越大。
  • 最老的对象位于最低的地址内,而新的对象则创建在增加的地址内。(在上面的图1中,地址是递增的。)
  • 新对象的分配指针标记了内存的已使用(已分配)内存区域和未使用(可用)内存区域之间的边界。
  • 通过删除非活动对象并将活动对象转移到堆的低地址末尾,堆周期性地进行压缩。这就扩展了在创建新对象的图表底部的未使用区域。
  • 对象在内存中的顺序仍然是创建它们的顺序,以便于定位。
  • 在堆中,对象之间永远不会有任何空隙。
  • 只有某些可用空间是已提交的。需要时,操作系统会从“保留的”地址范围中分配更多的内存。
  • 回收垃圾

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

    完全回收

    在完全回收中,我们必须阻塞程序的执行,并到GC堆中找到所有根(root)。这些根以多种形式多种种形式出现堆栈和指向堆中的全局变量的形式居多。从根开始,我们访问每个对象,并沿途追溯包含在每个被访问对象内的每个对象指针,指针用于标记这些对象。以这种方式,回收器将发现每个可获得的或活动的活动对象。而其他对象(不可达到对象)就会转为非活动对象。

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

    部分回收

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

    首先让我们考虑最理想状态:。假设最近执行了一次完全回收,并且堆得到了很好的压缩。然后程序继续执行,并且发生了一些分配操作,在发生足够多的分配之后,内存管理系统决定进行回收了。

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

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

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

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

    遗憾的是,这只是极其理想的情况,,因为至少有一些较老的对象肯定会发生更改以便指向新的对象。如果发生这种情况,就不能只是忽略它们。

    使代与写入屏障(Write Barriers)配合工作

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

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

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

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

    性能

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

    过度频繁分配

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

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

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

    过度大内存分配

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

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

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

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

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

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

    过多的指针

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

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

    过多的根

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

    过多的对象写入

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

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

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

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

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

    最后,也许基于代的垃圾回收器的最大缺陷是创建了很多对象,而这些对象生命周期长短不一致因此,可能就会出现如下问题:某对象已经到达gen2级别,那么只有完全回收而完全回收的代价非常高,以致于只要垃圾回收器总会尽可能延迟执行这样的回收。所以,残留的"生命周期较长"的对象的数目可能以很快的速度不断增长,清理它们的速度和开销可能与我们的预期相差甚远。

    要避免出现这些种类的对象,可以采取以下措施:

  • 分配尽可能少的对象,适当注意正在使用的临时空间的数量。
  • 使生命周期较长的对象的大小保持最小。
  • 使堆栈上的对象指针尽可能少(它们是根)。
  • 采用以上措施,您的gen0回收很可能是高度有效的,相信gen1的增长会得到有效的遏制,gen0回收效率得到提高。结果,gen1回收的频率可以大大减少,当它变得很谨慎地执行gen1回收时,您的中等长度生命周期对象将已经处于非活动状态,并且可以在这个时候开销较低地恢复这些对象。

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

    终结(Finalization)

    既然我们已经用简化的分配模型讨论了几个主题,我想使事情变得复杂一点,以便我们可以讨论一个更重要的现象,这就是终结器(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垃圾回收器为高速的分配服务提供了很好的内存使用机制,并且不会带来长期碎片的问题,但为此所执行的操作可能使性能远远低于最佳状态。

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

  • 同时分配所有(或尽可能多)的内存用于给定的数据结构。
  • 删除在复杂性方面几乎无需代价就可以避免的临时分配。
  • 最大程度地减少对象指针的写入次数,尤其是对较老对象的写入。
  • 减少数据结构中的指针密度。
  • 有限制地使用终结器,然后尽可能多地只对"叶子"对象使用它。如果需要,应当分割对象来帮助进行该操作。
  • 通过定期检查关键数据结构,使用分配分析器之类的工具来对内存使用情况进行分析,对保持内存使用机制的有效性保持垃圾回收器的最佳状态大有帮助。