《垃圾回收算法手册 自动内存管理的艺术》——标记整理与复制式回收(笔记)

三、标记—整理回收

内存碎片化是非移动式回收器无法解决的问题之一,即:尽管堆中仍有可用空间,但是内存管理器却无法找到一块连续内存块来满足较大对象的分配需求,或者需要花费较长时间才能找到合适的空闲内存。

许多长期运行的程序,如果通过非移动式回收器进行内存管理,通常会出现碎片化问题,进而导致程序性能的下降。

标记—整理通过标记后,将所有存活对象重新整理,让它们紧紧挨着,以减少碎片化。(和我们硬盘的碎片化整理一样)


标记—整理算法的执行需要经过数个阶段:

  • 首先是标记阶段,其相关内容我们在上一章已经讨论过
  • 然后是整理阶段,即移动存活对象,同时更新存活对象中所有指向被移动对象的指针。

在不同算法中,堆的遍历次数、整理过程所遵循的顺序、对象的迁移方式都有所不同。

整理顺序( compaction order)会影响到程序的局部性。移动式回收器重排堆中对象时所遵循的顺序包括以下3种:

  1. 任意顺序:对象的移动方式与它们的原始排列顺序和引用关系无关。

  2. 线性顺序:将具有关联关系的对象排列在一起,如具有引用关系的对象,或者同一数据结构中的相邻对象。

  3. 滑动顺序:将对象滑动到堆的一端,“挤出”垃圾,从而保持对象在堆中原有的分配顺序。

我们所了解的整理式回收器大多遵循任意顺序或者滑动顺序。任意顺序整理实现简单,且执行速度快,特别是对于所有对象均大小相等的情况。但任意顺序整理很可能会将原本相邻的对象分散到不同的高速缓存行或者虚拟内存页中,从而降低赋值器空间局部性。

所有现代标记——整理回收器均使用滑动整理顺序,它不改变对象的相对排列顺序,因此不会影响赋值器局部性。

复制式回收器甚至可以通过改变对象排布顺序的方式将对象与其父节点或者兄弟节点排列得更近,从而提升赋值器的局部性。一些实验表明,由任意顺序整理导致的对象重排列会大幅降低应用程序的吞吐量。

整理算法可能会存在多种限制:

  • 任意顺序算法只能处理单一大小的对象,或者只能对不同大小的对象分别进行整理;
  • 整理过程需要两次甚至三次整堆遍历

一遍标记并将存活对象连起来比如用指针,一遍更新引用,一遍移动对象到新的区域。

需要注意的是,我们标记用的是类似根可达的方式,因此标记顺序和内存地址的顺序可能是不一样

  • 对象头部可能需要一个额外的槽来保存迁移信息,这对于通用内存管理器来说是一个显著的额外开销。

整理算法可能对指针有特定限制,如指针的引用方向是什么?是否允许使用内部指针?

所有整理式回收算法的执行都遵从如下范式:

在这里插入图片描述

3.1 双指针整理算法(任意顺序)

Edwards 的双指针算法属于任意顺序整理算法,其需要两次堆遍历过程,最佳适用场景为只包含固定大小对象的区域。

原理

对于某一区域中的待整理存活对象,回收器可以事先计算出该区域整理完成后存活对象的 “高水位标记”( high-water mark),地址大于该阈值的存活对象都将被移动到该阈值以下。

步骤

  • 第一次遍历
  1. 指针free指向区域始端,指针scan 指向区域末端。
  2. 在第一次遍历过程中,回收器不断向前移动指针free,直到在堆中发现空隙(即未标记对象)为止
  3. 类似地,不断向后移动指针scan直到发现存活对象为止。
  4. 如果指针free和指针scan发生交错,则该阶段结束,否则便将指针scan所指向的对象移动到指针free的位置,同时将原有对象中的某个域(指针scan 所指向的)修改为转发地址,然后继续进行处理。

图3.1 描述了这一过程,其中对象A被移动到新的位置A’,且在对象A中的某个槽(即第一个槽)中记录了A’的地址。


值得注意的是,该算法的整理质量取决于指针free所指向的空隙与指针scan 所指向的存活对象大小的匹配程度。除非对象大小固定,否则碎片的整理程度一定很低。
在这里插入图片描述

该阶段完成后,指针free将位于存活对象边界。

  • 第二次遍历

回收器在该过程中会将指向存活对象边界之外的指针更新为其目标对象中所记录的转发地址,即对象的新位置。

在这里插入图片描述
在这里插入图片描述
优势:

  • 简单快速,且每次遍历过程的操作较少。
  • 该算法支持内部指针。其内存访问模式是可预测的,因此也支持预取(不论是硬件预取还是软件预取),进而可以提升回收器的高速缓存友好性。

缺点

  • 需要将标记位保存在一个独立的位图中,或者在对象分配时即在位图中记录其首地址
  • 双指针算法重排列堆中对象的顺序是任意式的,因此会破坏赋值器的局部性。

但是,由于相关对象总是成簇诞生、成批死亡,我们可以将连续存活对象整体移动到较大空隙中,而不是逐个进行移动,所以在某些情况下赋值器的局部性甚至有可能得到提升。

3.2 Lisp 2算法(滑动顺序)

Lisp 2回收算法(见算法3.2)是一种历史悠久的回收算法,无论是其原始形态,还是为适应并行回收的改进版本,都得到了广泛应用。

原理

它需要在每个对象头部额外增加一个完整的头域来记录转发地址(标记位也可以复用该域)

在标记阶段结束之后的第一次堆遍历过程中,回收器将会计算出每个存活对象的最终地址(即转发地址),并且将其保存在对象的forwardingaddress域中(见算法3.2)。

步骤

用computeLocations方法在堆中移动两个指针:

  • 指针scan对来源区域中的所有(存活的或死亡的)对象进行迭代
  • 指针free指向目标区域中的下一个空闲位置。

computeLocations方法需要3个参数:

  1. 堆中待整理区域的起始地址

  2. 堆中待整理区域的结束地址

  3. 整理目标区域起始地址。

  • 第一次遍历

目标区域通常与待整理区域相同,但并行回收器可能会为每个线程设定不同的来源和目标区域。

  1. 如果指针scan遍历到的对象是存活的,意味着该对象(最终)会被移动到指针free所指向的位置。此时回收器将指针free写入对象的forwardingaddress域,然后根据对象的大小向前移动指针free(需要考虑对齐填充)。

  2. 如果遍历到死亡对象,则将其忽略。

  • 第二次遍历

在第二次堆遍历过程(算法3.2中的updateReferences方法)中,回收器将使用对象头域中记录的转发地址来更新赋值器线程根以及被标记对象中的引用,该操作将确保它们指向对象的新位置。

  • 第三次遍历
    在第三次遍历过程中, relocate最终将每个存活对象移动到其新的目标位置。

需要注意的是,遍历的方向(从低地址到高地址)与对象的移动方向(从高地址到低址)相反,这便可以保证回收器在第三次遍历过程中复制对象时其目的地址已经腾空

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

某并行回收器将堆划分为多个内存块,并且在相邻内存块上使用不同的滑动方向,相对于每内存块都向同一个方向滑动的算法,该算法可以产生较大的对象“聚集”,进而产生更大的空闲内存间隙,图14.8即是一个示例。

Lisp 2算法可以在多方面进行改进:

  1. 标记—清扫回收器在清扫阶段的数据预取技术也可以应用在Lisp 2算法中

  2. 在computeLocations方法的第10行之后,回收器可以将相邻垃圾合并,以提升后续遍历过程的性能。

Lisp 2算法的主要缺陷有两个:

  1. 算法需要三次完整的堆遍历过程

  2. 每个对象需要额外的空间来记录转发地址,这两个缺陷可以说是互为因果

3.3 引线整理算法(滑动顺序)

原理

Fisher通过一种不同的策略解决了指针更新问题,即 “引线”( threading) ,该算法不需要任何额外存储,且支持滑动整理。

引线算法要求对象头部存在足够的空间来保存一个地址(如果必要可以覆盖头域的其他数据),这一要求并不苛刻,但回收器所记录的地址必须要能与其他值区分,要满足这一要求可能有些困难。最知名的引线算法当属Morris的版本,但是Jonkers的版本限制更少(例如在指针方向上)。

引线的目的是通过对象N可以找到所有引用了该对象的对象,实现方法是临时反转指针的方向。

步骤

图3.2演示了如何在引线之后找到之前引用了对象N的对象。需要注意的是,经过图3.2b 中的引线操作之后,对象N头部info域的值被写入到对象A的一个指针域中,当回收器通过指针追踪来逆引线(unthread)、更新引用时,必须要能分辨出对象A的这一域中记录的并非引线指针。

在这里插入图片描述

Jonkers的算法需要两次堆遍历过程,第一次遍历实现堆中前向指针的引线,第二次遍历实现堆中后向指针9的引线(见算法3.3)。

在第一次遍历开始时,回收器先对根进行引线,然后在堆中从头到尾进行扫描,与此同时,将所有存活对象的大小累加,最终以此来更新指针free。

在图3.2中,如果仅考虑存活对象N,那么该算法很容易理解:

  1. 当回收器在第一次遍历过程中遇到对象A时,其会对A中指向对象N的引用进行引线
  2. 当遍历到对象N时,会完成所有指向对象N的前向指针的引线(见图3.2b)
  3. 此时回收器可以沿着对象N的这条引线链完成所有指向对象N的前向指针的更新,即将它们都改写为指针free,也就是对象N未来的新地址
  4. 当到达引线链的终点时,回收器将恢复对象N头部info域的值
  5. 完成上述步骤之后,还需要增加指针free,并对N的所有子节点进行引线

第一次遍历完成之后,所有前向指针都已经指向了对象整理后的新地址,且所有后向指针都已完成引线。

第二次遍历过程则会根据后向指针引线链简单地更新指向对象N的引用,同时完成对象N的移动。

在这里插入图片描述

优点:

  • 需要额外的空间,尽管其对象头部必须能够容纳一个指针(且该指针必须能与一般的值进行区分)。

缺点:

  • 该算法需要两次修改对象的头部,第一次是引线,第二次是逆引线并更新引用。

  • 与标记过程类似,Jonkers的算法中沿着引线链进行遍历的高速缓存友好性较差,而整个算法总共需要三次这样的指针遍历过程(即:标记、引线、逆引线)。

Martin指出,可以将标记过程与第一次整理过程合并,从而将回收时间减少三分之一,但这也反映了指针追踪以及修改指针域的开销之大。


Jonkers的算法对指针的修改是破坏性的,其本质上是串行的,因此无法用于并发整理。

例如在图3.2b中,当回收器完成对象B中第一个指针域的引线之后,堆中将不再有任何能够反映出该域曾经指向对象N这一信息(除非将对象N的地址存储在指针链的末端,即在对象A的头部中占用一个额外的槽,但这破坏了不使用额外空间的本意)。

最后,Jonkers 的算法不支持内部指针(interior pointer),这在某些场景下可能是一个重要问题。Morris的引线整理算法虽然支持内部指针,但其代价是要求为每个域分配一个额外的标签位,且第二次整理过程的遍历方向必须与第一次相反(从而引入了堆的可解析性问题)。

3.4 单次遍历算法(滑动顺序)

如果要将滑动式回收器的堆遍历次数降低到两次(一次标记、一次滑动对象),且避免昂贵的引线开销,那么就必须使用一个额外的表来记录转发地址。

所谓的单次应该指的是标记之后只循环一次

Abuaiadh等,Kermany和 Petrank各自独立设计出了可以完全满足这一要求且适用于多处理器的高性能整理算法:

  • 前者的算法属于并行式、万物静止式算法(使用多个整理线程);

  • 后者的算法可以配置成并发式回收算法(允许赋值器线程和回收器线程同时执行)和增量式回收算法(定期挂起一个赋值器线程并简单地执行小部分回收工作)。

这两种算法都需要使用数个额外的表或者向量。

原理

与许多回收器类似,标记过程是基于位图(即图3.3中的标记向量——译者注)进行的,每个位对应堆中一个内存颗粒(即一个字)。

在标记过程中,如果发现存活对象,则设置其所占用空间的第一个和最后一个内存颗粒对应的位。

例如在图3.3中,回收器会针对存活对象old设置标记向量的第16位和第19位。回收器在后续的整理阶段可以通过对标记向量的分析计算出任意存活对象的大小。

在这里插入图片描述
回收器使用一个额外的表来记录转发地址。如果记录每个对象的转发地址,则会引入难以承受的开销(即使对象已经满足一定的字节对齐要求),因此这两种算法都将堆划分成大小相等的小内存块(分别是256字节和512字节)。

偏移向量(offset vector) 记录了每个内存块中第一个存活对象的转发地址,其他存活对象的转发地址可以通过偏移向量和标记位向量实时计算得出。

对于任意给定对象,我们可以先计算出其所在内存块的索引号,然后再根据该内存块在偏移向量和标记位向量中的对应数据计算出该对象的转发地址。

因此回收器不再需要两次遍历过程来移动对象和更新指针,转而可以通过对标记位向量的一次遍历来构造偏移向量,然后通过一次堆遍历过程同时完成对象的移动和指针的更新。减少堆的遍历次数可以提升回收器的局部性。

步骤

下面我们将分析算法3.4(即 Compressor算法)的具体实现细节。
在这里插入图片描述

在标记过程结束之后,computeLocations方法将通过对标记位向量的遍历来计算偏移向量。从本质上讲,这一过程与Lisp 2(见算法3.2)中的计算方法一致,但它不需要访问堆中对象。


我们以图3.3中 block 2内的第一个存活对象为例(即图中加粗的方块),block 0中的第2、3、6、7位被设置, block 1中的第3、5位被设置(本例中,每个内存块包含8个槽),这表示在该对象之前已经有7个内存颗粒(字)在位图中得到了标记,因此block 2中的第一个存活对象将被移动到堆中第7个槽中。回收器将这一地址记录在与该块对应的偏移向量中(图中标有offset [block]的虚线)。

完成偏移向量的计算后,回收器将更新根以及存活域,并使其指向对象的新地址。


在Lisp 2算法中,由于迁移信息记录在堆中,且堆中对象的移动会破坏原有对象的迁移信息,因此回收器需要将更新引用和移动对象的过程分开。

但在Compressor算法中,转发地址可以快速地通过标记位向量和偏移向量实时计算得到,因而无须将其保存在堆中,于是回收器可以在单次遍历过程中同时完成对象的迁移以及引用的更新,即算法3.4中的updateReferencesRelocate方法。对于堆中任意给定地址的对象,Compressor回收器均可通过newAddress方法获取其内存块编号(通过移位和掩码操作),并且将该值作为偏移向量的索引值来获取其中第一个存活对象的转发地址,然后再借助标记向量获取内存块中存活对象的数量及大小,并据此增加偏移。

这一操作可以通过查表的方式在常数时间内完成,例如,在图3.3中,对象old在内存块的已标记槽中的偏移量为3,那么该对象的目标地址将是第10个槽,即:offset [block]=7加上offsetInBlock(old)=3。

四、复制式回收

  • 标记—清扫回收的开销较低,但其可能受到内存碎片问题的困扰。

在一个设计良好的系统中,垃圾回收通常只会占用整体执行时间的一小部分,赋值器的执行开销将决定整个程序的性能,因此应当设法降低赋值器的开销,特别是应当尽量提升它的分配速度。

  • 标记—整理回收器可以根除碎片问题,而且支持极为快速的“阶跃指针”(bump a pointer)分配(见第7章),但它需要多次堆遍历过程,进而显著增加了回收时间。

本章将介绍第三种追踪式回收算法:半区复制(semispace copying)。

回收器在复制过程中会进行堆整理,从而可以提升赋值器的分配速度,且回收过程只需对存活对象遍历一次。其最大的缺点在于,堆的可用空间降低了一半

每次使用使用一半,会收时存活的对象移入另一半区域(同时会整理在一起),当前使用区域全部回收,另一区域变为使用区域。

4.1 半区复制回收

原理

基本的复制式回收器会将堆划分为两个大小相等的半区 (semispace),分别是 来源空间(fromspace)目标空间(tospace)

为了简单起见,算法4.1假定堆是一块连续的内存空间,但这并非强制性要求。当堆空间足够时,在目标空间中分配新对象的方法是根据对象的大小简单地增加空闲指针,如果可用空间不足,则进行垃圾回收。

回收器在将存活对象从来源空间复制到目标空间之前必须先将 两个半区的角色互换 (见算法4.2中的第2行)。在回收过程中,回收器简单地将存活对象从来源空间中迁出;在回收完成后,所有存活对象将紧密排布在目标空间的一端。

在下一轮回收之前,回收器将简单地丢弃来源空间(以及其中的对象),但在实际应用中基于安全考虑,许多回收器在初始化下一轮回收过程之前都会先将该区域清零(见第11章中讨论运行时系统接口的内容)。

在这里插入图片描述

在回收过程的初始化完成之后,半区复制回收器首先将根对象复制到目标空间,并以此来填充工作列表(算法4.2中的第4行)。

当遍历到来源空间中的某一对象时,copy方法首先检查该对象是否已完成迁移(即是否已存在转发地址),如果没有,则将该对象复制到目标空间中指针free所指向的地址,同时根据对象的大小增加指针free(与分配过程类似)。

对于存活对象在目标空间中的对应副本,回收器必须能够保持其原有的拓扑关系,因此当回收器将对象复制到目标空间时,会将其转发地址记录在来源空间内的原有对象中(算法4.2中的第34行)。

forward方法在对目标空间中的域进行扫描时会使用目标对象的转发地址来更新该域,如果目标对象的转发地址尚不存在,则对该对象进行复制(算法4.2中的第22行)。当回收器完成对目标空间中所有对象的扫描时,回收过程结束。

与标—整理回收不同,半区复制回收无须在对象头部中引入额外空间。由于来源空间中的对象在复制完成后便不再使用,所以其每个槽都可以用于记录转发地址(至少在万物静止式回收中如此)。因此复制式回收甚至适用于不包含头部的对象。

在这里插入图片描述

4.1.1 工作列表的实现

与其他追踪式回收器类似,半区复制需要一个工作列表来记录待处理对象。工作列表有多种实现方式,每种方式的对象图遍历顺序以及空间需求各不相同。

Fenichel和 Yochelson的策略是将工作列表作为一个简单的辅助栈,十分类似于第二章所描述的标记—清扫回收器所使用的标记栈,当栈为空时,复制过程结束。


Cheney扫描(Cheney scanning)算法

Cheney提出的一种十分优雅的算法,该算法利用目标空间中的灰色对象实现先进先出队列。该算法仅需要一个指针scan来指向下一个待扫描对象,除此之外不再需要任何额外空间。

  • 在半区翻转完成后,指针free和指针scan均指向目标空间的起始地址(见算法4.3中的initialise方法)。

  • 完成根对象的复制后,指针scan和指针free之间的灰色对象(已完成复制但未完成扫描)便构成了工作列表。

  • 随着目标空间中对象域的扫描以及更新,指针scan 不断向前迭代(见算法4.3中的第9行)。

  • 当工作列表为空,也就是指针scan与指针free重合时,回收完成。

该算法的实现非常简单,要确定回收过程是否完成,仅需要通过isEmpty方法判断指针scan和指针free是否重合,remove方法只是简单地返回指针scan,add方法则无须执行任何操作。

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

在这里插入图片描述

4.2 遍历顺序与局部性

赋值器和回收器的局部性对程序性能有重要影响。


启发式方法

以标记—清扫和复制式回收的对比为例:

  • 对于空间较小的堆:在同等条件下,标记—清扫回收的可用堆大小是复制式回收的两倍,因此其回收次数会比后者少一半,于是我们可能会认为标记—清扫回收的整体性能更优。

  • 对于较大的堆,顺序分配提升了赋值器的局部性,提升了各个层次的缓存命中率,其所带来的性能收益明显高于标记—清扫回收的空间收益。

Cheney的复制式回收器遍历顺序在本质上属于广度优先顺序,尽管其在目标空间中对灰色对象的扫描是线性的(即其访问模式具有可预测性),但是它会将父节点与子节点分离,从而破坏了赋值器的局部性。

对于图4.2a 所示的对象布局,图4.2b 中的表对比了对同一对象布局使用不同顺序进行遍历的结果,每一行展示了不同遍历顺序下对象在目标空间中的最终排列形式。对第2行进行观察可知,在广度优先顺序下,只有对象2和3距其父节点较近。

在这里插入图片描述
复制式回收器和整理式回收器都会移动对象,因此可以潜在地影响赋值器的局部性。

  • 对于标记—整理算法而言,滑动顺序通常是最优的,因为它保持了赋值器分配对象时建立的顺序。

  • 对于将存活对象迁移到新空间且不破坏原有数据的复制式回收,其可以通过对象的重排列来提升赋值器的局部性。

但是,我们无法找到一个最优的对象布局来最大限度地提升程序的高速缓存命中率,其原因有二:

  1. 回收器无法预知赋值器未来将会以何种方式访问存活对象
  2. 其次,Petrank和Rawitz 指出,对象排列问题是一个NP完全问题,也就是说,即使可以完全预知赋值器未来访问对象的次序,也无法找到一个高效算法来计算出最优的排列方式。

NP完全问题
NP的英文全称是Non-deterministic Polynomial的问题,即 多项式 复杂程度的 非确定性 问题。

唯一的办法是使用启发式方法。通过程序过往的行为来预测其未来的行为是一种可行方案。

启发式算法(heuristic algorithm)是相对于 最优化 算法提出的。

  1. 一些研究者假定程序在不同输人下行为都是相似的,进而采取 在线分析(profiling) 策略,也有研究者假定程序在连续两个时间区间内的行为不会发生变化,进而使用**在线采样(onlinesampling)**策略。

  2. 另一种启发式方法是保持对象在分配时的顺序,就像滑动整理那样。

  3. 第三种方法是尝试将子节点靠近它的某个父节点排列,因为访问子节点的唯一途径是经过该节点的一个父节点。Cheney 算法使用广度优先遍历,从而导致具有相关性的对象分离,即趋向于将“远亲”而非父子节点排列在一起, 而深度优先遍历则趋向于将子节点与其父节点排列得更近(图4.2b中的第一行)。

优化深度优先

对于不同复制顺序对赋值器局部性的影响,早期的研究主要集中在减少 缺页异常(page fault) 方面,其目的是将相关对象排列在同一内存页中。

Moon对Chenny算法进行了修改并使其拥有近似深度优先的遍历顺序,新算法在指针scan的基础上引入了第二个指针partialScan (见图4.3)。

在这里插入图片描述
Fenchel和Yochelson的算法通过引人一个辅助的后进先出标记栈来达到深度优先遍历顺序,但即使不使用辅助栈且不付出空间代价也可以实现准深度优先遍历。

当完成某一对象的复制后,该算法先在目标空间内最后一个尚未完成扫描的页中进行次级扫描( secondary scan),然后才会在第一个未完成扫描的页中继续进行主扫描(见算法4.4)。此时的工作列表实际,上是由一对Cheney队列组成的。

与纯粹的广度优先搜索相比,这一层次分解(hierarchical decomposition)方案的优势在于,它能更有效地将父节点与子节点排列在同一页中。

图4.2b中的第三行展示了在一页可以容纳三个对象时,对整棵树使用层次分解算法进行复制之后的状态。

在这里插入图片描述
Moon的算法最大缺陷在于,它只记录了一对扫描指针,无法区分指针scan和指针free之间的哪些对象已完成扫描,因而有可能将某些对象扫描两次。

Wilson 等 声称Moon的算法重复扫描的比例大概有30%,并对此进行改进。

  • 他们为每一页记录指针scan和指针free,从而将工作列表变成所有需要进行部分扫描的块的链表,因此主扫描可以跳过已经完成次级扫描的对象。

为什么广度优先不行

2.6节曾讨论过如何提升标记—清扫回收器标记阶段的性能,其中提到,Cher等指出使用栈来引导的遍历遵从深度优先顺序,但其对高速缓存行的预取却遵从广度优先顺序。

因此一个自然而然的问题便是,是否可以将基于栈的深度优先复制与Cher等的先进先出预取队列相结合?

很遗憾,答案是否定的。

尽管先进先出顺序可以减少高速缓存不命中对复制过程的影响,但它会将父子节点分开,因为只有当对象从预取队列中移除,而不是从栈中移除时,回收器才会访问对象所包含的引用。

我们来考察图4.4中将字符串对象S从栈中弹出的过程:
在这里插入图片描述
在理想情况下,对象S应当与其相关的字符数组C一起排列在目标空间中,这正是深度优先算法所能达到的效果。

当使用先进先出队列时,S从栈中弹出后将被立即添加到预取队列中,假设此时队列已满,则回收器会将最老的对象X从队列中移除并复制,同时将其引用的对象Y和乙压人栈中。但是,回收器从队列中移除和复制Y和Z将发生在S之后、C之前。

上述各种复制算法的重排列方式都是静态的,即它们都没有考虑具体程序的实际行为,但可以肯定的是,对象重排列方式所能带来的收益最终取决于赋值器的行为。


在线对象记录

Lam等 发现,两种算法都对程序数据结构的组合方式以及形状十分敏感,对于非树形结构,其性能反而会有所降低。

Siegwart和Hirzel也发现,并行层次分解回收器可以提升某些基准测试程序的性能,但对于其他的则几乎没有效果。

为解决这一问题,Huang 等 对程序进行动态分析,并尝试将对象的“热”域与其父节点排列在一起。算法4.5展示了他们的在线对象记录( online object recording) 方法,图4.2b的最后一行展示了其效果。在算法的主扫描循环中(算法4.5中的第6行),回收器在对所有的“冷”域进行处理之前会先处理工作列表中的所有“热”域。

对于一个配备了 方法采样机制(method sampling mechanism) 的自适应动态编译器而言,确定这些域的开销通常很小。

他们的算法也可以通过对“热”域的淘汰与重新扫描来适应程序在不同阶段的行为变化,他们发现在引人该算法后,系统的性能可以达到或者超过了诸如广度优先等静态重排列顺序。

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

其他方式

Chen等 以及Chilimbi和Larus 各自通过在分代回收器中主动调用回收器来提升局部性,但其开销较大,因而不会经常启用。对于以提升局部性为目标的回收,分配率的变化是主要的触发因素,转译后备缓冲区( translation lookaside buffer, TLB)中的数据或者L2高速缓存命中率的变化是次要触发因素。

他们将得到访问的对象记录在一个固定大小的环状缓冲区中(他们声称,节点级别分析比域级别分析的开销要小5%,因为面向对象程序中大多数对象都小于32字节)。在突发式的采样过程中,他们使用一个开销较大(但经过高度优化)的读屏障日来拦截赋值器加载引用的操作,并据此分辨热对象。热对象复制的过程分为两个阶段:

  1. 首先将赋值器正在访问的对象复制到一个临时缓冲区中,然后使用层次分解的方法将热对象添加到该缓冲区以提升换页性能。
  2. 回收器将已复制对象的原有位置标记为空闲,然后将临时缓冲区中经过重排列的对象移动到堆的一端。

该方案尝试在高速缓存性能以及换页行为方面同时进行优化。实验结果表明,将两种优化方法结合的收益通常大于两者各自收益的总和,且对于多数大型C#应用程序而言,平均执行时间都会得到改善。尽管该算法会保留部分垃圾对象,但其总量通常很小。


还有学者提出,可以依照对象类型来进行自定义的静态重排序,特别是对于系统数据结构来说。

  • 通过允许类的开发者来决定域的复制顺序,Novark等 显著提升了某些特定数据结构的高速缓存命中率。Shuf等 使用 离线分析(off-line profiling) 的方法来确定 富类型( prolific type) ,他们同时对分配器进行修改,即当创建父对象时为其子对象预留相邻空间,这样既提升了局部性,同时又可以将具有相同生命周期的对象聚集在一起。

对于本节所提到的将先进先出预取队列与深度优先复制相结合所带来的问题,该方案可以在一定程度上予以缓解。

附录

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

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
书围绕着动态内存自动回收的话题,介绍了垃圾收集机制,详细分析了各种算法和相关技术。   本书共12章。第1章首先介绍计算机存储器管理的演化和自动内存回收的需求,并引入了本书所使用的术语和记法。第2章介绍了3种“经典”的垃圾收集技术:引用计数(reference counting)、标记-清扫(mark-sweep)和节点复制(copying)。 随后的4章更详细地讨论了上述这些垃圾收集方标记-缩并(mark-compact)收集。第7章和第8章分别介绍了在现代垃圾收集实现中具有重要地位的分代(generational)垃圾收集和渐进(incremental)垃圾收集。第9章和第10章扩展了垃圾收集的领域,讨论了如何让垃圾收集能够在无法得到来自语言编译器的支持的环境(分别是C和C++)中运行。第11章讨论了一个相对较新的研究领域 -- 垃圾收集和硬件数据cache的相互作用。第12章简要地考察了用于分布系统的垃圾收集。   本书适合对动态内存管理感兴趣的读者阅读,可供专业的研究人员参考。 目录: 第1章 简介 1.1 内存分配的历史 1.1.1 静态分配 1.1.2 栈分配 1.1.3 堆分配 1.2 状态、存活性和指针可到达性 1.3 显堆分配 1.3.1 一个简单的例子 1.3.2 垃圾 1.3.3 悬挂引用 1.3.4 共享 1.3.5 失败 1.4 为什么需要垃圾收集 1.4.1 语言的需求 1.4.2 问题的需求 1.4.3 软件工程的课题 1.4.4 没有银弹 1.5 垃圾收集的开销有多大 1.6 垃圾收集算法比较 1.7 记法 1.7.1 堆 1.7.2 指针和子女 1.7.3 伪代码 1.8 引文注记 第2章 经典算法 2.1 引用计数算法 2.1.1 算法 2.1.2 一个例子 2.1.3 引用计数算法的优势和弱点 2.1.4 环形数据结构 2.2 标记——清扫算法 2.2.1 算法 2.2.2 标记——清扫算法的优势和弱点 2.3 节点复制算法 2.3.1 算法 2.3.2 一个例子 2.3.3 节点复制算法的优势和弱点 2.4 比较标记——清扫技术和节点复制技术 2.5 需要考虑的问题 2.6 引文注记 第3章 引用计数 3.1 非递归的释放 3.1.1 算法 3.1.2 延迟释放的优点和代价 3.2 延迟引用计数 3.2.1 Deutsch-Bobrow算法 3.2.2 一个例子 3.2.3 ZCT溢出 3.2.4 延迟引用计数的效率 3.3 计数域大小受限的引用计数 3.3.1 “粘住的”计数值 3.3.2 追踪收集恢复计数值 3.3.3 仅有一位的计数值 3.3.4 恢复独享信息 3.3.5 “Ought to be two”缓冲区 3.4 硬件引用计数 3.5 环形引用计数 3.5.1 函数程序设计语言 3.5.2 Bobrow的技术 3.5.3 弱指针算法 3.5.4 部分标记——清扫算法 3.6 需要考虑的问题 3.7 引文注记 第4章 标记——清扫垃圾收集 4.1 与引用计数技术的比较 4.2 使用标记栈 4.2.1 显地使用栈来实现递归 4.2.2 最小化栈的深度 4.2.3 栈溢出 4.3 指针反转 4.3.1 Deutsch-Schorr-Waite算法 4.3.2 可变大小节点的指针反转 4.3.3 指针反转的开销 4.4 位图标记 4.5 延迟清扫 4.5.1 Hughes的延迟清扫算法 4.5.2 Boehm-Demers-Weriser清扫器 4.5.3 Zorn的延迟清扫器 4.6 需要考虑的问题 4.7 引文注记 第5章 标记——缩并垃圾收集 5.1 碎片现象 5.2 缩并的方 5.3 “双指针”算法 5.3.1 算法 5.3.2 对“双指针”算法的分析 5.3.3 可变大小的单元 5.4 Lisp2算法 5.5 基于表的方法 5.5.1 算法 5.5.2 间断表 5.5.3 更新指针 5.6 穿线方法 5.6.1 穿线指针 5.6.2 Jonkers的缩并算法 5.6.3 前向指针 5.6.4 后向指针 5.7 需要考虑的问题 5.8 引文注记 第6章 节点复制垃圾收集 6.1 Cheney的节点复制收集器 6.1.1 三色抽象 6.1.2 算法 6.1.3 一个例子 6.2 廉价地分配 6.3 多区域收集 6.3.1 静态区域 6.3.2 大型对象区域 6.3.3 渐进的递增缩并垃圾收集 6.4 垃圾收集器的效率 6.5 局部性问题 6.6 重组策略 6.6.1 深度优先节点复制与广度优先节点复制 6.6.2 不需要栈的递归节点复制收集 6.6.3 近似于深度优先的节点复制 6.6.4 层次分解 6.6.5 哈希表 6.7 需要考虑的问题 6.8 引文注记 第7章 分代垃圾收集 7.1 分代假设 7.2 分代垃圾收集 7.2.1 一个简单例子 7.2.2 中断时间 7.2.3 次级收集的根集合 7.2.4 性能 7.3 提升策略 7.3.1 多个分代 7.3.2 提升的阈值 7.3.3 Standard ML of New Jersey收集器 7.3.4 自适应提升 7.4 分代组织和年龄记录 7.4.1 每个分代一个半区 7.4.2 创建空间 7.4.3 记录年龄 7.4.4 大型对象区域 7.5 分代间指针 7.5.1 写拦截器 7.5.2 入口表 7.5.3 记忆集 7.5.4 顺序保存缓冲区 7.5.5 硬件支持的页面标记 7.5.6 虚存系统支持的页面标记 7.5.7 卡片标记 7.5.8 记忆集还是卡片 7.6 非节点复制的分代垃圾收集 7.7 调度垃圾收集 7.7.1 关键对象 7.7.2 成熟对象空间 7.8 需要考虑的问题 7.9 引文注记 第8章 渐进和并发垃圾收集 8.1 同步 8.2 拦截器方案 8.3 标记——清扫收集器 8.3.1 写拦截器 8.3.2 新单元 8.3.3 初始化和终止 8.3.4 虚存技术 8.4 并发引用计数 8.5 Baker的算法 8.5.1 算法 8.5.2 Baker算法的延迟的界限 8.5.3 Baker的算法的局限 8.5.4 Baker算法的变种 8.5.5 动态重组 8.6 Appel-Ellis-Li收集器 8.6.1 各种改进 8.6.2 大型对象 8.6.3 分代 8.6.4 性能 8.7 应变复制收集器 8.7.1 Nettle的应变复制收集器 8.7.2 Huelsbergen和Larus的收集器 8.7.3 Doligez-Leroy-Gonthier收集器 8.8 Baker的工作环收集器 8.9 对实时垃圾收集的硬件支持 8.10 需要考虑的问题 8.11 引文注记 第9章 C语言的垃圾收集 9.1 根不确定收集的一个分类 9.2 保守垃圾收集 9.2.1 分配 9.2.2 寻找根和指针 9.2.3 内部指针 9.2.4 保守垃圾收集的问题 9.2.5 识别错误 9.2.6 效率 9.2.7 渐进、分代垃圾收集 9.3 准复制收集 9.3.1 堆的布局 9.3.2 分配 9.3.3 垃圾收集 9.3.4 分代垃圾收集 9.3.5 无法精确识别的数据结构 9.3.6 准复制收集的效率 9.4 优化的编译器是“魔鬼” 9.5 需要考虑的问题 9.6 引文注记 第10章 C++语言的垃圾收集 10.1 用于面向对象语言的垃圾收集 10.2 对C++垃圾收集器的需求 10.3 在编译器中还是在库中 10.4 保守垃圾收集 10.5 准复制收集器 10.6 智能指针 10.6.1 在没有智能指针类层次的情况下进行转换 10.6.2 多重继承 10.6.3 不正确的转换 10.6.4 某些指针无法“智能化” 10.6.5 用const和volatile修饰的指针 10.6.6 智能指针的“泄漏” 10.6.7 智能指针和引用计数 10.6.8 一个简单的引用计数指针 10.6.9 用于灵活的垃圾收集的智能指针 10.6.10 用于追踪垃圾收集的智能指针 10.7 为支持垃圾收集而修改C++ 10.8 Ellis和Detlefs的建议 10.9 终结机制 10.10 需要考虑的问题 10.11 引文注记 第11章 垃圾收集与cache 11.1 现代处理器体系结构 11.2 cache的体系结构 11.2.1 cache容量 11.2.2 放置策略 11.2.3 写策略 11.2.4 特殊的cache指令 11.3内存访问的模 11.3.1 标记——清扫技术,使用标记位图和延迟清扫 11.3.2 节点复制垃圾收集 11.3.3 渐进垃圾收集 11.3.4 避免读取 11.4 改进cache性能的标准方法 11.4.1 cache的容量 11.4.2 块大小 11.4.3 相联度 11.4.4 特殊指令 11.4.5 预取 11.5 失误率和总体cache性能 11.6 专用硬件 11.7 需要考虑的问题 11.8 引文注记 第12章 分布垃圾收集 12.1 需求 12.2 虚拟共享存储器 12.2.1 共享虚拟存储器模型 12.2.2 共享数据对象模型 12.2.3 分布共享存储器之上的垃圾收集 12.3 与分布垃圾收集有关的课题 12.3.1 分类原则 12.3.2 同步 12.3.3 鲁棒性 12.4 分布标记——清扫 12.4.1 Hudak和Keller 12.4.2 Ali的算法 12.4.3 Hughes的算法 12.4.4 Liskov-Ladin算法 12.4.5 Augusteijn的算法 12.4.6 Vestal的算法 12.4.7 Schelvis-Bledoeg算法 12.4.8 Emerald收集器 12.4.9 IK收集器 12.5 分布节点复制 12.6 分布引用计数 12.6.1 Lermen-Maurer协议 12.6.2 间接引用计数 12.6.3 Mancini-Shrivastava算法 12.6.4 SPG协议 12.6.5 “Garbage collecting the world” 12.6.6 网络对象 12.6.7 带权引用计数 12.6.8 世代引用计数 12.7 对actor进行垃圾收集 12.7.1 Halstead算法 12.7.2 标记算法 12.7.3 逻辑上集中的收集器 12.8 引文注记

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值