GC系列:如何优化标记-整理算法

来源:互联网 发布:比原子小的物质知乎 编辑:程序博客网 时间:2024/05/17 04:52

引言

标记-整理算法有一个整理对象,避免产生内存碎片的过程,那么回收器是怎么整理对象的?整理算法又是怎么区分性能好坏的?整理过程大概需要哪几个步骤?《The Garbage Collection Handbook》详细地描述了这一算法。
    内存碎片化是非移动式回收器无法解决的问题之一,尽管堆中有可用空间,却无法找到一块连续的内存块来满足较大对象的分配需求或者需要花费较长时间才能找到。
    堆整理的最大优势在于,它允许极为快速的顺序分配,简单地进行堆上限判断,然后根据所需要空间的大小阶跃式地移动空闲指针。

    标记-整理的步骤:

  1. 标记阶段
  2. 整理阶段:移动存活对象,同时更新存活对象中所有指向被移动对象的指针

整理的顺序

    不同算法中,堆遍历的次数,整理的顺序,对象的迁移方式都有所不同。而整理顺序又会影响到程序的局部性。主要有以下3种顺序:
     1. 任意顺序:对象的移动方式和它们初始的对象排列及引用关系无关
     2. 线性顺序:将具有关联关系的对象排列在一起
     3. 滑动顺序:将对象“滑动”到堆的一端,从而“挤出”垃圾,可以保持对象在堆中原有的顺序
    任意顺序整理实现简单,且执行速度快,但任意顺序可能会将原本相邻的对象打乱到不同的高速缓存行或者是虚拟内存页中,会降低赋值器的局部性。
    所有现代的标记-整理回收器均使用滑动整理,它不会改变对象的相对顺序,也就不会影响赋值器的空间局部性。复制式回收器甚至可以通过改变对象布局的方式,将对象与其父节点或者兄弟节点排列的更近以提高赋值器的空间局部性。
    整理算法的限制,如任意顺序算法只能处理单一大小的对象,或者针对大小不同的对象需要分批处理;整理过程需要2次或者3次遍历堆空间;对象头部可能需要一个额外的槽来保存迁移的信息。

    几种不同类型的整理算法

  1. 双指针回收算法:实现简单且速度快,但会打乱对象的原有布局
  2. Lisp2算法(滑动回收算法):需要在对象头用一个额外的槽来保存迁移完的地址
  3. 引线整理算法:可以在不引入额外空间开销的情况下实现滑动整理,但需要2次遍历堆,且遍历成本较高
  4. 单次遍历算法:滑动回收,实时计算出对象的转发地址而不需要额外的开销

    双指针整理算法:

    属于任意顺序整理算法,需要2次遍历堆空间,最佳适用场景为只包含固定大小的区域。原理:针对某一块内存区域中的待整理的存活对象,先计算出如果所有存活对象都移动到一端之后的截止地址,然后将地址大于该截止地址的对象移动到截止地址以下的空间。
    如图,高水位阈值即为截止地址。在算法的开始阶段,指针free指向区域始端,指针scan指向区域末端。在第一次遍历过程中,回收器不断向前移动指针free,直到发现空隙。同时,指针scan由后往前移动,直到遇到存活对象。然后在该对象的对象头部记录下转发地址。如果指针free和scan交错,则该过程停止,否则变将指针scan遇到的对象移动到free所在的位置。在第二次遍历中,回收器将指向存活对象边界外的指针更新为目标对象头部记录的转发地址,即对象的新地址。该算法的整理质量取决于指针free所指向的空隙和指针scan所指向的对象大小的匹配度。

    优点:

    速度快,简单,且每次遍历的操作比较少。转发地址是在对象移动之后才写入的,不会存在信息丢失。同时,由于指针scan是由高位向地位移动的,所以要求回收器能逆向进行堆解析。但是由于对象的移动是任意的,可能会将原先并列的对象顺序打乱,降低赋值器的空间局部性。

    Lisp2整理算法:

    在标记结束的第一次遍历堆时,回收器会计算出每个对象的最终转发地址,并保存在对象头的某一个域中。计算转发地址需要3个参数,分别是待整理区域的起始地址,最终地址,目标区域的起始地址。目标区域通常与待整理区域相同。指针scan扫描来源区域的所有(存活的和死亡的)对象,指针free指向目标区域的下一个空闲地址。如果scan扫描到的对象是存活的,那就意味着该对象最终会移动到指针free所在地址,然后将该地址写入到对象头域中,然后根据对象的大小向前移动指针free.
    在第二次遍历时,回收器将使用对象头域中保存的转发地址来更新赋值器线程根以及被标记的对象,确保它们指向对象的新位置。第3次遍历时,才将对象移动至转发地址。
    需要注意的是,遍历的方向(从低地址到高地址)和对象移动的方向(从高地址到低地址)相反,这样可以保证低地址的对象移动到高地址的时候,高地址区域已经腾空。
    缺点:1.需要遍历3次;2.需要额外的空间记录转发地址。滑动回收是一种具有破坏性的操作,存活对象的新副本会覆盖其他存活对象的原有副本,所以在移动对象,更新引用之前,必须记录下其转发地址。

    单次遍历算法:

    首先是标记的过程,标记过程是基于位图的,每个位对应堆中的一个颗粒(即一个字)。在标记过程中,如果发现存活对象,就设置该对象的第一个字节和最后一个字节在位图中对应的位(称为标记向量)。回收器会在整理阶段时根据标记向量的分析计算出任意存活对象的大小。

    回收器使用额外的一张表来记录转发地址,但是如果记录每个对象的转发地址,则开销又过大。所以该算法将堆分成大小相等的内存块(256字节和512字节)。偏移向量记录了每个内存块中第一个存活对象的转发地址,而其他对象的地址则可以根据偏移向量和前面提到的标记向量实时计算出来。
    所以对于给定的任何一个对象,可以先计算出它所在的内存块的索引,再根据该内存块的偏移向量和该对象的标记向量计算出该对象的转发地址。所以回收器不需要遍历2次堆内存来移动对象和更新指针,而是先通过对标记位向量的一次遍历来够造偏移向量,然后通过一次堆遍历过程同时完成对象的移动和指针的更新。减少堆的遍历次数也可以提升回收器的局部性。
    在Lisp2算法中,由于对象的迁移信息记录在对象头中(堆)中,且堆中对象的移动会破坏原有对象的迁移信息,所以回收器需要将更新引用和移动对象的过程分开。但在单次遍历算法中,迁移信息是根据偏移向量和标记向量实时计算出来的,无须保存在堆中,所以回收器可以在单次遍历过程中同时完成对象的迁移和引用的更新。

    需要考虑的问题:

    1.整理的吞吐量开销

    在整理式堆中进行顺序分配的速度很快。如果堆的可用内存相对较大,标记-整理算法是一个合适的移动式回收策略。与标记-清扫和复制式算法相比,标记-整理的回收速度较慢。许多整理算法对空间有额外开销,或者对赋值器有一定的要求,同时因为需要多次堆遍历,所以和标记-清扫/复制式回收算法相比,吞吐量较低。每次遍历的开销都很大,因为不仅需要多次访问对类型信息和对象的指针域,还有指针开销也很大。一个通用的解决方案是尽量使用标记-清扫算法,直到碎片化达到一定程度时才使用标记-整理算法

    2.长寿数据

    复制式算法在处理长寿对象(永生对象)时的效果很差,它只会重复得将这些对象从一个区复制到另外一个区。分代回收器可以将这些对象移动到一个很少进行回收的区域,从而较好的实现长寿对象的处理。但是分代回收器并不适用较小堆空间的情况。因为如果要对分代回收器中最老的一代进行回收,它仍要处理长寿对象。相比之下,标记-整理回收器则可以选择不去整理这一个区域。

    3.局部性

    标记-整理回收器可能会保留对象在堆中原有的分配顺序。随意打乱对象排列顺序会影响赋值器的局部性。

    4.标记-整理算法的局限性

    包括记录转发地址需要占用多少额外的空间。某些整理算法对赋值器有要求:双指针等简单的处理算法只能处理固定大小的对象,将对象按照大小分级当然可以,但是既然已经如此又何须进行整理?引线算法要求指针和暂存在指针域的非指针临时值进行区分,由于引线算法会(临时性)破坏指针域,所以不适合并发回收器。

阅读全文
1 0
原创粉丝点击