《垃圾回收算法手册 自动内存管理的艺术》——堆内存的划分、分代回收(笔记)

八、堆内存的划分

到目前为止我们都假定垃圾回收以这样的方式进行:所有的对象是由相同的垃圾回收算法管理的,并且所有垃圾将在同一时间得到回收。

然而这个假设并不是必须的,如果我们对不同的对象加以区别对待的话,在回收处理的性能上将得到相当大的好处。

最广为人知的例子就是所谓 “分代回收算法”(generational collection) ,该算法将对象按不同的年龄(age)进行分隔,并优先回收更年轻的对象。

对象的管理可以用:

  • 采用直接算法(如引用计数)
  • 采用间接算法,即追踪算法来完成。

追踪算法可能:

  • 会移动对象(标记—整理算法或复制算法)
  • 不移动(标记—清扫算法)。

于是我们会考虑是否希望垃圾回收器移动不同类别的对象,如果是,如何移动它们为佳。

我们可能希望能够依照对象的地址快速判断出应当对其使用何种回收或分配算法。

最一般的情况是,我们可能希望区分何时回收不同类别的对象。

8.1 术语

区分那些我们想把某些特定的内存管理策略应用其上的对象集合是有用的,同时我们也可以使用一些机制来更有效地实现这些内存管理策略。

扫描二维码关注公众号,回复: 16058494 查看本文章

我们将使用术语 “空间”表示那些使用相同处理方法的对象的逻辑集合

一个空间可能会使用一个或多个地址空间中的内存块。所有的块都是连续的,大小通常为2的整数次幂,并按2的整数次幂地址对齐。

8.2 为何要进行分区

将堆分割成几个分区,每个分区采用不同管理策略或不同机制的做法通常是有效的。

这些想法最初出现在Bishop的那些很有影响力的论文中。

使用内存分区处理的原因包括对象的

  • 移动性
  • 大小
  • 更低的空间消耗
  • 更简单的对象性质识别
  • 垃圾回收效率的改善
  • 停顿时间的降低
  • 更好地实现局部性

在我们考虑特定的垃圾回收模型和利用堆内存划分方法来进行对象管理之前,我们先来检验一下这些原因。

8.2.1 根据移动性进行分区

在一个 混合回收器(hybrid collector) 中,识别哪些对象可移动,哪些对象不能移动或移动代价很大是十分必要的。

当运行时系统和编译器之间缺乏沟通或者一个对象被传给了操作系统(例如,一个IO缓冲区)时,对象就很有可能无法移动。

Chase 指出异步移动对象可能还会不利于编译器的优化。

  • 为了移动一个对象,我们必须能够找到所有指向这个对象的引用,以便将每一个引用改为指向对象的新位置。

  • 相反地,如果回收过程是不移动对象的,则追踪式回收器只需要找到至少一个引用就足够了。

因此,当一个引用被传给一个根本不关心垃圾回收的程序库(例如,传给Java的原生接口)的时候,该对象就不能被移动了。

此时,要么这个对象必须被钉住(pinned),要么我们必须保证对象在被程序库访问期间垃圾回动作收不能在其空间中执行。

那些因为所指对象被移动而必须被更新的引用包含了根集合中的元素。然而确定根引用与待移动对象之间精确的映射关系对构建托管语言与其运行时环境之间的接口来说是一个更富挑战性的工作。

最初实现的流程通常是保守地扫描根集合(线程栈和寄存器),而不是构建一个 栈帧槽(stack frame slot) 等与其所含对象引用之间的 类型精确(type-accurate) 的映射。

当编译器(例如,C和C++编译器)不能提供精确类型信息时更是如此。保守式栈扫描将每一个栈帧中的 槽(slot) 都当作一个潜在的索引项,并使用一些测试来丢弃那些不可能是指针的值(例如,那些超出堆内存范围的值或指向的位置没分配任何对象)。

由于保守的栈扫描标明了一个栈中所含有的真实指针槽的超集,所以改变这些槽里的数据就是不可能的了(因为我们可能会在不经意之间更改某一整数, 而这个整数可能刚好就是一个指针)。

超集:如果一个集合S2中的每一个元素都在集合S1中,且集合S1中可能包含S2中没有的元素,则集合S1就是S2的一个超集

因此保守的垃圾回收算法不能移动任何被根直接引用的对象。

然而如果可以适当地给出堆中对象的一些信息(可以不必是完整的类型信息),一个 主体复制(mostly-copying) 垃圾回收器就可以安全地移动除了 模糊根(ambiguous roots) 集合直接可达的对象之外的任何对象。

8.2.2 根据对象大小进行分区

在某些情况下移动对象可能是不合适的(但并非不能移动)。例如,移动大对象的代价可能比因不移动它们所产生碎片的代价更大。

一个常用的策略是,将大小超过特定阈值的对象分配到一个专门的大对象空间( large object space, LOS)里去。我们在之前的章节中已经看到过分区适应分配器是如何区别对待大对象和小对象的。

大对象通常被放置在独立的内存页(所以大对象的尺寸至少应该是内存页大小的一半),并且通过一个像“标记—清扫”这样不移动对象的回收器来进行管理。

值得注意的是,通过将对象放置到它们的专属内存页,我们既可以使用Baker的 转轮(treadmill) 回收器,也可以通过重新映射虚拟内存页的方式来实现虚拟“复制”

8.2.3 为空间进行分区

对象隔离在减少堆空间的整体需求方面可能很有用。

在一个由支持快速分配并提供良好空间局部性(当一个对象序列被分配并初始化的时候)的策略管理之下的空间中创建对象是十分可取的做法。

Blackburn等 证明连续分配和空闲链表这两种内存分配方法之间的代价差异是非常小的(只占总执行时间的1%),两者之间真正的差异主要是由两种分配方法对局部性改进程度的 二阶效应(second order effect of improved locality) 决定的。

特别是对年轻对象,如果把它们按照分配顺序排列,将会为其带来一些 额外的好处。

复制和滑动回收器都可以消除碎片并允许顺序内存分配。但是复制式回收器需要的地址空间是非移动式回收器的两倍,而标记—整理回收方式则相对较慢。

因此将对象进行隔离通常是有益的,这样便于让不同的内存管理器来管理不同的内存空间。

对于那些生命周期较长并且内存碎片对其并非急需处理的对象来说,我们可以将其保存在一个主要是非移动操作、偶尔会进行某趟扫描来整理的空间里面。而那些分配频率、死亡率更高的对象则可以放到一个由分配速度快且回收代价小的复制式回收器管理的空间中去(相对于此类存活对象的数量比例来说,回收代价相对较低)。

需要注意的是,为大对象预留复制空间的高昂代价是我们采用非复制式回收器来管理大对象空间的一个更深层次因素。

8.2.4 根据类别进行分区

将不同类别的对象进行物理隔离可以让“类型”这样的属性通过对象地址就可以简单地识别出来,而无需获取对象某一字段的值或更糟的是去追踪一个指针。

除此之外,这样做还有以下几点好处。

  1. 这种做法充分利用了高速缓存的优势,因为它消除了加载更多字段的必要性(特别地,当某一特定类别对象的位置为静态时,地址之间的比较可对应于一个编译期的常量)。

  2. 通过属性来进行对象隔离,把所有共享相同属性的对象放置在相同的连续内存块中,我们就可以对空间进行基于地址的快速识别,同时此举也使得空间和对象属性关联了起来,而不用将相同的属性值在每个对象的头部复制一份。

  3. 对象的类别对于某些回收器来说是非常关键的。那些不含指针的对象是不需要被追踪式回收器扫描的。鉴于处理一个大型指针数组的开销很可能是由移动指针而不是像移动对象这样的开销决定的,所以将大型的与指针无关的对象保存在它们自己独立的空间里是有益的。当 大型压缩位图(compressed bitmaps) 成为 伪指针(false pointers) 的一个经常性来源时,如果把这些位图放到一个永远也不会被扫描的区域,保守式垃圾回收器会因此受益。如果将那些不可能成为 垃圾环路(garbage cycle) 备选根的天生 非循环(inherently acyclic) 对象隔离保存的话,可回收循环引用垃圾的追踪回收器将因此获益。

虚拟机通常在堆内存中生成并保存 代码序列(code sequence) 。移动和清理代码会有一些特殊的问题,如确定和保持一致性、 代码的引用或确定代码何时不再使用并因此可以被卸载(注意类的重新载人通常并不透明,因为类可能是有状态的)。代码对象也通常是体积庞大且长寿的。

由于这些原因,不迁移代码对象通常是可取的做法, 同时也可将卸载代码作为针对特定应用程序的特殊情况来考虑。

8.2.5 为效益进行分区

进行对象隔离最著名的理由是要充分利用对象统计信息。某些对象从构建出来开始到程序结束一直被使用而另一些对象的生命周期却很短,这种现象很常见。

早在1976年,Deutsch和Bobrow就发现“统计显示,新分配的数据很可能是要么被‘ 敲定’,要么在相对较短的时间内被抛弃”。

在Java程序中,一种十分普遍的现象是大多数 分配点(allocation point) 所创建的对象的寿命符合 双峰(bimodal) 寿命分布。

大量研究已经证实,很多(而非全部)应用程序中对象的生命周期特征满足 弱分代假说(weak generational hypothesis) , 即“大多数对象都在年轻时死亡”。 无论是分代还是 准分代(quasi-generational) ,众多垃圾回收策略的着眼点是将回收的重点集中到那些最有可能成为垃圾的对象上面,以达到花费最少的努力来尽量多地清理存储空间的目的。

如果对象生命周期的分布是足够偏斜的,则反复清理一个(或多个)堆内存的子集而非整个内存就是值得的。

例如,分代的垃圾回收器通常在每次回收整个堆之前就已经对堆中的某一空间(新生代或幼儿区)进行了多次回收。

需要注意的是,这里面有一个权衡的考虑:

  • 由于不用在每次回收过程中都追踪整个堆,所以回收器将允许一些垃圾不被清理(在堆中漂浮)。这就意味着,分配新对象时系统的可用空间是小于其应有大小的,因此垃圾回收器也会比在理想情况下调用得更频繁。

更进一步,正如我们稍后会看到的那样,将堆内存分隔为已回收空间和未回收空间将会使赋值器和回收器增加更多簿记管理的负担,但倘若被选为进行回收操作的空间中的对象存活率足够低的话,分隔回收策略可能会非常有效。

8.2.6 为缩短停顿时间进行分区

对于一个追踪式回收器来说,回收操作的代价主要取决于需要进行追踪的存活对象的数量。

如果使用复制式回收器,则清理操作的代价仅依赖于存活对象的数量,甚至在标记—清扫回收器中,追踪操作的代价也远超清扫操作。

通过限制回收器需要追踪的 定罪空间(condemned space) 的大小,我们就可以限定需要清理或标记的对象的数量,从而控制垃圾回收所需的时间。

在一个需要万物静止的回收器中,这样做就意味着可以缩短停顿时间。不幸的是,回收堆内存的一个子集改善的仅仅是预期的时间,因为对某个单一的空间回收之后所返回的空闲内存可能仍无法让计算过程继续进行,所以还是需要回收整个堆。因此,一般而言,分区回收不能降低最坏情况下的停顿时间。

在某些极端情况下,分区策略可以使一个空间能够在常数时间内被清理完。

如果一个定罪区域中的所有对象从该区域之外都是不可达的,则对于回收器来说,清理该区域时不需要进行任何追踪工作:

  • 该区域所占内存可以被一并返回给分配器。

要判定一个区域是不可达的,需要将合适的对象访问规则与堆结构(例如有界区域的栈)有机地结合起来。

正确使用的责任通常全都落在程序员身上(正如Java的实时规范中定义的那样)。然而,如果能给一个类似ML这样合适的语言,这些区域还是可以被自动推断出来的。

通过一个允许对某些类型和区域引用进行推断的复杂的类型系统,C语言的扩展Cyclone可以有效地减轻程序员的负担。

8.2.7 为局部性进行分区

随着内存层级变得日益复杂(更多的层级、多CPU核心、多插槽,以及非一致性内存访问),局部性对于性能的重要性不断增加。简单的回收器与虚拟内存和高速缓存的交互往往比较差。作为追踪动作的一部分, 追踪式回收器需要接触每一个存活对象。


标记—清扫回收器同样可能需要接触已经死亡的对象。复制式回收器则可能会接触每一个堆内存页,即使在任意时间点只有一半的内存是用来保存存活对象的。

研究人员长期争论的一个问题是,垃圾回收器不应该只是简单地用来清理垃圾,还应该用来改善整个系统的局部性。

我们在第4章已经看到如何改变复制式回收器的遍历顺序,以便通过父子对象共置的方式提高赋值器的局部性。

分代回收器可以使回收器和赋值器的局部性都得到进一步的改善。该回收器得益于将大部分精力集中在堆的某一个分区上,这种做法有利于用最小的代价获取最多的空闲空间。

减少工作集的大小对于赋值器也是有益的,因为年轻对象的变化率通常比老对象要高。

8.2.8 根据线程进行分区

垃圾回收动作需要在赋值器线程和回收器线程之间进行同步。

对于每次只需中止不超过一个赋值器线程的 即时回收(on-the-fly collection) ,其可能需要一个与其他赋值器线程进行握手同步的复杂系统才能实现,而即使是万物静止的回收算法也需要通过同步来中止所有赋值器线程的运行。

如果我们每次只中止一个线程,并仅回收由该线程分配的且尚不能从其他线程可达的那些对象,则同步代价是可以减少的。

为了做到这一点,回收器必须能够区分出哪些对象是仅单个线程可访问的而哪些对象是被多个线程共享的,例如可以使用线程本地 子堆(heaplet) 进行分配。

在一个更大的粒度上,识别访问特定任务的对象可能是有用的,其中的“任务”是由一个互相协作的线程集合组成的。

例如,一个服务器上可能有多个应用程序在运行,而每一个应用程序通常要求它们自己的虚拟机整个被载入并初始化。与此相反,多任务虛拟机(multi-tasking virtual machine, MVM)则允许多个应用程序(任务)在单个多任务虚拟机中运行。

我们需要小心谨慎,以确保不同的任务不能直接(访问其他任务的数据)或间接地(拒绝其他任务对如内存、CPU时间等系统资源的公平访问)影响对方。若能既不干扰其他任务(例如,不需要运行垃圾回收),又能在完成之后将与本任务相关的所有资源释放掉就更好了。

通过将属于不同线程的非共享数据进行分隔,所有这些问题的处理都能得到简化。

8.2.9 根据可用性进行分区

我们不希望接触其他线程可达对象的一个原因是为了降低同步开销。

然而,我们可能也希望将对象按用途分区,因为回收器可能会根据对象的物理分布采取不同的处理策略。

在一个应用程序服务器中,作为客户端请求的一部分而初始化的远程对象往往比本地对象存活的时间更长

扩展Sun的HotSpot分代回收器来识别并专门处理这些对象,可以有效提高服务器的工作负载。

更一般地,在一个由分布式垃圾回收管理的系统中,最好能够用不同的策略和机制来管理远程和本地的对象与引用,因为访问远程对象的代价比访问本地对象大几个数量级。

分布式并不是导致对象访问代价不一致的唯一因素。之前我们花了很大精力研究追踪式回收器如何最小化高速缓存不命中的代价。高速缓存不命中的代价可能是几百个时钟周期,而访问一个所在内存页被交换出去的对象的代价将是几百万个时钟周期。

对于一个早期的分代回收器来说,避免频繁缺页是需要优先解决的问题,时至今日,导致频繁换页的配置可能会被认为是在进行不可救药的破坏。

物理页的组织(在内存中或换出了)可以被认为是另一种形式的堆分区,是一个切实可资利用的特性。书签(Bookmarking) 回收器通过与虚拟内存系统配合协作,来改进对于被换出页的选择(从回收器视角),并使回收器可以在无需访问非驻留内存页里对象的情况下完成追踪操作。

同样地,非一致内存访问的计算机有一些 内存银行(memory bank) ,这些内存银行和某些处理器挨得比较近。Sun的Hotspot回收器可以识别这一属性,并优先在“附近的”内存中分配对象,对于那些访问不同内存区域耗时差异显著的大型服务器,这一策略可以最大限度地降低内存访问时延。

8.2.10 根据易变性进行分区

最后,我们可能希望根据对象的易变性来对其进行分区。

相较于存活时间较长的对象,那些新创建的对象往往被修改的更频繁(如初始化其属性字段) 。基于引用计数的内存管理往往会产生很高的 每次更新(per-update) 开销,因此不大适合管理那些更新频繁的对象。

另外,在非常大的内存堆中,可能在任意时间周期内都只有相对一小部分对象被更新,但一个追踪式回收器却必须访问所有的垃圾候选对象。引用计数在该场景下可能更为适用。


Doligez和Gonthier通过易变性(和线程原则)将ML对象分隔开来,他们为每个线程配备了私有的不可变对象空间、非共享对象空间,同时所有线程共享同一个空间。

他们的模式需要一个关于引用的很强的属性:

  • 线程本地堆外(其他线程的本地堆或共享空间)的指针没有指向该堆里的任何对象。

通过把更改的内容用 写时复制(copy on write) 的方法同步到共享内存区的策略,我们成功避免了线程本地堆内存被外部引用。这在语义上是透明的,因为引用的目标是不可变的。

综上所述,这些属性让每个线程的私有堆都可以被异步回收。

该方法的一个额外的优势是,与其他大多数各自独立回收每个空间的策略不同,该方法不需要追踪跨空间的指针(虽然赋值器仍必须检测这些指针)。

8.3 如何进行分区

  • 方法1

将堆内存分成互不重叠的地址段。在最简单的情况下,每个空间占据连续的堆内存块,因此这种映射是一对一的。把这些内存块按2的整数次幂地址边界对齐将是更为有效的做法。

在这种情况下,对象所属的空间信息就相当于被编码到地址的最高位,并可以通过移位或掩码操作来找到具体位置。一旦获知了空间的特性,回收器就可以决定如何处理其中的对象了(例如,标记、复制、忽略等)。

如果能在编译期获知空间的 布局(layout) ,则这种尝试(即与一个常量进行比较)可能会特别有效。此外,若把这些二进制位看成内存块表的索引,我们就可以对空间进行查找操作了。

然而,对32位操作系统的内存使用来说,将内存分割成连续的内存区可能不是最有效的方案。

  1. 这些区域占据的虚拟地址空间必须是事先保留的。虽然那些即时的 划进/划出(mapped in and out) 连续空间的操作并不会提交物理内存页,但连续的地址空间或多或少会感觉不大灵活并可能导致虚拟内存耗尽,即使系统中仍有足够的物理内存页可用。

  2. 在很多情况下操作系统都趋向于把类库的代码段映射到不可预知的地方——有时是故意为之,以便于改善系统的安全性。这使得预留大块连续虚拟地址空间的操作变得非常困难。

在大部分情况下,改用64位地址空间就可以有效解决这些问题。

  • 方法2

将空间实现为非连续内存块的地址空间集合。不连续空间通常都包含几组
连续地址空间,而每段地址空间又是由固定大小的帧组成的。

如前所述,如果帧地址是依照2的整数次幂边界对齐,且为操作系统页大小的整数倍,则对于帧的操作将会更高效。

同样地,这样做的缺点是在获取对象的空间信息时需要查表。通过把对象在物理上分隔开以实现特定空间的做法并不总是必要的。相反,对象所属的空间可以通过在其头部添加若干位的方式来加以标识。

虽然这使我们无法通过一个快速的内存比较来对空间进行判定,但这种方法或多或少还是有一些优点的。

  1. 这种方法允许我们根据像年龄或线程可达性这样的运行时属性来进行对象划分,即使是在回收器不移动对象的情况下也适用。

  2. 该方法可能会有助于处理那些需要被暂时钉住的对象(例如那些代码可访问但回收器没察觉到的对象)。

  3. 运行时的动态分区可能会比静态分区更精确一些。例如,静态逃逸分析( escape analysis) 提供了一个对象是否可能被共享的保守估计。虽然Hones和King 说明了如何在线程本地分配场景中获得更为精确的 静态逃逸估计(estimateofescapement) ,静态分析还是不能适应超大型程序的需求,而且动态类加载技术的出现通常迫使该分析更加保守化。如果对象的逃逸是被动态追踪的,则当前线程本地对象和那些正在(或已经)被超过一个线程访问的对象之间的差别就很明显了。

逃逸指对象脱离了原线程本地内存的范围,成为了共享对象。

动态分隔的缺点是会引人更多的 写屏障(write barrier) 。一旦一个指针更新操作使其所指对象成为潜在的共享状态,则其所指对象和传递闭包都必须被标记为共享。

8.4 何时进行分区

分区的决策既可以静态(在编译期)完成,也可动态进行一当一 个对象被创建时, 或者在垃圾回收期间,或者当赋值器访问对象的时候。

最著名的分区方案是按对象的 分代(generational) 进行划分,在此方案中,对象根据其年龄被分隔开来,但这只是根据对象年龄的相关属性进行分区的形式之一。

与年龄相关的回收器根据对象的年龄将其分隔成若千个空间。在这种情况下,分区是由回收器动态执行的

当一个对象的年龄增长超过某个阈值的时候,该对象将被提升(从物理上或逻辑上移动)到下一个分代的空间中。

由于在移动对象方面存在一些限制,将对象进行分隔的操作可能是由回收器来完成的。

例如,当某些对象被钉住时,大多数复制回收器可能无法移动它们一这 些对象被某些不会意识到它的位置可能会变动的代码访问。


分区的决策也可能是由分配器完成的

最常见的情况是,分配器会根据一个分配请求所需的内存大小来决定对象是否应该被分配到大对象空间中。在系统支持的对程序员可见的显式内存区或可以由编译器推测得到的区域(如作用域)内,分配器或编译器可以将对象放置
到一个指定的区域中去。除非被明确告知对象是共享的,否则位于线程本地系统内的分配器会把对象放到该执行线程的本地子堆中。一些分代系统会尝试把一个新对象和指向它的那些对象置于相同的区域里,因为无论如何该对象最终都会被提升到那里。

通过对象的类型、代码或者一些其他的分析,对象所属的空间可以被静态地划定。如果可以提前预知某种类型的对象都存在某种公共属性,如 永生性( immortality) ,则编译器就可以决定应该将这些对象分配到哪个空间,并生成适当的代码序列。

分代垃圾回收器通常在为新对象预留的一块新生代区域中创建对象,随后,回收器将会把其中一些对象提升到更老的分代中去。然而,如果编译器“知道”某些对象(例如,那些在代码中某一特定 地点创建的对象)通常会得到提升,则编译器可以把它们直接 预分配(pretenure) 到年老代里面去。

最后,当堆内存被并发回收器(见第15章)管理的时候,对象还可以被赋值器重新划分。赋值器访问对象的操作可能会被读屏障或写屏障居中调节( mediated),这些都会导致一个或多个对象被移动或标记。对象的颜色(黑色、灰色、白色)以及持有对象的新/旧空间都可以被认为就是-一个分区。分配器可以根据其他–些属性来动态地区分对象。

如上所见,当对象逃逸出它诞生的线程时,Domani等人使用的写 屏障就可以把它们从逻辑上分隔开来。通过与操作系统进行协作,运行时系统可以在对象所在页被换人、换出时将对象进行重新划分。

九、分代垃圾回收

垃圾回收器的主要目的是找到已经死亡的对象并回收它们所占用的空间。在对象数量较少的情况下,追踪式垃圾回收器(特别是复制式垃圾回收器)能够最高效地进行回收。但长寿对象的存在却会影响回收效率,因为回收器不是反复地对其进行标记、追踪,就是反复地把它们从一个半区复制到另一个半区。

第3章提到,在标记—整理回收器中,长寿对象会积累在堆底,并且回收器通常会避免对这些底部的“沉积物"进行处理。虽然这一策略可以减少移动长寿对象的次数,但是回收器仍然需要对它们进行扫描,也需要对它们所包含的引用进行更新。

分代垃圾回收是对上述算法的进一步改进与提升。在任何时候,分代垃圾回收算法都尽可能少地去处理长寿对象。

弱分代假说(weak generational hypothesis) 告诉我们,大部分对象都在年轻时死亡,因此可以利用这一特性尽量提高回收效益(即回收所得到的空间),同时减小回收开销。

分代垃圾回收算法依照对象的寿命将其划分为不同的 分代(generation) ,同时将不同分代的对象置于堆中不同的区域。回收器会优先回收年轻代对象,并将其中寿命够长的对象 提升(promote) 到更老的一代。

大多数分代垃圾回收算法通过复制的方法来处理年轻代对象。如果被处理的分代中存活对象的数量足够少,则其 标记/构造率(mark/cons ratio) 会降低,即回收器在一次回收过程中所需要处理的数据量与分配器在相邻两次回收之间所分配的数据量之间的比例较低。

回收器处理年轻代对象所需要的时间主要取决于年轻代的整体大小,因此,回收某一分代的期望回收停顿时间可通过调整分代大小的方式来实现。在现有硬件条件下,一个配置合理的垃圾回收器(在运行符合弱分代假说的程序时)完成一次年轻代垃圾回收所需的时间通常是10ms级别。

假定两次垃圾回收的时间间隔足够长,则该类型垃圾回收器可以满足绝大多数应用程序的要求。某些情况下,分代垃圾回收器需要对整个堆进行回收。

例如可用内存已经耗尽,或者仅仅依靠回收年轻代所能获取的内存已经不足。因此分代垃圾回收器最多只能改善期望停顿时间(而非最大停顿时间),这显然不能满足实时系统的要求。分代垃圾回收可以通过减少对年老代对象的处理次数来提升内存吞吐量,但这需要付出一定的额外开销。

我们知道,仅针对年轻代对象的回收并不能回收任何一个年老代垃圾对象,同时回收器也无法及时回收已经成为垃圾的长寿对象。因此,为了能够独立回收某一分代,分代回收器必须在赋值器之上维护一个额外的记忆集来记录分代间指针。与分代回收带来的收益相比,这一开销是值得付出的。

如何设计分代垃圾回收器才能同时达到提升吞吐量与减少停顿时间的目的,是一门精妙的艺术。

9.1 示例

在这里插入图片描述

图9.1是分代垃圾回收的一个简单示例,它使用两个分代。新对象诞生在年轻代。在每一次年轻代对象回收过程中,如果某一对象的寿命够长,则回收器会将其提升到年老代。

在进行第一次回收之前,

  • 年轻代包含4个对象:N、P、V、Q
  • 年老代包含三个对象: R、S、U
  • 对象R和N同时被堆之外的根或者其他对象引用

假设对象N、P、V已经存活了一段时间,但是对象Q却是在回收过程开始之前刚刚创建的。此时,将哪个年轻代对象提升到年老代是一个十分重要的问题。

分代垃圾回收器的一个重要任务便是把寿命够长的年轻代对象提升到年老代

要达到这一目的首先需要一种测量对象寿命的方法,并通过某种方法将其记录。

在图9.1中的年轻代对象里,对象N直接被根对象引用,对象P、Q通过年老代对象R、S间接地被根对象引用。绝大多数分代垃圾回收器不会扫描整个堆,而只会扫描正在进行回收的一代中的对象。为达到这一目的,分代垃圾回收器必须额外维护一个分代间指针集合,例如,该集合会记录对象s,通过该集合便可快速地找到对象P和Q。

有两种情况会导致分代间指针的出现

  1. 赋值器在一个年老代对象中写人一个指向年轻代对象的指针
  2. 回收器将一个年轻代对象提升到年老代, 如图9.1中将对象P而Q提
    升到年老代。

不论哪种情况,都可以通过一个 写屏障(write barrier) 来检测分代间指针的产生。也就是说,为捕获分代间指针赋值器,必须通过写屏障来进行指针写操作,回收器也必须依赖一个简单的复制写屏障来对提升过程中出现的分代间指针进行探测。

在图9.1所示的例子中,所有包含对回收过程有用的分代间指针的对象(即: S、U)都会被记录在 记忆集(remset) 中。

不幸的是,将分代间指针作为次级回收根集合的方法加剧了浮动垃圾问题:尽管次级回收的频率较高,但其无法对年老代对象进行回收(如对象U)。

更糟糕的是,U持有一个分代间指针,这导致回收器必须将其当作年轻代的根集合来对待。这一 “庇护” (nepotism) 效应将导致回收器把对象V (即对象U的后代)提升到年老代(而不是将其回收),从而进一步减少了年老代的可用空间。

9.2 时间测量

将对象 依照寿命进行分代 的前提是如何去 测量对象的寿命

对象寿命的测量有两种方式:

  1. 基于对象所经历的时间
  2. 基于堆所分配的字节数

墙上时钟对于理解系统的外部行为是有用的,程序运行的总时间是多少?垃圾回收过程的停顿时间是多少?停顿时间如何分布?这些问题的答案决定了系统是否满足预定的设计目标:

  • 是需要足够多的时间去执行任务,还是需要足够快的响应速度。

后者既是人机交互系统的要求,也是硬实时系统(如嵌入式系统)或者软实时系统(允许少量的交互延迟)的要求。

然而,确定对象寿命的更好方法统计该对象存活期间堆所分配的字节数

尽管64位系统的指针和整数会比32位系统占用更多空间,但空间分配在很大程度上仍是一种不依赖机器的测量方法。字节分配数同时也直接反映出内存分配器的压力,这一压力很大程度上与垃圾回收过程的频率相关。

不幸的是,在多线程系统下(此时系统中包含多个程序或系统线程),以堆分配的字节数来测量寿命存在一定的困难:

  • 计数器会受到一些与垃圾回收过程无关的内存分配的影响,所以如果简单地统计整体内存分配情况,测量结果可能偏大。

实际应用中的分代垃圾回收器一般会将对象所经历的垃圾回收次数作为该对象的寿命,这样不仅记录更加方便,而且占用的额外空间较少,但这只是字节分配测量方法的一个近似替代。

9.3 分代假说

弱分代假说(weak generational hypothesis) 的含义是:

  • 大多数对象都在年轻时死亡。

这一假说已经在各种不同种类的编程范式或者编程语言中得到证实。

(后略)

9.4 分代与堆布局

回收器可以使用多种不同的策略来组织对象的分代。

  • 它们可以在物理上或者逻辑上使用两个或更多分代,也可以将所有分代限制在相同大小的空间内,还可独立维护每个分代的空间大小
  • 分代内部的数据结构可以是 扁平的(flat) , 也可以是一系列基于对象寿命的子空间,称之为 “阶”(step) 或者 “桶”(bucket)
  • 分代内部可以包含存放大对象的特定子空间,每个分代也可以使用不同的算法来维护其中的对象。

分代垃圾回收器的主要设计目标是减少回收过程的停顿时间,同时提升空间吞吐量。如果使用复制的方法对年轻代对象进行回收,那么期望的停顿时间很大程度上取决于 次级回收(minor collection) 之后的存活对象总量,而这一数值又取决于年轻代的整体空间大小。

如果年轻代的整体空间太小,那么虽然一次回收过程很快,但两次回收的间隔过短,年轻代对象没有足够的时间到达死亡,因而回收到的内存不多,这一情况将引发很多不良后果。

  1. 没有足够多的对象可以在短时间内死亡,进而导致年轻代对象的回收过于频繁,且存活下来需要复制的对象数量变多。频繁的垃圾回收会增大回收器停顿线程、扫描其栈上数据的开销。

  2. 将较大比例的年轻代对象提升到年老代会导致年老代被快速填充,进而增大年老代,甚至整个堆的垃圾回收频率。另外,过早地将年轻代对象提升到年老代还会导致 “庇护”现象 的出现:

    • 年老代中的垃圾会使得它们的年轻后代在次级回收中存活下来,所以回收器会将这些年轻后代提升到年老代,从而进一步提高了提升率。
  3. 许多证据表明,对新生对象的修改会比对年老对象的修改更加频繁。如果过早地将年轻代对象提升到年老代,则大量的 更新操作(mutation,变异、改变) 会给赋值器的写屏障带来较大压力,这种现象是不希望出现的。因此我们必须结合系统的真实负载来平衡赋值器和垃圾回收过程的开销。在一个设计良好的系统中,垃圾回收所占用的运行时间一般都小于赋值器。

例如,对于某个 快速路径(fast path) 中只包含少量指令的写屏障,假设其占用整体运行时间的5%,进一步假设垃圾回收占用整体运行时间的10%,其他种类的写屏障则很容易将拦截过程占用的时间翻倍,即额外增加5%的负载。为了补偿写屏障带来的额外负载,垃圾回收器必须将自已的时间消耗减半,但这是很难实现的。

  1. 对象的提升将会使程序的工作集合变得稀疏。因此,分代垃圾回收器的设计是一门对这三方面进行平衡的艺术:不仅要尽量加快次级回收的速度,而且要尽量减少次级回收以及成本更高的 主回收(major collection) 的频率,最后还应当尽量减少赋值器的内存管理开销。

9.5 多分代

如果回收器使用更多的分代,不仅可以快速回收年轻代,而且可以降低年老代的填充速度,进而降低整个堆的回收频率。

中间代能够筛选出那些经历过年轻代回收后仍然存活但很快就会死亡的对象。

如果回收器将年轻代回收的存活对象集体提升,那么其中将包含很多刚刚诞生但在不久的将来就会死亡的对象,而如果使用多分代垃圾回收,不仅可以使年轻代保持较小的整体大小以减少回收停顿时间,而且还可以避免最老代中出现一些很快就会死亡的对象。

大多数系统在回收最老代对象的同时也会回收年轻代,这带来一个好处,即需要记录的分代间指针就会只有一个方向,即从年老代到年轻代,这一方向的引用一般比反向的引用要少。

单同时但多分代垃圾回收器也存在一些缺陷。

  • 尽管对中间分代进行一次回收的时间小于回收整个堆所需要的时间,但是仍然会大于一次年轻代回收所需要的时间。

  • 多分代垃圾回收器的实现通常比较复杂,而且会给回收器的扫描过程带来额外的负担,其关键实现代码也与两分代垃

9.6 年龄记录

9.6.1 集体提升

对象的年龄记录方式与回收器的提升策略紧密相关。多分代是记录对象年龄的一种粗略方法。

图9.2展示了对年轻代进行组织以控制对象提升的四种策略,我们将逐一进行介绍。

在这里插入图片描述
最简单的组织方式是将年老代之外的其他分代当作独立的半区(见图9.2a),当回收器对某一分代进行回收时,直接将其中的存活对象集体提升到下一代

该方案不仅实现简单,而且各年轻代的内存空间得到最大程度的利用,因为回收器不仅无需单独记录每个对象的年龄,也无须为每一代预留专门的复制保留区(最老代使用复制式回收策略的场景除外)。

在这里插入图片描述

图9.3 展示了年轻代对象所经历的回收次数(一次或两次)与其存活率之间的关系。在“大多数对象都在年轻时死亡”这一前提下,图中的曲线展示了t时刻分配的对象在经历数次回收之后依然存活的比例。

从中我们可以看出,对象的创建时间越接近下一轮回收,则其在下一轮回收中存活的几率就越高。我们将注意力集中在第n次和第n+1次回收之间的图形区域。

曲线(b)展示了经历一轮回收后依然存活的对象的比率,从中我们可以看出,大多数对象活不过其所经历的第一轮回收, 即图中的浅灰色区域。

位于曲线(c)下方黑色区域中的对象可以经历两轮回收。

如果我们将一次回收之后所有的存活对象集体提升,则曲线(b)之下的深灰色区域以及黑色区域中的对象都会得到提升,但如果仅提升经历两次回收的对象,则只有曲线(c)下方黑色区域中的对象才会得到提升。

如果每个对象都包含一个 上限大于1的复制计数器,则回收器可以避免提升过于年轻的对象(它们可能很快死亡),从而显著降低提升率。如果将提升的标准设置为大于两次回收,反而可能起到负面效果。

Wilson 指出,如果要将提升率降低一半,则对象在得到提升之前所需经历的复制次数可能会增加四倍或者更多。

9.6.2 衰老半区

使用两个或者多个衰老半区对某一分代进行组织是实现延迟提升的方法之一。

该策略要求对象在得到提升之前必须已经在其所属分代的来源空间与目标空间之间复制了多次。

在Lieberman和Hewitt最初的分代回收器中,分代中的对象只有经历多次回收之后才可以集体提升。

在图9.2b所示的衰老半区中,某一分代的存活对象是被复制到目标半区,还是被集体提升到下一个分代,取决于该分代中对象的整体年龄。

该方案中,尽管较老的对象会有足够长的时间到达死亡,但最年轻的对象依然有可能过早地得到提升。

Sun公司的ExactVM也将年轻代划分为两个半区(见图9.2c),但其对年轻代对象的提升可以精细到单个对象级别,其方法是在对象头域中预留五个位来记录对象的年龄。回收器可以根据对象的年龄来判断是将其提升,还是将其复制到同一分代的目标半区中。该方案虽然可以避免提升最年轻的对象,但是其对年轻代存活对象的处理却会引入一些额外开销。

桶组(bucket brigade)分阶系统(step system) 可以更好地进行对象年龄鉴别,同时也无须为每个对象保留特定的空间以记录其年龄。

该策略将每个分代内部划分为多个子空间,每次回收都会将一个桶或者阶中的存活对象递进式地复制到下一个,同时将最高阶中的存活对象提升到下一个分代。也就是说,在一个阶数为n的分阶系统中,对象在被提升到下一个分代之前必须经历过n轮回收。


Glasgow Haskell 允许将每个分代划分为任意多个阶(默认的配置是年轻代包含两个阶,其他分代只有一个阶), UMass GC Toolkit 也采用相同的策略。

在Shaw 的桶组策略中,每一阶又进一步被划分为两个半区,存活对象必须在阶内的两个桶之间经历过b次复制之后才可能被提升到下一阶,因此对于二阶桶组系统,对象在被提升到下一代之前必须经历2b-1 ~ 2b次复制。

Shaw 还对其策略进行调整以简化对象的提升。

图9.4是其桶组策略的一个实例,其中b=3,即对象在被移动到下一个衰老桶或者被提升到下一代之前,至少要经历3次复制。在Shaw的系统中,各分代在空间上是连续的,因此可以将衰老桶与年老代进行合并,即只有当最后一个桶的目标空间被填满时才进行存活对象的提升,提升的方法则是简单地调整两个分代之间的边界。

图9.2c中的 衰老空间(aging space) 与此处的二阶桶策略存在一定的相似之处,但它却需要对存活对象头部中的年龄位进行额外的操作。

在这里插入图片描述

尽管“分阶”和“分代”策略都是根据年龄来划分对象,但不能将它们混淆。不同分代的回收频率不同,而同一分代内部的所有分阶则具有相同的回收频率。

由于年老代的回收通常会比年轻代要晚,因而回收器必须记录从年老代指向年轻代的跨代指针,而跨阶指针则无需记录。

将年轻代划分为多个分阶,不仅可以在无需为每个对象记录年龄的前提下避免过早的对象提升,而且还可以降低赋值器写屏障的开销。

分代:是记录每个对象的年龄,即使都在年轻代,它们的年龄却可能不同。
分阶:按阶段放置对象,同一个阶段,认为其中的对象年龄都相同。
一个不怎么恰当的比喻,分阶更像是按照类似高一、高二、高三、大一这种形式,而不是按照分代的16岁、17岁这种。(高一阶段的同学可以是15、16、17岁)

9.6.3 存活对象空间与柔性提升

在上述所有基于半区复制的回收策略中,年轻代中一半的空间要用作复制保留区,因而其空间浪费率较高。

为此,Ungar 进一步将年轻代划分成一个较大的 诞生空间(creation space) 以及两个较小的 存活对象半区(survivor semispace),即桶(见图9.2d)。

对象在诞生空间中分配,次级回收会将其中的存活对象提升到 存活对象目标空间(survivor to space) 。对于存活对象来源空间(survivor from space) 中的对象,次级回收会根据其年龄决定是将其移动到年轻代内部的存活对象目标空间还是将其提升到下一代。

JavaJDK1.3.1,也就是常用的HotSpot JVM虚拟机,用的就是类似的模式:
1个Eden区以及2个survivor区-老年代-永久代/元空间

诞生空间可以远比两个存活对象半区大,因而这种空间组织方式可以提升空间利用率。例如在Sun的HotSpotJava虚拟机中,诞生空间与存活对象空间的大小比例是32:1,即年轻代的复制保留区仅占用不到3%的空间。

HotSpot的对象提升策略并非要求对象达到一个固定的年龄,而是尝试为存活对象腾出一半的可用空间。相比之下,其他半区复制回收策略则会浪费一半的空间。


Opportunistic垃圾回收器使用桶组系统,同时配备一个较小的存活对象空间,该回收器中对象的提升年龄标准具有一定柔性。

该回收器可以在不单独记录和操作每个对象年龄的前提下实现对象级别的提升控制。

与其他策略相似,该回收器也将年轻代划分为一个诞生空间以及两个衰老空间,但衰老空间并非两个半区,而是采用分阶策略。

次级回收将诞生空间中的存活对象移动到一个衰老空间中,同时将另一个衰老空间中的存活对象提升。

如果仅依靠这一策略, 则对象的提升标准即为复制次数达到两次。

但Wilson和Moher注意到,对象在诞生空间中是按照其分配的时间顺序排列的,因此只需要在诞生空间中引入一个高水位标记便可通过一次地址判断简单地区分出较为年轻的对象(即图9.5中位于高水位线之上的部分)。

可以将诞生空间中较年轻的部分视为第0阶桶,同时将诞生空间中较老的部分以及衰老空间视为第1阶桶,只有第1阶桶中的存活对象才会得到提升。

在这里插入图片描述

该策略将对象的提升年龄标准限定为最多经历两个次级回收,同时也不必显式记录对象年龄或对其进行分区组织(例如我们前面所提到的半区组织方式)。

诞生空间中,提升的阈值(即高水位标记一译者注) 可以设定为1 ~ 2之间的任意小数,且可以在任意时间修改。

图9.3展示了该算法的效果,其中的白色虚线代表高水位标记,其左侧深灰色区域以及黑色区域(即曲线©之下)中的所有对象都会在下次回收过程中得到提升。

在高水位标记右侧,黑色区域中的对象将会得到提升,而灰色区域中的对象则会在下一次回收中死亡。Wilson和Moher在字节码Scheme-48中使用了这一方案,且其使用了3个分代。标准ML也采用了该方案,但其分代数量增加到14个。

9.7 对程序行为的适应

某些回收器可以在运行时根据程序的行为做出调整,例如Opportunistic回收器可以在程序运行时改变回收策略,其调整机制不仅可以达到很细的粒度,而且实现简单。

真实应用程序(而非玩具式的程序或者人为的基准测试程序)的执行通常是分阶段的,同时对象生命周期的分布既不是随机的也不是静态的,因此回收器根据程序行为进行自适应调整是十分必要的。

许多程序都具有一些共同的行为模式。

  • 某一集合中的对象可能逐渐积累,然后在同一时刻全部死亡。
  • 存活对象的整体大小也可能在达到某一值后长期处于平稳。

Ungar和Jackson 将对象成簇诞生但慢慢消亡的情况其类比为 “蛇吞”

一旦对象生命周期的分布模型不符合弱分代假说,则分代回收器便可能遇到问题。如果大量对象在存活到年老代之后才会死亡,则回收器的性能会受到影响。

为解决这一问 题,Ungar 和Jackson 提出了多种柔性方案来控制年老代对象的数量。垃圾回收器对赋值器行为的适应能力是十分有用的,例如可以减少期望停顿时间或者提升整体吞吐量。

最简单的回收调度策略是仅当可用空间耗尽时才执行垃圾回收,但通用内存管理器可以通过对最年轻分代的大小进行调整来控制停顿时间:

  • 诞生空间越小,则次级回收所要提升的年轻代对象就越少。

每个分代的空间大小也会影响对象的提升率。如果年轻代的空间太小以至于新生对象没有足够的时间到达死亡,提升率便会增大,而如果诞生空间很大,则回收时间间隔会增大,对象的提升率也会降低。

9.7.1 Appel 式垃圾回收

Appel针对标准ML提出了一种自适应分代策略,其年轻代可以在堆内存总量不变的情况下占用尽可能多的空间(而不是一个大小固定的空间)。

ML语言中,一次回收完成后通常只有不到2%的对象可以存活,而该策略正是针对这一情况而设计的。

Appel 的方案将堆划分为3个区域:

  • 年老代(old)
  • 复制保留区(copy reserve)
  • 年轻代(young)

(见图9.6a)

针对年轻代的回收会将其中所有存活对象集体提升到年老代的末尾(见图9.6b)。

在这里插入图片描述

回收完成后,年老代之外的其他空间将被划分为大小相同的两份

  • 一份用作复制保留区
  • 另一份则作为年轻代的诞生空间。

如果年轻代的可用空间小于某一阈值, 则会执行整堆回收。

与其他基于复制的回收策略一样,Appel式垃圾回收必须保证复制保留区在最差情况下仍可以容纳所有的存活对象(即年轻代和年老代存活对象的总和)。

最保守的策略是确保old + young ≤ reserve,但Appel却可以在降低整堆回收频率的同时将这一要求降低为old ≤ reserve且 young ≤ reserve, 其证明如下。

  • 在次级回收之前,即使所有的年轻代对象都存活下来,复制保留区也能够将其全部容纳。

  • 次级回收完成后,所有刚刚完成提升的对象均位于“ old’ ”所标识的区域,由于它们都是存活对象,因而即使立刻执行整堆回收,这些对象也无需移动。

  • 同时由于old ≤ reserve,新的复制保留区也可容纳old所标识区域中所有在以往的次级回收中得到提升的对象(见图9.6c)。

  • 主回收完成后,回收器需要将old区域中所有的存活对象(当前是在堆的顶端)移动到堆的底部。

需要注意的是,对于一部分在年轻代而另一部分在年老代的环状垃圾,这种“二次回收”的方法无能为力,但在下次主回收中,这种环状垃圾必然全部位于年老代,因此最终可以得到回收。


Appel的分代回收器基于连续的堆空间,但也有其他Appel式的回收器使用块结构堆,后者可以避免在主回收完成后将存活对象移动到堆底这一操作。在年轻代具有收缩能力的基础上,年老代也可使用诸如标记—清扫等非移动式回收算法来管理。

与使用集体提升策略且年轻代大小固定的分代回收器相比,Appel式回收器的优点在于其复制保留区的大小可以动态调整,从而可以提升内存利用率并降低回收频率。

但是,为避免回收器出现性能颠簸,仍有一些细节需要注意。Appel的设计出发点在于许多程序都具有较高的分配率以及较小的提升率。

如果诞生空间收缩得太小,则次级回收发生的频率会变高,但由于得到提升的对象太少并不足以触发主回收,因而程序的性能会受到影响。解决这一问题的方法是在年轻代空间小于某一阈值时便进行整堆回收。

9.7.2 基于反馈的对象提升

还有许多控制提升率的策略均以降低停顿时间为目标。 基于反馈的统计分析提升(demographic feedback-mediated tenuring) 尝试对刚提升不久便立即死亡的对象进行控制以减缓停顿时间过长的情况。

该方案使用一次回收所提升对象的总量来预测下次回收将要提升的对象总量,并据此减少或者增大提升率。如果对象存活率超过某一最大值,则在下次回收过程中,判定对象是否应当提升的年龄标准会增大。

尽管这一策略可以控制对象的提升率,但它却无法将年老代对象降级到年轻代。Barrett和Zorn 在不同分代之间引入具有双向移动能力的 危险边界(threatening boundary) ,但由于回收器无法预测分代之间的边界未来会落在哪里,因而写屏障需要对更多的指针进行追踪。

Sun的HotSpot回收器家族在1.5.0版本中引入了Ergonomics机制,其目的在于根据不同的用户需求来调整各分代的大小。Ergonomics的设计目标并不是满足硬实时要求,而是以3个软目标作为出发点的。

  • Ergonomics首先尝试满足最大停顿时间的要求
  • 在此基础上尝试提升吞吐量(通过测量垃圾回收占程序整体执行时间的比例来判定)
  • 在前两个目标都达到的前提下尽量减少程序所占用的空间

优化停顿时间的方法是:对每次回收的停顿时间进行统计分析,找出时间消耗最长的分代,然后缩小该分代所占据的空间。吞吐量的提升则是通过增大整个堆或者某个分代空间来实现的,但分代空间的增大同时也会导致该分代的回收时间变长。默认情况下,堆空间通常更倾向于增大而不是收缩。

Vengerov 提出了一种分析HotSopt回收器吞吐量的模型,并基于该模型得出一种实用的回收器调整算法。该算法主要关注HotSpot回收器两个分代的相对大小、对象提升阈值、年轻代在得到提升之前所需经历的回收次数,并在运行时对其进行动态调整。

Vengerov经过观察得出一个重要结论,即对提升率阈值的调节不能仅考虑这一操作所能减少的对象提升数量,还要考虑主回收完成之后年老代的可用空间与每个次级回收所提升对象总量的比值。

Vengerov在其ThruMax算法中提供了一种可以交替调整年轻代空间大小以及提升率的协同演化框架,其工作流程大致如下:

在HotSpot回收器的第一次主回收之后,或者存活对象的总量达到某一稳定状态之后(即连续两个次级回收之间年轻代存活对象总量的75% ~ 90%),ThruMax逐渐增大诞生空间的大小S,直至其到达一个临近的最佳值(即S开始下降,或者在某个值附近摆动),然后ThruMax对提升阈值进行调整,直到吞吐量出现下降趋势为止。

如此一来,回收器既可以在避免任何副作用的前提下适当减少诞生空间的大小s,也能确保在进行足够多的次级回收之后才需要进行主回收。

总之,像HotSpot这样的复杂回收器中可能会存在大量的可调参数,但每个参数之间可能存在一定的依赖关系。

9.8 分代间指针

在对某个分代进行回收之前,回收器必须先确定该分代的根

正如我们在图9.1中所看到的,某个分代的根不仅包括寄存器、栈、全局变量中的指针值,如果该分代内部的对象被堆中其他空间的对象所引用,且这些空间不会与该分代同时进行回收,则这些引用也属于该分代的根。

这些引用通常来自于更老分代,或者各分代之外的堆空间,例如大对象空间,或者永远不会进行回收的空间(如永生对象或代码所占据的空间)。

分代间指针的创建有3种方式:

  1. 在对象创建时写人
  2. 在赋值器更新指针槽时写人
  3. 在将对象移动到其他分代时产生

回收器必须要对分代间指针进行记录,只有这样才能确保在对某一分代
单独进行回收时根的完整性。

我们将所有需要记录的指针统称为 回收相关指针(interesting pointer)

另一种需要关注的引用来源是存在于堆之外的 引导映像(bootimage) 中的对象(即引导对象),这些对象自从程序启动之后便一直存在。

通用垃圾回收系统至少可以使用3种方式来处理这些对象:

  1. 对引导对象进行 追踪(trace) , 其优势在于如果某一堆中对象仅从某一引导对象可达,而该引导映像对象本身不可达时,回收器可以将该堆中对象回收;

  2. 对引导对象进行扫描,并从中找出指向我们所关注的分代的指针;

  3. 对引导映像对象中的回收相关指针进行记录。追踪的代价较高,通常只会在整堆回收时使用,因此追踪通常与扫描或记忆集结合使用。

扫描的优势在于当赋值器对引导映像对象进行更新时无需使用额外的写屏障,但其缺陷在于回收器必须搜索更多的域才能找全回收相关指针。

如果将扫描与追踪相结合,则回收器在追踪完成后必须将不可达引导映像对象的各指针域清零,否则将错误地导致某些垃圾对象重新可达。

记忆集同样也拥有其自身的优势与开销,也不需要将不可达引导映像对象的指针域清零。

9.8.1 记忆集

记忆集:是用于记录分代间指针的数据结构,其中所记录的是从堆中一个空间指向另一个空间的指针来源(如图9.1中的对象U以及对象S的第二个槽)。

这里需要注意的是,记忆集中所记录的是回收相关指针的来源而非目标,其原因有二:

  1. 在移动式回收器中,一且目标对象被复制或者提升,回收器便可根据记忆集更新回收相关指针的来源。

  2. 在连续两次回收过程之间,某个回收相关指针的来源域可能会多次被修改,如果记录回收相关指针的来源而非目标,则回收器可以仅对回收时刻该指针域所引用的对象进行处理,从而无需考虑其曾经指向过哪些其他对象。

因此,每个分代的记忆集均只需记录可能指向该分代内部对象的回收相关指针来源。不同的记忆集实现方式在来源地址的记录方面所能达到的精度也各不相同。

精度并非越高越好,较高的精度通常会增大赋值器的额外开销、记忆集空间开销以及回收器处理记忆集的时间开销。

需要注意的是,此处记忆集中的“集”并非严格意义上的集合,其具体实现中通常允许出现元素的重复,此时记忆集便成为 “多集合”(multiset)

需要探测和记录的指针当然是越少越好。

  • 回收器所执行的指针写操作(例如移动对象)通常很容易探测到。
  • 赋值器所执行的指针写操作可以用软件写屏障的方式实现,即编译器在每个指针写操作之前插入额外的指令,但如果缺乏编译器的支持,该方案的实现通常会遇到问题。这种情况下,通常需要借助于操作系统的虚拟内存管理器来获取写操作发生的地址。

在基于状态的语言中,破坏性( destructive) 指针写操作出现的比例通常更高。Java 应用程序中,指针写操作的比例通常变化较大,如Dieckmann和H6lzle发现在所有的堆访问操作中,指针写操作的比例为6% ~ 70% (此处的最大值应该是一个异常值,次大的值是46%)。

9.8.2 指针方向

并非所有的写操作都需要进行回收相关指针的探测和记录。

此时如果编译器可以检测出针对栈槽的写操作,则可以免去该过程中的写屏障开销。

另外,许多写操作所涉及的对象都是在同一个分区内,尽管此类写操作可能会被探测到,但它不会产生任何回收相关指针,因此也无需记录其来源。

如果我们对各分代进行回收的顺序施加一定的限制,则需要记录的分代间指针的数量还可以大幅降低。

如果可以确保在对年老代进行回收的同时一定会回收年轻代,则无需记录从年轻代到年老代的指针(例如图9.1中对象N所包含的指针)。

许多指针写操作都是在对新创建对象进行初始化时发生的,Zorn 估计Lisp语言中90% ~ 95%的指针写操作都发生在对象初始化过程中(在剩余的写操作中,2/3 的写操作都发生在年轻代内部)。

从概念上讲,新创建对象中的指针必然指向年龄更大的对象,但不幸的是,在许多语言中,对象的分配与其内部域的初始化是两个相互独立的过程,这导致编译器无法将初始化过程与非初始化过程相区分,而后者极有可能创建从年老代指向年轻代的引用。

某些语言中,编译器拥有更强的指针写操作判断能力,从而无需引人写屏障。

例如,在诸如Haskell的懒惰式纯函数式语言中,大多数指针写操作都会指向更老的对象,而只有真正对一个待计算值(thunk,即已经绑定了参数的函数)进行计算并用一个指针将其覆盖时,才有可能创建从年老代指向年轻代的指针。对于ML这种有副作用的严格编程语言,开发者必须对可写变量进行显式声明,而只有对这些对象进行写操作,才可能创建从年老代指向年轻代的指针。对于诸如Java之类的面向对象语言,情况则稍为复杂。


面向对象语言的编程范式通常集中在对象状态的更新上,这自然会导致从年老代指向年轻代的指针出现得更频繁。然而,大多开发者都会编写函数式风格的代码,这通常可以避免副作用,同时在许多应用程序中绝大多数指针的写操作均是针对年轻代对象的初始化。

但Blackburn等 指出,不仅是在不同应用程序之间,同一程序内不同个体之间也存在着相当大的行为差异。

指针写操作会在指针的方向以及距离(此处是指来源对象与目标对象在创建时间上的距离)上表现出较大的差异性,其原因之一便是:

  • 可能存在许多针对同一区域的写操作,这对记忆集的实现有着重要的影响。

如果在任何情况下都对年轻代进行整体回收,则写屏障可以忽略对新生区的写操作(可以预期,此类操作通常较为普遍)。但如果堆中具有多个独立的回收区域,则可能需要使用不同的指针过滤器。

例如,回收器可能会使用启发式方法来估算哪个区域的存活对象最少,进而确定对不同区域进行回收的优先级。这种情况下,我们必须对两个方向上的指针都进行记录,因而需要记录的指针数量通常较多,此时的最佳选择是选用整体空间大小与内部所记录指针数量无关的记忆集。

9.9 空间管理

年轻代存活对象的归宿有两个

  1. 被复制到同一分代的其他半区
  2. 被提升到年老代

基于年轻代存活对象较少这一假设, 我们通常期望增多且简化年轻代的回收。一旦要对年老代进行回收,通常也需同时回收所有年轻代,否则就需要在写屏障中对双向指针都进行记录。通常对最老代的回收会触及堆中的所有其他区域,但永生对象区域或者引导映像区除外,这些区域中的引用通常会被看作根,且回收器也需要更新其指针域。整堆回收无需使用记忆集(永生对象区域以及引导映像区除外,但如果整堆回收也会扫描这些区域,则记忆集便彻底不再需要了)。

有许多策略可以用于最老代的管理。

  • 半区复制策略是一种可选方案,但它可能并非最佳选择。半区复制需要在堆中开辟一块复制保留区,这会导致堆可用空间的减少,从而增大了各级回收的频率。半区复制同时也可能导致长寿对象在两个半区之间来回复制。
  • 标记—清扫回收器的内存使用率较高,特别是在堆空间较小的情况下。 尽管标记—清扫回收所使用的空闲链表分配通常比顺序分配要慢,且其局部性无法预测,但这通常仅在对象分配较为频繁的年轻代时才会成为问题。 标记—清扫回收的主要缺陷在于它是非移动式回收,从而有可能加剧年老代的内存碎片情况,针对这一问题可以引人额外的整理式回收,即在年老代碎片率达到一定程度时进行整理(而非每次回收都进行整理)。
  • 标记—整理式回收也可以较好地处理长寿对象。整理式回收通常会在年老代的底部形成一个 “密集前缀”(dense prefix) 。HotSpot 标记—整理式回收器只有在这一“沉积区”的碎片率达到一定程度(可以由用户决定的)时才会进行整理。

分代垃圾回收器通常会在物理上隔离不同的分代,这便要求年轻代使用复制式回收进行管理。Appel式回收器等较为保守的回收器要求复制保留区在最差情况下都能够容纳所有存活对象,但在实践中,一次回收后年轻代的存活对象通常较少。

如果使用较少的复制保留区,并且可以在保留区空间不足的情况下切换到整理式回收,则空间利用率可以大幅提升。 但是,由于复制保留区空间不足的情况只有在回收过程中才会发生,所以回收器必须能够随时在复制和标记两种操作之间进行切换。


图9.7a所展示的是回收器完成所有存活对象鉴别之后堆的状态,此时复制保留区已被填满,其中已完成复制的对象为黑色,其余的年轻代存活对象为灰色。接下来的操作将是把所有已标记对象整理到新生代的一端(见图9.7b),这通常需要多次遍历。不幸的是,整理过程会破坏年轻代黑色对象中所记录的转发地址。McGachey和Hosking的解决方案是:

  • 先对灰色对象进行一次遍历并更新其中指向黑色对象的引用,然后再使用Jonkers的滑动式整理器(参见3.3节)移动已标记的灰色对象,这一引线算法无需在对象头域中占用额外的头域。
  • 更好的解决方案可能是使用Compressor算法(参见3.4节),因为它不仅无需引入任何额外头域,而且也不会修改存活对象的任何一个域。
    在这里插入图片描述

9.10 中年优先回收

事实证明,对于许多应用程序而言,分代垃圾回收都可以十分高效地管理寿命较短的对象。但正如我们在9.7节所看到的,寿命稍长日的对象处理起来则较为棘手。分代垃圾回收本质上是仅回收堆中较为年轻的对象,同时忽略其他较老对象,但在每次回收中,年轻对象集合的具体范围取决于需要回收的分代的数量,即:

  • 是仅回收新生代,还是也需要回收中间分代(在使用超过两个分代的系统中),或者是要进行整堆回收。

某些自适应策略具有控制对象提升率的能力,我们可以将其看作是通过对各年轻代的年龄范围进行调整,使得年轻代对象有更多的时间到达死亡。但分代垃圾回收并非避免整堆回收的唯一途径 ,基于年龄的回收(age-based collection) 策略可能包括:

  • 仅针对最年轻分代的回收(分代回收) (youngest- only collection) :回收器仅处理堆中最年轻的对象。

  • 仅针对最年老分代的回收(oldest-only collection) :可以想象一个仅处理堆中最年老对象的回收器,即假定更老的对象更容易死亡。但这一策略通 常都十分低效,因为它会花费大量的时间频繁地处理永生对象或者非常长寿的对象。而正是这一原因,使许多回收器刻意避免对这些古老的“沉积物”进行处理。

  • 中年优先回收(older-first collection) :回收器将主要精力集中在 中年( middle-aged) 对象的处理上。这一策略可以确保年轻代对象有足够的时间到达死亡,同时也避免对长寿对象进行处理(当然也会偶尔对其进行处理)。

中年优先回收存在两个技术难点:

  1. 如何确定中年对象
  2. 由于两个方向的回收相关指针(从年老到中年、从年轻到中年)都需要记录,因而记忆集的管理复杂度会有所提升。

下面我们将介绍解两种解决方案。

  • 更新中年优先回收(renewal older first garbage collection)

一种获取对象“年龄”的方案是计算对象自创建以来所经历的时间,或者距其上一次经历垃圾回收的时间,并以两者的最小值为准。

更新中年优先回收通常仅回收堆中“最老”的前缀。为简化记忆集的管理,回收器将堆划分为k个大小相等的阶,同时将编号最小的空阶用于对象分配。

  • 当堆空间耗尽时,回收器将对最老的kj阶进行回收(见图9.8中的灰色窗口),并将其中的存活对象复制到与第1阶相邻的复制保留区(图9.8中的黑色区域)。
  • 此时,存活对象将得到“更新”,此时第j~ 1阶成为最老的阶。

在图9.8中,堆在虚拟地址上向右增长,从而简化了写屏障的设计,即记忆集只需要对方向从右向左且来源地址大于j的指针进行记录。

缺点:

  1. 这种堆排列方式在64位的应用程序中通常不会遇到问题,但是在32位系统中则很容易耗尽虚拟地址空间。为此,更新中年优先回收必须在触达堆的末端时回绕到堆的始端,并且对各阶重新进行编号,此时写屏障在捕捉回收相关指针时便不能简单地依赖地址比较,它必须对来源和目标对象所在阶的编号进行比较。

  2. 对象在堆中的排列顺序并非按照其真正的年龄进行的,而是不可逆地混杂在一起。Hansen 在Scheme语言的Larceny 实现中引入一个标准的新生代以过滤掉大量回收相关指针( 然后仅使用更新中年优先回收方式管理年老代),但其记忆集所占用的空间依然十分庞大。

在这里插入图片描述

  • 延迟中年优先回收(deferred older-first garbage collection)

该方案可以确保对象在堆中按照其真实年龄排列。

  • 延迟中年优先回收使用一个固定大小的回收窗口(图9.9中的灰色区域),并在堆中沿着年老到年轻的方向进行滑动。

  • 当堆空间耗尽时,回收器仅对回收窗口内的对象进行回收,同时忽略其他更老的或者更年轻的对象(图9.9中的白色区域)。

  • 回收完成后,所有存活对象(图9.9中的黑色部分)都被将复制到最老对象之后的区域,而回收所得的空间将被添加到堆的最年轻端(即最右端)。下一轮的回收窗口将与存活对象的年轻端紧邻。

该方案寄希望于回收器可以在堆中达到一个最佳回收状态,在该状态下,回收窗口中仅有少量对象存活,同时回收器也拥有较低的标记—构造率,因而回收窗口将会以十分缓慢的速度滑动(正如图9.9中最后一行所示)。


然而,有时,当回收窗口触达堆的最年轻边界时,回收器便需要将回收窗口重置到堆的最老端。

在延迟中年优先回收算法中,尽管对象依照其真实年龄排列,但其赋值器写屏障的实现却十分复杂,因为其必须对所有从年老区指向回收窗口的指针、所有老—新指针、所有的新—老指针(除非该指针的来源位于回收窗口中)进行记录。

类似的,回收器的复制写屏障也必须记录所有从存活对象指向其他区域的指针、所有从年轻存活对象指向年老存活对象的指针。

另外,延迟中年优先回收通常会将堆划分为多个内存块,每个内存块都有其自身的“死亡时间”(且必须确保较老内存块的死亡时间大于较年轻的内存块)。此时写屏障的实现可以以内存块死亡时间的比较结果为基础进行,但同时也必须小心处理死亡时间溢出的情况。

与其他分代回收策略相比,延迟中年优先回收可以降低最大停顿时间,但与更新中年优先回收类似,该策略需要对更多的指针进行追踪。在地址空间较小时,中年优先回收写屏障的复杂性决定了其并不比其他回收方案更好,但当地址空间更大时(如64位系统),写屏障的实现便可简化,该回收方案也更加具有竞争力。

在这里插入图片描述

9.11 带式回收框架

本章我们已经介绍了多种不同的基于年龄的回收策略,这些回收策略的基本指导思想大体上可以总结如下:

  • “大多数对象都在年轻时死亡”,即弱分代假说。
  • 分代回收器会避免对年老对象进行频繁回收。
  • 增量回收可以改善回收停顿时间。
    • 在分代垃圾回收器中,新生代的空间通常较小
    • 其他回收技术通常也会限制待回收空间的大小,例如成熟对象空间回收器(mature object space collector) (也称为火车回收器) 。
  • 在较小的诞生空间中使用顺序分配可以提升数据的局部性。
  • 对象需要足够的时间到达死亡。

带式垃圾回收框架 beltway garbage collection framework) 对上述所有思想进行了综合,它可以配置成任意一种基于分区策略的复制式回收器。

带式回收器的一个回收单元称为 回收增量(increment) ,多个回收增量可以组合成队列,称为 回收带(belt) 。图9.10中,每一行代表一个回收带,回收带上的每个“托盘”代表一个回收增量。

尽管每次回收所选定的回收增量通常是最年轻回收带中非空且最老的一个,但整体来说,回收带内部的各回收增量通常依照先进先出的方式进行独立回收,各条回收带之间同样也符合先进先出的回收顺序。

提升策略决定了一次回收完成后存活对象的目标空间,即它们有可能被复制到同一回收带中的其他回收增量里,也有可能被提升到更高一级回收带中的某一回收增量里。

需要注意的是,带式回收器并非另一种分代回收器,更不能简单地将回收带与分代混为一谈。

其主要区别在于

  • 分代回收器通常会回收某一回收带中的所有回收增量
  • 带式回收框架可以对每个回收增量独立进行回收

在这里插入图片描述
图9.10展示了带式回收器对其他回收算法的模拟,其中的某些算法我们已经在前面的章节中介绍过,而另一些则是在本书中新出现的回收算法。

简单的半区复制回收器可以看作一个包含两个回收增量的回收带(见图9.10a),每个回收增量即为堆空间的以半。

一次回收完成后,第一个回收增量(即来源空间)中的所有存活对象被复制到第二个回收增量(即目标空间)中。

分代垃圾回收器的每个分代都可以看作是一个独立的回收带。对于新生代大小固定的分代回收器而言,其第0个回收带内部的回收增量无法变化(见图9.10b),而在Appel式回收器中,两个回收带中的回收增量都可以自由增长直至堆可用空间耗尽(见图9.10c)。

基于衰老半区的回收算法可以通过增大第0个回收带中回收增量的数量来模拟(见图9.10d)。但与9.6节所介绍的衰老半区算法不同,此处增加回收增量的目的是减少停顿时间,因为次级回收并不会处理第二个回收增量中的不可达对象。

带式回收框架也可以模拟更新中年优先回收与延迟中年优先回收。图9.10e可以清晰地反映出更新中年优先回收是如何将不同年龄的对象混杂在一起的。延迟中年优先回收使用两个回收带,当回收窗口触达第一个回收带的最年轻端时,回收器需要将两个回收带置换(见图9.10f)。


借助于带式回收框架, Blackburn等人还开发出了其他一些新的复制式回收算法。XX带式回收算法(见图9.10g)相当于是在Appel式回收器中引入了回收增量,即当第1个回收带被填满时,回收器仅处理第一个回收增量。

此处的X意味着回收增量可以占用的最大空间占整个堆空间的百分比,因此100.100带式算法即等价于标准的Appel式分代回收器。对于XX带式算法而言,如果X<100,则算法的完整性得不到保证,因为环状垃圾可能会跨越第1个回收带中的多个回收增量。

而在X.X.100带式算法中,第3个回收带仅包含一个回收增量,且该回收增量可以增长到与堆空间大小相同的规模,因而其完整性可以得到保证(见图9.10h)。

假设在所有配置中回收器都仅对最年轻回收带中最老的回收增量进行回收,则写屏障仅需要记录从年老回收带指向年轻回收带的指针,以及同一个回收带中从年轻回收增量指向年老回收增量的指针。如果我们从0开始对所有的回收带按照从年轻到年老的顺序编号,则每个增量可以用<b,i>来表示,其中b为回收带的编号,i为回收增量在回收带中的索引号。

此时,对于从 < b i , i > <b_i,i> <bi,i> 指向 < b j , j > <b_j, j> <bj,j>的指针,如果满足 b j < b i ∨ ( b j = b i ∧ i < j ) b_j<b_i \vee (b_j = b_i \wedge i<j) bj<bi(bj=bii<j),则写屏障需要对该指针进行记录。

当然,也可以使用较小的整数 n i n_i ni,对所有的回收增量统一进行编号,此时判定某一指针 是否需要记录的标准可以简化为 n j < n i n_j < n_i nj<ni,但这一策略可能需要偶尔对各回收增量的编号进行调整,例如将一个新的回收增量添加到某一回收带时。

一种典型的实现方案是将地址空间划分为多个 帧(frame) ,而每个回收增量都位于不同的帧上。如果地址空间足够大,则可以采用类似于中年优先回收的布局方案,即将回收增量直接与内存地址绑定,此时新老回收增量的比较可以通过简单的地址比较来实现,从而无需再将回收增量映射到具体的编号。

带式回收器的性能与其具体的配置方式密切相关。回收带在堆中的布局以及写屏障的实现至关重要,因为这不仅决定了哪些指针需要记录,而且决定了哪些对象需要复制,以及复制的目标空间。

9.12 抽象分代垃圾回收

我们曾经介绍过,Bacon等 将抽象追踪过程看作一种引用计数方式, 即在标记对象时增加其引用计数。算法9.1展示了基于两个分代以及集体提升策略的传统分代垃圾回收算法的抽象。

与第6章其他抽象回收算法类似,分代垃圾回收的抽象算法也通过一个多集合 I I I来记录年轻代对象被延迟的引用计数。

记忆集可以看作是所有从更老的分代指向年轻分代的指针集合,而多集合中所记录的条目则与记忆集中的条目一一对应,正因如此,decNursery 方法才需要把即将被覆盖的槽从多集合 I I I中移除。

如果某一年轻代对象n存在于多集合 I I I中,则分代回收器会将其保留。对象n在多集合 I I I中出现的次数相当于其被年老代对象所引用的次数。追踪式回收器可以使用一个位来替代对象n的引用计数。

当执行collectNursery方法时,多集合 I I I的初始值是年轻代中引用计数非零的对象集合,当然此处的引用计数只考虑了来自年老代对象的引用。此时的多集合 I I I也相当于是延迟引用计数中的零引用表。当rootsNursery方法将来自根集合的引用增加到多集合 I I I之后,

scanNursery方法从多集合 I I I开始进行追踪,最后由sweepNursery方法执行清扫。清扫过程将会把存活对象从年轻代移出并提升到年老代,同时将所有不可达的年轻代对象(也就是抽象引用计数为零的对象)回收。需要注意的是,算法9.1的第18行所执行的操作是将所有年轻代存活对象集体提升到年老代,对此处的操作进行修改便可以模拟其他提升策略。

在这里插入图片描述
在这里插入图片描述

附录

[1]《垃圾回收算法手册 自动内存管理的艺术》
[英]理查德·琼斯(Richard Jones)[美] 安东尼·霍思金(Antony Hosking) 艾略特·莫斯(Eliot Moss)著
王雅光 薛迪 译

猜你喜欢

转载自blog.csdn.net/weixin_46949627/article/details/127967433