现在的位置: 首页 > 综合 > 正文

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

2012年11月02日 ⁄ 综合 ⁄ 共 4795字 ⁄ 字号 评论关闭
文章目录

性能

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

太多的分配

这确实是产生错误的最基本原因。使用垃圾回收器分配新的内存确实是很快的。您可以在上面的图 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,就不再需要回收器使对象继续存活,也不需要调用终结器。

http://www.microsoft.com/china/MSDN/library/netFramework/netframework/NFdotnetgcbasics.mspx

抱歉!评论已关闭.