• 渐进式垃圾回收


    以往介绍的GC算法是不能被打断的,也就是说GC一旦开始运行,应用程序就要停止执行,等到GC运行结束后才能继续执行。这种现象有个专门的英文单词叫Stop-The-World,我们称这种GC为停止型GC,如下图所示。

    在这里插入图片描述

    相应的,渐进式GC是可以被打断的,这样GC和应用程序就能够并发运行了,而且能缩短最大暂停时间。如下图所示。

    在这里插入图片描述

    三色标记法

    三色标记法和写入屏障是并行标记的关键,写入屏障后面再介绍。顾名思义,三色标记法将对象分为三种颜色:

    • 白色:还未搜索过的对象
    • 灰色:正在搜索的对象
    • 黑色:搜索结束的对象

    三色标记法由Edsger W. Dijkstra等人提出,大致流程是先将根直接引用的对象标记为灰色,然后遍历灰色对象,将其子对象标记为灰色。当一个对象的所有子对象全部变成灰色后,它自己就变成黑色。当不存在灰色对象时,标记就结束了,此时活动对象全部是黑色,垃圾全部是白色。最后将全部白色对象回收,将黑色对象全部染白,GC结束。

    注意再将灰色对象的子对象标记为灰色的过程中,只标记直接子对象,没有递归。

    从以上流程可以看出,三色标记法只适用于搜索型GC算法,因此这里我们以经典的GC标记清除算法为例说明。

    渐进式GC标记清除算法分为以下三个阶段:

    • 根查找阶段:将根直接引用的对象标记为灰色
    • 标记阶段:查找灰色对象,将其所有子对象涂成灰色,并将该对象涂成黑色
    • 清除阶段:查找堆,将白色对象加入空闲链表,将黑色对象变回白色

    伪代码如下:

    //渐进式GC算法
    incremental_gc() { 
      case $gc_phase //GC所处阶段 
      when GC_ROOT_SCAN //根查找阶段
        root_scan_phase() 
      when GC_MARK //标记阶段
        incremental_mark_phase() 
      else //清除阶段
        incremental_sweep_phase() 
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    我们用全局变量$gc_phase记录了GC所处的阶段,每次从应用程序切回GC时,都会从上次中断的地方继续执行。当然为了GC能够正确的从中断的地方继续执行,还有许多额外的工作要做。

    根查找阶段伪代码如下:

    //根查找阶段
    root_scan_phase() { 
      for(r : $roots) //遍历根直接引用的对象
        mark(*r) //标记对象
      $gc_phase = GC_MARK //进入GC标记阶段
    }
    
    //将obj标记为灰色
    mark(obj) {
      if(obj.mark == FALSE) //未被标记过
        obj.mark = TRUE //设置已标记过
        push(obj, $mark_stack) //入栈,也就是将obj标记为灰色
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    根查找阶段将根直接引用的对象放入标记栈中,也就是将对象标记为灰色。染色的过程不一定真的要给对象设置一个颜色,你可以理解标记栈是一个灰色集合,被放入灰色集合的对象就是灰色对象。另外,obj.mark有两个状态TRUEFALSE,它的真正含义其实就是黑色和白色。我们用一个字段和一个栈完成了三种颜色的表示,obj.mark=TRUE表示黑色,obj.mark=FALSE表示白色,栈中的对象表示灰色。理解这一点对理解算法中的颜色转换至关重要。

    跟查找阶段是不能中断的,当根标记完以后,将$gc_pahse设置为GC_MARK,此时可以切回应用程序,当再次进入GC时,就会进入标记阶段继续执行。

    注意mark函数同时给obj染上了黑色和灰色,这一点也很巧妙。因为在下一步标记阶段中,当我们把obj的子对象都染灰以后,还需要把obj染黑。由于我们已经在mark函数中提前将obj染黑了,所以只需要将obj从灰色集合中删除就可以了。

    标记阶段伪代码如下:

    //标记阶段
    incremental_mark_phase() { 
      for(i : 1..MARK_MAX) //一次最多标记MARK_MAX个对象
        if(is_empty($mark_stack) == FALSE) //标记栈非空,表示还有灰色对象
          obj = pop($mark_stack) //取出一个灰色对象
          for(child : children(obj)) //遍历灰色对象的子对象
            mark(*child) //将子对象标记为灰色,注意这里没有递归
        else //标记栈为空,表示已没有灰色对象,标记完成
          //将根查找阶段和标记阶段再来一遍
          //因为GC和应用程序交替运行中,应用程序可能修改引用
          for(r : $roots) //遍历根直接引用的对象
            mark(*r) //将未标记过的对象染灰
          while(is_empty($mark_stack) == FALSE) //标记栈非空,还有灰色对象
            obj = pop($mark_stack) //取出一个灰色对象
            for(child : children(obj)) //遍历灰色对象的子对象
              mark(*child) //将子对象染灰
          $gc_phase = GC_SWEEP //进入清除阶段
          $sweeping = $heap_start //初始化$sweeping指向堆头,为清除阶段做准备
          return
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    标记阶段并不是一次标记完所有对象,而是每次至多标记MARK_MAX个,当然最后一次可能例外。

    标记阶段不断的从灰色集合中取出对象,并将其子对象染灰,注意,这里我们只处理直接引用的子对象,没有递归。pop函数的另一层含义是将对象染黑,因此实际上我们是先将对象染黑,然后将它的子对象染灰。

    当标记结束时,我们再次将根查找阶段和标记阶段执行了一遍。这是因为标记阶段中间会穿插执行应用程序,而应用程序执行时可能会改变根对象的引用关系,所以在进行清除之前,需要将清除阶段更新的引用关系扫描出来。而且这次我们是一次扫描完,没有中断,因此扫描的对象个数可能会不止MARK_MAX个。

    整个标记结束后,$gc_phase被设置为GC_SWEEP,下次进入GC就会进入清除阶段。同时初始化$sweeping指向堆的开头,这是因为标记阶段也是渐进式的,也就是说会多次执行清除阶段,而初始化$sweeping只能在第一次进入清除阶段时执行,因此在标记阶段结束时初始化$sweeping更合适。

    由于标记阶段和应用程序是交替执行的,如果应用程序执行时更改了对象引用关系,可能会导致错误标记和标记遗漏。标记遗漏的影响要比错误标记更严重,因为错误标记的垃圾还可以在下次GC时回收掉,一旦标记遗漏,导致活动对象被回收,后果将是不堪设想的。

    标记遗漏发生在黑色对象中的指针更新为指向白色对象时,因为黑色对象是扫描结束的对象,不会再对它进行搜索,那么它指向的白色对象也就不可能被标记到了。这个情况如下图所示。

    在这里插入图片描述

    上图中,GC运行阶段将A标记为了黑色,B是灰色,C还未扫描到,是白色。随后切换到应用程序,A的指针更新为指向C,B到C的指针被删除。当再次切换回GC时,B被正常标记为黑色,但是由于A已经是黑色,所以C已经无法被搜索到了,也就无法被正确标记了。

    渐进式GC中更新指针时,不能直接写入内存,而是需要一些额外的处理,以保证GC的正确运行。这种在写入指针前进行的额外处理就叫做写入屏障。Dijkstra写入屏障伪代码如下。

    //写入屏障
    write_barrier(obj, field, newobj) { 
      if(newobj.mark == FALSE) //newobj未标记过,即newobj是白色对象
        newobj.mark = TRUE //标记,将newobj染黑
        push(newobj, $mark_stack) //入栈,将newobj染灰
      *field = newobj //写入指针
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    如果被引用的是白色对象,那么写入屏障会将它染灰,这样它就能被搜索到了。适用写入屏障的标记过程如下。

    在这里插入图片描述

    由于写入屏障的存在,将A的指针指向C时,会将C染灰,这样它就能被正确标记了。

    清除阶段伪代码如下:

    //清除阶段
    incremental_sweep_phase() { 
      swept_count = 0 //记录被清除的对象数
      while(swept_count < SWEEP_MAX) //每次最多清除SWEEP_MAX个对象
        if($sweeping < $heap_end) //清除未完成
          if($sweeping.mark == TRUE) //黑色对象,表示是活动对象
            $sweeping.mark = FALSE //取消标记,即染白,为下次GC做准备
          else //白色对象,表示是垃圾
            //头插法将白色对象插入空闲链表
            $sweeping.next = $free_list 
            $free_list = $sweeping 
            $free_size += $sweeping.size
          $sweeping += $sweeping.size //下一个对象
          swept_count++ //累加被回收的对象个数
        else //清除完成
          $gc_phase = GC_ROOT_SCAN //重新回答根扫描阶段
          return
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    清除阶段也不是一次回收掉所有白色对象,而是每次最多回收SWEEP_MAX个,分多次回收完。对于黑色对象,只需要取消标记就可以将它们染白。清除完成以后,将$gc_phase设置为GC_ROOT_SCAN,下次切回GC时,又会从根扫描阶段开始进入新一轮的GC。

    渐进式GC的分配过程也有点小小的区别,伪代码如下。

    //分配对象
    newobj(size) {
      //$free_size记录了可用空闲空间的总大小
      if($free_size < HEAP_SIZE * GC_THRESHOLD) //空闲空间不够
        incremental_gc() //执行GC
        
      chunk = pickup_chunk(size, $free_list) //从空闲链表分配对象
      if(chunk != NULL) //分配成功
        chunk.size = size //设置分块大小
        $free_size -= size //减小可用空闲空间大小
        //GC处于清除阶段且chunk未被回收
        if($gc_phase == GC_SWEEP && $sweeping <= chunk) 
          chunk.mark = TRUE //将chunk标记为黑色,表示为活动对象
        return chunk //返回分配的对象
      else 
        allocation_fail() //分配失败
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    因为渐进式GC是穿插在应用程序中执行的,一次GC要分多次完成,每次进入GC不一定会增加空闲空间。因此渐进式GC不能像停止型GC那样等到空闲空间枯竭后再执行GC,而是在空闲空间下降到一定阈值后就要开始执行GC了。

    另外,如果GC处于清除阶段,而我们分配的对象又没被回收过,那么需要将它标记为活动对象,否则,接下来的清除过程会误将它当作垃圾回收掉。如果分配的对象已经被回收过了,则不用做任何处理。如下图。

    在这里插入图片描述

    渐进式GC的最大优点是缩短了最大暂停时间。

    渐进式GC的缺点是降低了吞吐量,因为写入屏障会增加额外的负担。

    吞吐量和最大暂停时间是一对矛盾的指标,优化一方,另一方就会恶化。

    Steele的算法

    与Dijkstra的算法相比,Steele的算法的标记函数写入屏障有所不同。

    //将对象染灰
    mark(obj) {
      if(obj.mark == FALSE) //对象为白色
        push(obj, $mark_stack) //染灰
    }
    
    //写入屏障
    write_barrier(obj, field, newobj) { 
      //GC处于标记阶段,obj为黑色,newobj为白色
      if($gc_phase == GC_MARK && obj.mark == TRUE && newobj.mark == FALSE) 
        obj.mark = FALSE //将obj染白
        push(obj, $mark_stack) //将obj染灰
      *field = newobj //更新指针
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    mark函数中,仅仅将obj染灰,并没有染黑。而在写入屏障中,当黑色对象指向白色对象时,我们将黑色对象重新染成灰色。过程如下。

    在这里插入图片描述

    上图中,将A指向B的指针更新为指向C时,写入屏障使得A重新变为灰色,所以A会被再次扫描,从而C也能被正确标记。

    Dijstra的写入屏障是将白色对象染灰,而Steele的写入屏障是将黑色对象染灰。因此Steele的算法会增加搜索次数,但同时,由于会重新搜索A,如果在搜索之前A删除了C的引用,那么重新变成垃圾的C将不会在此轮GC中保留下来,而如果是Dijstra的算法,则会将C保留到下次GC。

    汤浅的算法

    汤浅全名汤浅太一,该算法是以GC开始时对象间的引用关系为基础来执行GC的,因此也称为快照式GC。

    汤浅算法的一大特点是允许从黑色对象指向白色对象的指针。根据定义,一个对象的所有子对象都被染灰以后,它自己才会变成黑色,因此黑色对象是不可能指向白色对象的,指向白色对象的都是灰色对象。那么汤浅的算法是如何保证正确性的呢?

    根据汤浅算法的定义,GC是根据GC开始时对象间的引用关系进行的。如果在GC执行的间隙,应用程序将指向活动对象A的指针更新为指向活动对象B,这时我们不用管指向B的指针,因为更新前B就是活动对象,它一定可以通过另一个对象找到。这其中的关键就是保持GC开始时活动对象间的引用关系,我们并不害怕新增的引用关系,我们害怕的是删除原有的引用关系。比如我们删除了原先指向B的指针,那么就可能导致B被漏标。

    注意,指针更新一定是从一个活动对象指向另一个活动对象,因为非活动对象指的是没有任何指针引用的对象,那么这样的对象你的代码又如何能引用它呢。

    保持原有引用关系的方式就是写屏障。它在删除引用时发挥作用,伪代码如下。

    //写屏障
    write_barrier(obj, field, newobj) { 
      oldobj = *field //field才是被删除的指针,不是obj
      //GC处于标记阶段且被删除的指针指向白色对象
      if(gc_phase == GC_MARK && oldobj.mark == FALSE) 
        oldobj.mark = TRUE //标记,染黑
        push(oldobj, $mark_stack) //入栈,染灰
      *field = newobj //更新指针
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    这里用到的mark函数和Dijstra算法的mark是同一个函数。注意在写屏障中,我们要染色的是*field指向的对象,既不是obj也不是newobj,因为我们要更新的是field指针。

    如果我们删除了一个指向白色对象的指针,那么需要将该对象染灰,这样我们才能搜索到它,也就间接保留了原有的引用关系。试想一下,如果指向白色对象的指针没有被删除,那么这个对象也一定会被染灰。图示如下。

    在这里插入图片描述

    如果在GC标记阶段分配了新对象,我们直接将它染黑。

    newobj(size) {
      if($free_size < HEAP_SIZE * GC_THRESHOLD) //空闲空间不够
        incremental_gc() //启动GC
    
      chunk = pickup_chunk(size, $free_list) //分配空闲分块
      if(chunk != NULL) //分配成功
        chunk.size = size //设置分块大小
        $free_size -= size //减小空闲空间大小
        if($gc_phase == GC_MARK) //GC处于标记阶段
          chunk.mark = TRUE //标记,染黑
        //GC处于清除阶段且chunk未被回收
        else if($gc_phase == GC_SWEEP && $sweeping <= chunk) 
          chunk.mark = TRUE //标记,染黑
        return chunk //返回空闲分块
      else 
        allocation_fail() //分配失败
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    因为新分配的对象都被标记为了黑色,因此在标记结束时,我们不需要重新搜索根引用的对象,进行补充标记。

    incremental_mark_phase() { 
      for(i : 1..MARK_MAX) //最多标记MARK_MAX个对象
        if(is_empty($mark_stack) == FALSE) //标记栈非空,说明还有灰色对象
          obj = pop($mark_stack) //出栈,相当于将obj染黑
          for(child : children(obj)) //遍历子节点
            mark(*child) //将子节点染灰
        else
          $gc_phase = GC_SWEEP //进入清除阶段
          $sweeping = $heap_start //初始化$sweeping指向堆头
          return
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    总结

    • 渐进式GC可以被中断,旨在缩短最大暂停时间。由于可中断,也就可以实现并发GC。
    • 三色标记法将对象分为三种颜色,是并发标记的关键,适用于搜索类GC算法。
    • 写屏障是渐进式GC能正确标记对象的关键。

    比较各个写屏障

    渐进式GC可以让应用程序和GC交替运行,这其中的一个关键问题是应用程序执行期间更新了引用后可能导致标记错误。还是以文中的示例说明,我们面临的问题是C找不到了。为什么找不到,我们可以从两个方面来看这个问题。

    首先从B的角度考虑,C丢失的原因是B指向C的指针被删除,所以无法从B搜索到C。

    其次从A的角度考虑,由于A已经搜索完成,因此即使增加了A到C的指针,也无法再从A搜索到C。

    Dijstra和Steele的写屏障都是从A的角度入手,但是也有不同,Dijstra是改变C,而Steele是改变的A。汤浅的算法则是从B的角度入手,它改变的也是C。

    三种写屏障分别从两个角度来考虑漏标的问题,它们都是对的。Steele的写屏障减少了误标,但是会增加搜索次数,而Dijstra和汤浅的写屏障会增加误标。

  • 相关阅读:
    从Docker初识K8S
    【安卓开发】安卓页面跳转
    Vue2.0源码理解(6) - 组件注册
    【char类型转换】
    小H靶场学习笔记:DC-2
    flinksql 回撤流中主键发生变更的影响(group by中的值发生改变)
    无人机航测拍摄分类和注意事项
    相同的树(C++解法)
    当我们做后仿时我们究竟在仿些什么(一)
    TextRank算法实践
  • 原文地址:https://blog.csdn.net/puss0/article/details/126357922