GC标记压缩算法

原理篇

GC标记压缩算法分为标记阶段和压缩阶段。它是将GC标记清除算法的清除阶段换成了压缩,而且这里的压缩不是将活动对象从一个空间复制到另一个空间,而是将活动对象整体前移,挤占非活动对象的空间。

你可以想象成俄罗斯方块的消除,或者想象活动对象是很重的石块,非活动对象是石块间的泡沫,石块在重力的作用下将泡沫压碎挤出,最后活动对象就紧密的排在了堆的开头。因此GC标记压缩算法不需要将堆均分,而是可以利用整个堆,所以堆的利用率要比GC复制算法高。

接下来介绍的算法是Donald E. Kunth的Lisp2算法。

Lisp2算法的对象头中也有一个forwarding指针,用来记录新对象的地址,目的还是用于指针重写。

在这里插入图片描述

下面先来看一个简单的示例。

在这里插入图片描述

第一步还是先标记,标记阶段和GC标记清除算法一样。第二步是压缩阶段,压缩执行完以后,垃圾被挤了出去,活动对象按照原来的顺序紧密排列在堆的开头。

压缩阶段共有三个步骤:

  • 计算对象新地址,写入forwarding指针
  • 指针重写
  • 移动对象

压缩阶段的伪代码如下。

//压缩阶段
compaction_phase() {
    
     
  set_forwarding_ptr() //设置对象新地址 
  adjust_ptr() //指针重写
  move_obj() //移动对象
}

第一步 找到新的家

set_forwarding_ptr的任务是找到每个对象压缩后的地址,并且记录到对象的forwarding指针中。其实就是双指针法,一个指针寻找活动对象,一个指针指向空闲位置。伪代码如下。

//设置对象新地址
set_forwarding_ptr() {
    
    
  //scan用来寻找活动对象
  //new_address指向空闲空间
  scan = new_address = $heap_start 
  while(scan < $heap_end) //遍历堆 
    if(scan.mark == TRUE) //活动对象
      //scan指向的活动对象将会移动到new_address处
      //这里先记录下这个新地址,指针重写和压缩时会用到
      scan.forwarding = new_address 
      new_address += scan.size //跳过该活动对象占用的空间
    scan += scan.size //下一个对象
}

这里我们还没有真正的移动对象,而是假设整个堆都是空闲的,然后来安排活动对象的地址。通过scan遍历整个堆寻找活动对象,它们的目标位置就是new_address指向的地方。这是我们第一次遍历堆,这一步执行完以后,堆的状态如下。

在这里插入图片描述

第二步 更新新家地址

经过第一步,我们已经知道了每个对象在压缩后的新地址,这一步我们需要根据这个新地址重写指针,以保证压缩后对象引用关系的正确性。对于需要移动对象的压缩算法,重写指针是不可避免的。重写指针通过adjust_ptr函数完成,伪代码如下。

//重写指针
adjust_ptr() {
    
     
  for(r : $roots) //遍历根直接引用的对象
    *r = (*r).forwarding //重写根引用的指针
  scan = $heap_start //scan指向堆开头
  while(scan < $heap_end) //遍历堆
    if(scan.mark == TRUE) //活动对象
      for(child : children(scan)) //遍历子对象
        *child = (*child).forwarding //重写子对象引用的指针
    scan += scan.size //下一个对象
}

首先遍历根直接引用的对象,重写到它们的指针,然后遍历堆,重写活动对象引用的指针。这是我们第二次遍历堆,这一步完成后,堆的状态如下。

在这里插入图片描述

第三步 搬家

前期准备工作已做完,现在我们要将活动对象复制到它们该去的位置,完成压缩。这一步由move_obj函数完成,伪代码如下。

//移动对象
move_obj(){
    
    
  //scan用于遍历堆
  //$free指向空闲空闲开头,这里需要更新它,因为分配时需要用到它
  scan = $free = $heap_start 
  while(scan < $heap_end) //遍历堆
    if(scan.mark == TRUE) //活动对象
      new_address = scan.forwarding 
      copy_data(new_address, scan, scan.size) //将对象复制到新地址
      new_address.forwarding = NULL //清空forwarding指针
      new_address.mark = FALSE //取消活动对象标记
      $free += new_address.size //跳过活动对象占用的空间
    scan += scan.size //下一个对象
}

这是我们第三次遍历堆,将活动对象复制到forwarding指针的位置,并将它和活动对象标记都清除。这一步完成以后$free之前都是活动对象,$free之后都是空闲空间。堆状态如下。

在这里插入图片描述

GC标记压缩算法优点如下:

  • 堆的利用率高
  • 分配速度快
  • 不会产生碎片化

GC标记压缩算法缺点是吞吐量低。因为在压缩阶段我们需要遍历堆3次,耗费时间与堆大小成正比,堆越大,耗费时间越久。

优化篇

Two-Finger算法

由Robert A. Saunders发明,压缩阶段只需要搜索堆2次。

把垃圾看成是活动对象间的空隙,Lisp2算法是在缩小空隙,Two-finger算法是在填补空隙。其基本思想是用指针A从前往后找空隙,用指针B从后往前找活动对象,将活动对象复制到空隙处。所以也可以称为双指针法,大致流程如下。

在这里插入图片描述

为了让填补空隙的过程具有可操作性,我们需要一个重要的设定:假设堆内对象大小全部相同。如此我们就不用去考虑空隙大小是否能够容纳活动对象这样烦人的问题了。

Two-Finger算法的压缩阶段只有两个步骤:

  • 移动对象
  • 重写指针

第一步 移动对象

假设堆的初始状态如下。

在这里插入图片描述

移动对象的函数是move_obj,伪代码如下。

//移动对象
move_obj() {
    
    
  $free = $heap_start //$free从前往后找非活动对象
  live = $heap_end - OBJ_SIZE //live从后往前找活动对象
  while(TRUE)
    while($free.mark == TRUE) //活动对象
      $free += OBJ_SIZE //跳过活动对象
    while(live.mark == FALSE) //非活动对象
      live -= OBJ_SIZE //跳过非活动对象
    if($free < live) //搜索未完成 
      copy_data($free, live, OBJ_SIZE) //将找到的活动对象复制到空隙处
      live.forwarding = $free //记录对象新地址
      live.mark = FALSE //取消原活动对象的标记
    else //搜索完成
      break //退出循环
}

$free从前往后寻找非活动对象,live从后往前寻找活动对象,它们就是Two-Finger中的那两根手指。由于我们假定了对象大小是一样的,所以遍历对象时可以直接通过加减OBJ_SIZE来进行。否则,我们没办法从后往前遍历对象。

这一步执行完以后,堆的状态如下。

在这里插入图片描述

注意到$free的位置,移动结束后,它刚好位于所有活动对象的末尾,指向空闲空间的开始。在$free的左边都是活动对象,在$free的右边都是非活动对象以及被移动过的活动对象。因此,在下一步重写指针时,我们可以通过对象的位置是否在$free的右边来判断需不需要重写指针。

第二步 重写指针

重写指针主要是重写被移动过的对象的指针,也就是处于$free右侧对象的指针。由函数adjust_ptr完成,伪代码如下。

//重写指针
adjust_ptr() {
    
     
  for(r : $roots) //遍历根直接引用的对象
    if(*r >= $free) //被移动过
      *r = (*r).forwarding //重写为新地址
  scan = $heap_start //从堆头开始
  while(scan < $free) //遍历堆的活动对象,注意不是遍历整个堆
    scan.mark = FALSE //取消活动对象标记
    for(child : children(scan)) //遍历字对象
      if(*child >= $free) //子对象被移动过
        *child = (*child).forwarding //更新为新地址
    scan += OBJ_SIZE //下一个对象
}

在这一步我们只需要遍历活动对象,而不是遍历整个堆。由于在上一步我们只清除了$free右侧活动对象的标记,这里我们继续将$free左侧活动对象的也清除,为下一次GC做准备。

相比于Lisp2算法,Two-Finger算法的优点是吞吐量高一点,因为它只需要遍历1次堆,第二次实际上只遍历了活动对象。

Two-Finger算法的缺点是无法有效利用局部性原理,因为移动对象过程中对象顺序会发生变化。

此外Tow-Finger算法还有一个奇葩的设定,要求对象大小一样。你也许会想,现实中怎么可能存在这种情况呢。不知你是否还记得在GC标记清除算法的优化篇中,有一个BiBOP方法。它的基本思想就是将堆分块,每块只分配同样大小的对象,这样就可以在每一块中分别使用Two-Finger算法了。只能说,世界就是这样的奇妙。

表格算法

表格算法是B. K. Haddon和W. M. Waite于1967年发明的算法,也只需要遍历堆2次。

考虑到许多活动对象在堆中都是连续的,构成一个对象群,我们可以将连续的活动对象作为一个整体来移动。相比于Lisp2算法要为每个对象记录一个forwarding指针,表格算法只需要记录下每个活动对象群第一个对象的移动距离,就能推算出对象群中每个对象的新地址。同时,相比于Two-Finger算法的填空方式,表格算法在压缩过程中能保持对象的相对顺序不变。

表格算法中需要记录的是对象群的最低地址左边空闲空间的大小(也就是移动距离)。所有对象群都需要记录这两项信息,这些信息就叫做间隙表格。表格指的是这两项信息构成的数组,间隙指的是对象群左边空闲空间的大小,就是活动对象间的空隙。间隙才是有用的信息,而对象群最低地址是为了能够找到间隙,相当于一个索引,你可以将它们看成一个键值对。明白这两项信息的作用对于理解算法大有帮助。间隙表格示例如下。

在这里插入图片描述

间隙表格会被记录在空闲空间中,也不需要占据额外的空间。为了能够利用非活动对象记录间隙表格,表格算法在分配对象时有一个最低大小限制。比如记录一个间隙需要2个字,则分配对象大小不能小于2个字。

非活动对象占据的空间就是空闲空间。

表格算法也分两个步骤:

  • 移动对象群并构建间隙表格
  • 重写指针

第一步 移动对象并构建间隙表格

假设移动前堆的状态如下。

在这里插入图片描述

移动对象伪代码如下:

move_obj() {
    
    
  //scan用来遍历堆
  //$free指向空闲空间开头
  scan = $free = $heap_start 
  size = 0 //记录对象群左边空闲空间大小
  while(scan < $heap_end) //遍历堆
    while(scan.mark == FALSE) //非活动对象
      size += scan.size //将非活动对象大小加到空间空间大小中
      scan += scan.size //下一个对象
    live = scan //对象群的第一个对象,也就是对象群最低地址
    while(scan.mark == TRUE) //活动对象
      scan += scan.size //下一个对象
    //移动对象并构建间隙表格
    slide_objs_and_make_bt(scan, $free, live, size) 
    $free += (scan - live) //跳过活动对象群
}

我们通过scan遍历堆,$free始终指向空闲空间开始的位置,size用来记录间隙的大小。当遇到非活动对象时,就把它的大小累加到size。遇到活动对象时,先记录下对象群起始位置,然后继续找到对象群结束位置,通过slide_objs_and_make_bt移动对象群并记录间隙表格。

上图的示例在第一轮移动之前的状态如下。

在这里插入图片描述

构造间隙表格非常复杂,简要过程如下所示,括号中的数字表示对象首地址。

在这里插入图片描述

首先我们移动对象群BC,将最低地址,也就是B的首地址100和BC左边空闲空间大小100写入scan指向的位置,因为此时scan一定指向着一个非活动对象。

接下来继续执行move_obj函数,找到对象群FG。但此时不能直接移动FG,因为这样会覆盖掉450号地址记录的间隙表格。我们需要首先将450号地址记录的间隙表格移动到scan指向的地址,也就是H占据的800号地址,然后移动对象群FG,最后将F的首地址和size记录到G腾出的空间,也就是700号地址的位置。

经过这样的"回避"操作之后,间隙表格就不是按对象群最低地址有序的了,这会给下一步重写指针带来一定的麻烦。因为前面我们说过真正有用的信息是对象群左侧空闲空间大小,也就是移动距离,但这个信息是通过对象群最低地址搜索获得的。如果间隙表格有序,那么可以就可以利用二分查找,否则除了排序就只能线性搜索了。

第二步 重写指针

这一步主要是利用间隙表格记录的信息更新移动后对象的新地址,伪代码如下。

//重写指针
adjust_ptr() {
    
     
  for(r : $roots) //遍历根直接引用对象
    *r = new_address(*r) //重写对象地址
  scan = $heap_start //从堆头开始
  while(scan < $free) //遍历活动对象
    scan.mark = FALSE //取消活动对象标记,为下次GC做准备
    for(child : children(scan)) //遍历子对象
      *child = new_address(*child) //重写对象地址
    scan += scan.size //下一个对象
}

//通过间隙表格计算对象新地址
new_address(obj) {
    
    
  best_entry = new_bt_entry(0, 0) //记录间隙表格的变量
  //找到obj所在对象群对应的间隙表格
  for(entry : break_table) //遍历间隙表格
    //找到最低地址小于等于obj的间隙表格中地址最大的那个
    if(entry.address <= obj && $best_entry.address < entry.address) 
      best_entry = entry //记录找到的间隙表格
  return obj - best_entry.size //原地址-移动距离=新地址
}

因为间隙表格的最低地址记录的是对象群中第一个对象的地址,所以对象群中每个对象的地址一定是大于等于该对象群对应的间隙表格所记录的最低地址的。比如示例中BC对象群对应的间隙表格(100,100),B的地址是100,C的地址是250,都是不小于100的,FG对象群同理。因此查找对象obj所对应的间隙表格,就是找到最低地址不大于obj地址的间隙表格中,地址最大的那个。

找到间隙表格之后,用对象地址减去移动距离就是对象的新地址。比例对象B的原地址是100,移动距离是100,新地址就是100-100=0,与B同属一个对象群的C的原地址是250,移动距离也是100,新地址就是250-100=150。FG同理。

表格算法的优点是空间利用率提高了,因为它有效利用了非活动对象来记录压缩需要的信息,甚至都不需要forwarding指针。

表格算法的缺点是压缩耗时,代价来源于维护和搜索间隙表格,而且在不排序的情况下只能线性搜索。

总结

  • GC标记压缩算法分为标记和压缩两个阶段。标记阶段从根节点开始标记活动对象,压缩阶段负责移动对象和重写指针。
  • Lisp2算法的压缩阶段依次将活动对象向左移动,"挤出"垃圾,需要遍历3次堆。
  • Two-Finger算法采用了填空法移动对象,要求堆内对象大小相等,需要遍历2次堆。
  • 表格算法以对象群为单位移动对象,利用非活动对象记录间隙表格,间隙表格记录了对象群首地址和移动距离,通过移动距离计算出对象新地址,需要遍历2次堆。
  • GC标记压缩都需要3个基本步骤:移动对象,记录新地址,重写指针。只不过Two-Finger算法和表格算法将移动对象和记录新地址合并成了一个步骤,在一次遍历中完成了。

更多阅读

猜你喜欢

转载自blog.csdn.net/puss0/article/details/126357914