垃圾回收器基础知识和性能提示

 

Rico Mariani
Microsoft Corporation

2003 年 4 月

总结: .NET 垃圾回收器提供高速分配服务,可很好地利用内存,并且没有长期碎片问题。 本文介绍垃圾回收器的工作原理,然后继续讨论垃圾回收环境中可能遇到的一些性能问题。 (10 个打印页)

适用于:
   Microsoft® .NET Framework

目录

简介
简化模型
收集垃圾
性能
终止
结论

简介

为了了解如何充分利用垃圾回收器,以及在垃圾回收环境中运行时可能会遇到哪些性能问题,请务必了解垃圾回收器工作原理的基础知识以及这些内部工作如何影响运行程序。

本文分为两个部分:首先,我将使用简化模型 (CLR) 垃圾回收器一般讨论公共语言运行时的性质,然后讨论该结构的一些性能影响。

简化模型

出于说明目的,请考虑托管堆的以下简化模型。 请注意 ,这不是实际 实现的内容。

图 1. 托管堆的简化模型

此简化模型的规则如下:

  • 所有可垃圾回收对象都从一个连续的地址空间范围进行分配。
  • 堆分为 几代 (以后) 以便只需查看堆的一小部分即可消除大部分垃圾。
  • 一代中的对象年龄大致相同。
  • 编号较高的世代表示堆中具有较旧对象的区域-这些对象更可能保持稳定。
  • 最旧的对象位于最低地址,而新对象在增加地址时创建。 (地址在上面的图 1 中呈下降趋势。)
  • 新对象的分配指针标记已用 (分配) 与未使用的 (可用) 内存区域之间的边界。
  • 通过删除死对象并将活动对象向上滑动到堆的低地址端,定期压缩堆。 这会展开图表底部的未使用区域,在其中创建新对象。
  • 为了保持良好的位置,内存中对象的顺序仍然是它们的创建顺序。
  • 堆中对象之间永远不会有任何间隙。
  • 只提交部分可用空间 如有必要,将从 保留 地址范围中的操作系统获取更多内存。

收集垃圾

最容易理解的回收类型是完全压缩垃圾回收,因此我将首先讨论这一点。

完整集合

在完整集合中,我们必须停止程序执行,并将所有 查找到 GC 堆中。 这些根有多种形式,但最明显的是指向堆的堆栈和全局变量。 从根开始,我们访问每个对象,并跟踪每个访问的对象中包含的每个对象指针,以标记对象,同时我们前进。 这样,收集器将找到每个 可访问实时 对象。 其他物体, 无法访问 的对象,现在 受到谴责

图 2. GC 堆的根

确定不可访问的对象后,我们希望回收该空间供以后使用:此时,收集器的目标是将 活动 对象向上滑动并消除浪费的空间。 停止执行后,收集器可以安全地移动所有这些对象,并修复所有指针,以便将所有指针正确链接到其新位置。 幸存对象将提升为下一代编号 (也就是说,) 更新代系的边界,并可以恢复执行。

分部集合

遗憾的是,完全垃圾回收的成本太高,每次都无法完成,因此现在讨论在回收中让几代人如何帮助我们完成。

首先,让我们考虑一个虚构的案例,其中我们非常幸运。 假设最近有一个完整集合,并且堆已很好地压缩。 程序执行会恢复,并发生一些分配。 事实上,会发生大量分配,在进行足够多的分配后,内存管理系统会决定是时候收集了。

现在,我们很幸运。 假设自上一个集合以来,在我们运行的所有时间中,我们根本没有对任何旧对象进行写入,只有新分配的第 0 (第0 代) 写入对象。 如果发生这种情况,我们将处于一个伟大的情况,因为我们可以大大简化垃圾回收过程。

我们可以假设第1 代、2 代) (所有较旧的对象仍然处于活动状态,或者至少它们足够活跃,因此不值得查看这些对象。 此外,由于它们都不是 (记住我们是多么幸运?) 没有从旧对象到新对象的指针。 因此,我们可以做的是像往常一样查看所有根,如果有任何根指向旧对象,只需忽略这些根。 对于指向第0 代的其他根 () 我们照常操作,遵循所有指针。 每当找到返回旧对象的内部指针时,我们都会忽略它。

完成此过程后,我们将访问第0 代中的每个实时对象,而无需访问旧一代中的任何对象。 然后,可以像往常一样谴责第0 代对象,并且我们仅向上滑动该内存区域,使较旧的对象不受干扰。

现在,这对我们来说真的是一个伟大的情况,因为我们知道大多数死空间很可能在年轻的物体中,那里有大量的流失。 许多类为其返回值、临时字符串和各种其他实用工具类(如枚举器和 whatnot)创建临时对象。 仅查看第 0 代,我们可以通过只查看极少数物体来轻松找回大部分死空间。

遗憾的是,我们从未幸运地使用此方法,因为至少一些较旧的对象必须更改,以便它们指向新对象。 如果发生这种情况,仅仅忽略它们是不够的。

使几代人使用写入障碍

若要使上述算法实际工作,我们必须知道修改了哪些较旧的对象。 为了记住脏对象的位置,我们使用一个名为 卡 表的数据结构,为了维护此数据结构,托管代码编译器会生成所谓的写入屏障。这两个概念是基于代的垃圾回收成功的核心。

卡表可以通过多种方式实现,但最简单的方法是将其视为位数组。 卡表中的每个位都表示堆上的内存范围,例如 128 个字节。 每次程序将对象写入某个地址时,写入屏障代码都必须计算写入的 128 字节区块,然后在卡表中设置相应的位。

建立此机制后,现在可以重新访问收集算法。 如果我们要执行第 0 代垃圾回收,则可以使用上述算法,忽略指向旧代的任何指针,但完成此操作后,还必须在每个对象中找到每个对象指针,这些对象位于卡表中标记为已修改的区块上。 我们必须像对待根一样对待那些。 如果我们还考虑这些指针,那么我们将正确收集第0 代对象。

如果卡表始终已满,此方法将无济于事,但实际上,老一代的指针实际上很少得到修改,因此此方法可节省大量成本。

性能

现在,我们已经有了一个基本模型来了解事情的运行方式,让我们考虑一些可能会出错、导致运行缓慢的事情。 这将让我们知道我们应该避免什么样的事情,以获得收集器的最佳性能。

分配过多

这真的是最基本的事情,可能会出错。 使用垃圾回收器分配新内存的速度非常快。 如上面的图 2 所示,通常需要移动分配指针,以便在“已分配”端为新对象创建空间,这不会比这快得多。 但是,垃圾回收迟早必须发生,而且,所有情况都相同,最好是晚于早发生。 因此,在创建新对象时,需要确保这样做确实必要且合适,即使只创建一个对象的速度很快。

这听起来可能是显而易见的建议,但实际上很容易忘记你编写的一小行代码可能会触发大量分配。 例如,假设你要编写某种类型的比较函数,并假设对象具有关键字字段,并且你希望比较按给定的顺序对关键字不区分大小写。 在这种情况下,不能只比较整个关键字字符串,因为第一个关键字 (keyword) 可能很短。 使用 String.Split 将关键字 (keyword) 字符串拆分为多个部分,然后使用正常不区分大小写的比较顺序比较每个部分会很诱人。 听起来很棒, 对吧?

嗯,因为事实证明这样做不是一个好主意。 可以看到,String.Split 将创建一个字符串数组,这意味着最初在关键字字符串中的每个关键字 (keyword) 一个新字符串对象,并为数组再创建一个对象。 是的! 如果我们在某类上下文中执行此操作,则进行 了大量 比较,而你的双行比较函数现在正在创建大量临时对象。 突然,垃圾回收器将非常努力地代表你工作,即使使用最聪明的回收方案,也有很多垃圾要清理。 最好编写根本不需要分配的比较函数。

Too-Large分配

使用传统的分配器(如 malloc () )时,程序员通常会编写代码,尽可能少地调用 malloc () ,因为他们知道分配成本相对较高。 这转化为在区块中分配的做法,通常以推理方式分配我们可能需要的对象,以便我们可以执行更少的总分配。 然后,从某种池手动管理预分配的对象,从而有效地创建一种高速自定义分配器。

在托管环境中,这种做法由于以下几个原因而不那么引人注目:

首先,进行分配的成本非常低 - 与传统分配器一样,没有搜索免费块;只需移动可用区域和已分配区域之间的边界。 分配成本低意味着最引人注目的入池原因根本不存在。

其次,如果你选择预先分配,你当然会进行比眼前需求所需的更多分配,这反过来又可能强制进行不必要的其他垃圾回收。

最后,垃圾回收器无法为手动回收的对象回收空间,因为从全局角度来看,所有这些对象(包括当前未使用的对象)仍 处于活动状态。 你可能会发现,大量内存被浪费在保持随时可用的但手头上没有正在使用的对象。

这并不是说预先分配总是一个坏主意。 例如,你可能希望这样做是为了强制最初将某些对象一起分配,但你可能会发现,它作为常规策略的吸引力不如在非托管代码中。

指针过多

如果创建的数据结构是大型指针网格,则存在两个问题。 首先,将有很多对象写入 (见下面的图 3) ,其次,当收集该数据结构时,你会让垃圾回收器遵循所有这些指针,并在必要时在移动时更改它们。 如果数据结构是长期存在的且不会发生太大更改,则收集器只需在第2 代级别) (发生完整集合时访问所有这些指针。 但是,如果你在暂时的基础上创建这样的结构,比如作为处理事务的一部分,那么你将更频繁地支付成本。

图 3. 指针中的数据结构繁重

指针中较重的数据结构也可能会有其他问题,与垃圾回收时间无关。 同样,如前所述,创建对象时,它们按分配顺序连续分配。 例如,如果要通过从文件还原信息来创建大型(可能很复杂)的数据结构,则这是很好的选择。 即使具有不同的数据类型,所有对象都将在内存中靠近,这反过来又有助于处理器快速访问这些对象。 但是,随着时间的流逝和数据结构的修改,新对象可能需要附加到旧对象。 这些新对象将在稍后创建,因此不会靠近内存中的原始对象。 即使垃圾回收器确实压缩了内存,你的对象也不会在内存中四处乱排,它们只是“滑动”在一起以消除浪费的空间。 随着时间的推移,由此产生的混乱可能会变得如此严重,以至于你可能倾向于制作整个数据结构的新副本,所有结构都包装得很好,并让旧的无序的一个在适当的时候被收集器谴责。

根太多

当然,垃圾回收器必须在回收时对根进行特殊处理-它们始终必须被枚举并依次进行适当考虑。 第0 代集合只能快速到你不给它大量根考虑的程度。 如果要创建一个在其局部变量中具有许多对象指针的深度递归函数,则结果实际上可能相当昂贵。 这种成本不仅因必须考虑所有这些根而产生,还产生于那些根可能保持活动时间不长的超大量第0 代对象, (下面) 讨论。

对象写入过多

再次提到我们之前的讨论,请记住,每次托管程序修改对象指针时,也会触发写入屏障代码。 这很糟糕,原因有两个:

首先,写入屏障的成本可能与最初尝试执行的操作的成本相当。 例如,如果在某种枚举器类中执行简单的操作,你可能会发现需要在每个步骤中将一些键指针从main集合移动到枚举器中。 这实际上是你可能想要避免的,因为由于写入障碍,复制这些指针的成本实际上增加了一倍,并且你可能必须在枚举器上每个循环执行一次或多次。

其次,如果你实际上是在较旧的对象上写入,则触发写入屏障会更糟糕。 修改旧对象时,可以有效地创建其他根,检查 (上面讨论的) 下次垃圾回收时进行。 如果你修改了足够多的旧对象,你会有效地否定与仅收集最年轻的一代相关的通常速度改进。

当然,这两个原因与在任何类型的程序中不执行太多写入的常见原因相得益彰。 在所有方面均等的情况下,最好 (读取或写入来减少内存,实际上) 以便更经济地使用处理器的缓存。

几乎寿命长的对象太多

最后,代代垃圾回收器最大的陷阱可能是创建许多对象,这些对象既不是临时的,也不是长寿的。 这些对象可能会导致很多麻烦,因为它们不会由第0 代集合 (最便宜的) 进行清理,因为它们仍然是必需的,它们甚至可能在第1 代集合中幸存下来,因为它们仍在使用中,但之后它们很快就会死亡。

问题在于,一旦对象到达第2 代级别,只有完整回收才能将其删除,并且完整回收的成本足以使垃圾回收器在合理可能的情况下延迟它们。 因此,拥有许多“几乎长期”的对象的结果是,你的第2 代将倾向于增长,可能以惊人的速度增长:它可能不会像你想要的那么快地清理,当它被清理时,这样做的成本肯定会比你希望的要高得多。

为了避免这些类型的对象,最佳防线如下所示:

  1. 分配尽可能少的对象,并适当注意正在使用的临时空间量。
  2. 将生存期较长的对象大小保持在最小值。
  3. 在堆栈上保留尽可能少的对象指针, (这些指针是根) 。

如果你执行这些操作,你的第0 代集合更有可能非常有效,并且第1 代不会增长得非常快。 因此,第1 代回收可以降低执行频率,当谨慎执行第1 代收集时,中等生存期对象将已死亡,并且此时可以廉价地进行恢复。

如果情况非常出色,那么在稳定状态操作期间,第2 代大小根本不会增加!

终止

现在,我们已经用简化的分配模型介绍了几个主题,我想稍微复杂一点,以便我们可以讨论一个更重要的现象,即终结器和终结的成本。 简言之,终结器可以存在于任何类中,它是一个可选成员,垃圾回收器承诺在回收该对象的内存之前对其他死对象调用该对象。 在 C# 中,使用 ~Class 语法指定终结器。

终结如何影响集合

当垃圾回收器第一次遇到一个对象时,该对象本来是死的,但仍需要完成该对象,它必须放弃在该时间回收该对象的空间的尝试。 而是将 对象添加到需要最终确定的对象列表中,此外,收集器必须确保对象中的所有指针在完成完成之前保持有效。 这基本上与从收集器的角度来看,需要最终化的每个对象都像临时根对象一样。

集合完成后,正确命名的 终结线程 将遍历需要终止的对象列表并调用终结器。 完成此操作后,对象将再次变为死状态,并会以正常方式自然收集。

完成和性能

通过对最终完成的这一基本了解,我们已经可以推断出一些非常重要的事情:

首先,需要终止的对象比不需要终止的对象生存时间长。 事实上,他们可以活 更长。 例如,假设需要完成第2 代中的对象。 将计划完成,但对象仍在第2 代中, 因此在下一代2 集合发生之前不会重新收集该对象。 这确实可能是很长的时间,事实上,如果事情进展顺利, 这将是 很长一段时间,因为第2 代集合是昂贵的,因此 我们希望 它们很少发生。 需要最终确定的较旧对象可能需要等待数十个(如果不是数百个)第 0 代集合,然后才能回收其空间。

其次,需要终结的对象会导致附带损害。 由于内部对象指针必须保持有效,因此,不仅直接需要终止的对象在内存中挥之不去,而且对象引用的所有内容(直接或间接)也将保留在内存中。 如果一个巨大的对象树由需要最终确定的单个对象锚定,那么整个树将挥之不去,可能会像我们刚才讨论的那样持续很长时间。 因此,请务必谨慎使用终结器,并将其放置在具有尽可能少的内部对象指针的对象上。 在我刚才给出的树示例中,通过将需要最终确定的资源移动到单独的对象,并在树的根目录中保留对该对象的引用,可以轻松避免此问题。 通过这种适度的更改,只有一个对象 (希望一个漂亮的小对象) 会挥之不去,并最大程度地降低最终成本。

最后,需要完成的对象为终结器线程创建工作。 如果最终确定过程很复杂,则唯一的终结器线程将花费大量时间执行这些步骤,这可能会导致积压工作,从而导致更多对象等待完成。 因此,终结器应尽可能少地完成工作,这一点至关重要。 另请记住,尽管所有对象指针在最终确定期间仍然有效,但这些指针可能指向已最终确定的对象,因此可能不太有用。 通常,即使指针有效,也最安全的做法是避免在最终代码中关注对象指针。 安全、简短的终结代码路径是最佳路径。

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 垃圾回收器提供了一个高速分配服务,它很好地利用了内存,没有长期碎片问题,但可以执行比最佳性能少得多的事情。

若要充分利用分配器,应考虑以下做法:

  • 分配所有内存 (或尽可能多地) 与给定数据结构一起使用。
  • 删除可以避免的临时分配,但复杂性很少。
  • 最大程度地减少写入对象指针的次数,尤其是写入旧对象的次数。
  • 降低数据结构中指针的密度。
  • 尽可能限制使用终结器,然后仅在“叶”对象上使用。 如有必要,请中断对象以帮助执行此操作。

定期查看关键数据结构和使用分配探查器等工具执行内存使用情况配置文件,对于保持内存使用有效并使垃圾回收器为你发挥最大效用大有裨效。