• 垃圾回收算法(2)引用计数


    引用计数:
     
    引用计数于1960年被提出。思想是在对象中增加一个“被多少个外部对象引用”的字段。当没有外部引用时,字段自然为0,说明是垃圾了。
     
    对象的分配延续前文,以free_list管理。
     
    它与上文的mark_sweep区别在于,gc并非显式调用,而是伴随着对象的分配与覆盖(pa = pb,即pa原值被覆盖)发生。内存管理与应用流程同步进行是引用计数的特征。
     
     
    这样,new_obj的流程为:
    new_obj(size) {
      obj = pickup_chunk(size, $free_list)
      if obj == NULL
        fail;
      else 
        obj.ref_cnt = 1 // 初始化为1
        return obj
    }
     
    同样,指针的“覆盖”如下:
    update_ptr(ptr, obj) {
      inc_ref_cnt(obj)
      dec_ref_cnt(obj) //这两者不可调换,因为可能ptr, obj是同一对象
      *ptr = obj
    }
     
    inc_ref_cnt(obj) {
      obj.ref_cnt++
    }
     
    def_ref_cnt(obj) {
      obj.ref_cnt--
      if (obj.ref_cnt == 0) 
        for (child : children(obj))  // 当没人引用时,对所有子对象进行递归
          def_ref_cnt(child)
      reclaim(obj)   // 塞入free_list
    }
     
    引用计数的经典逻辑就是这么简单,看一下它的优缺点:
    优点:
    1. 内存完全不会被垃圾占用,一有垃圾可以立即加收;
    2. 没有一个“最大暂停时间”;
     
    当然缺点也很明显:
    1. 每次更新指针(覆盖)都会伴随引用计数的流程,计算量比较大;
    2. 计数器本身需要占位,如果每个对象占内存空间,内存空间与最大被引用数相关;
    3. 实现烦琐。算法虽然简单,但修改代码将pa=pb换为update_ptr时容易遗漏导致问题;
    4. 循环引用无法处理。
     
    下面针对这些缺点看一下对应的一些方法。
    1. 计算量大
    可以缩减计算范围,比如,从根(如mark sweep中描述的root)出发的全局变量的指针覆盖,并不用update_ptr变更计数,那这样会有一些对象引用计数为0但仍被root引用着,可以使用一个zero count table来记录这些对象。这样可以大大减少因引用计数为0时的计算量。而本身因引用计数降为0应该被回收的垃圾,则在专门的逻辑中处理,到时再放入free_list中。
     
    dec_ref_cnt(obj) {
      obj.ref_cnt--
      // 引用计数为0时,并不会立即回收内存,而是放入zct中。只有zct满了,才会。
      if obj.ref_cnt == 0 {
        if (if_full($zct)) {
          scan($zct)
        }
        push($zct, obj)
      }
    }
     
    scan_zct() {
      for (r: $root) 
        r.ref_cnt++
     
      for (obj: $zct)
        if obj.ref_cnt == 0 
          remove($zct, obj)
          delete(obj)
     
      // 很简单,只是先加后减,操作后zct中的引用计数仍为0,且被root引用着
      for (r : $root) 
        r.ref_cnt--
    }
     
    // 最后看下delete,很简单,是真实的回收。
    delete(obj) {
      for (child : children(obj))
        child.ref_cnt--
        if child.ref_cnt == 0 
          delete child
      reclaim(obj)
    }

    以上则是引入zct后,减少引用计数时的逻辑。

     
    同样,在new时,如果内存不足,则调用一次scan_zct,再重新分配一遍即可。
    这个方法会增大最长暂停时间。
    1. 计数器本身的内存占用:
    这个问题的含义是,如果因为计数器内存占用考虑而设得太小,比如5位,那么只能记录被32个对象引用,超过后计数器就溢出了。
    “sticky"引用计数法是处理是处理这种问题的思路。研究表明,很多对象一生成立即就会死了,也就是说大多数对象的计数是在0,1之间,达到32的本身很少。另一方面,如果真有达到32个引用的对象,那么很大程度上这个对象在执行的程序中占有重要的位置,甚至可以不需要回收!
      另一个方法是适当时候启动mark-sweep来进行一轮清理,这时mark不需要额外使用标志位,直接使用引用计数就可以。这样不仅可以将溢出的引用计数回收,也可以将循环引用的垃圾回收
      此外,还可以引出一个极端的方法,1位计数法。这种方法将引用计数从对象中剥离,而放在引用对象的指针中(由于字节对齐,指针的最后几位用不到)。这样不仅有上述sticky引用计数的优点,而且可以带来更高的缓存命中率,因为对象引用关系变化时,对象本身的内存是不变的。
     
    1. 循环引用问题
    第2个问题的解决方法中提到了,解决循环引用的一种方式是某个时机加入mark-sweep算法。但事实上这是个很低效的办法。因为引入这种全堆的扫描仅仅是为了极少量存在的循环引用,显然不合适。
    因此,可以引入优化,将扫描范围由全堆缩减到“疑似循环引用对象的集合”,这就是部分标记-清除算法(partial mark sweep)
     
    它的核心思想是,找出一个可能是循环引用垃圾(注意,不是找循环引用,是找循环引用垃圾)环中的一个对象,将其放置入一个特殊的集合。对这个集合进行mark-sweep,判断出是否真的循环引用了。
    算法如下:
     
    将对象分为4种:
    black:确定的活动对象;white:确定的非活动对象;hatch:可能是循环引用的对象;gray:用于判断循环引用的一个中间态。
     
    算法的切入点在于减引用计数,如下:
     
    def_ref_cnt(obj) {
      obj.ref_cnt--
      if obj.ref_cnt == 0  // 引用为0,绝不可能是循环引用垃圾
        delete(obj)            // delete函数上面有,减子对象的引用计数并回收
      else if obj.color != HATCH   // 可见,疑似循环引用垃圾的必要条件,是被减引用后,计数未达0
        obj.color = HATCH
        enqueue(obj, $hatch_queue)  // 这里仅仅将可疑的对象本身入队列
    }
    对应的,new:
    new_obj() {
      obj = pickup_chunk(size)
      if obj != NULL 
        obj.color = BLACK
        obj.ref_cnt = 1
        return obj
      else if !is_empty($hatch_queue)
        scan_hatch_queue()      // 当无内存可用时,开始检测循环引用队列并释放之
        return new_obj(size)
      else
        fail()
    }
    下面,便是如何判断循环引用垃圾的核心逻辑:
     
    scan_hatch_queue() {
      obj = dequeue($hatch_queue)
      if obj.color == HATCH        // 思考,什么时候不为hatch?
        paint_gray(obj)
        scan_gray(obj)
        collect_white(obj)
      else if !is_empty($hatch_queue)
        scan_hatch_queue()
    }
    继续看下一个关键中的关键,下面这个是个递归函数。它的核心思想在于,如果当前这个obj是个循环垃圾,那么它的引用计数不为0的原因,是因为被垃圾循环引用着。同理,如果从它自己的子节点开始尝试着循环减引用计数,如果能减到自己为0,那么可以说明自己是循环引用的垃圾。
     
    paint_gray(obj) {
      // 递归函数
      if obj.color == BLACK | HATCH // 为什么可能为BLACK?因为起始对象虽然是hatch,但它的引用的子对象可能是black
        obj.color = GRAY                  // 标识,防止在循环引用的情况下无尽递归
        for child : children(obj)
          child.ref_cnt--                    // 注意!关键点!hatch的obj本身没有减,而是从子节点开始减!这个减是个试探减,最终如果不是循环引用垃圾,还要恢复!
          paint_gray(child)
    }
    经过上述处理,已经将可疑的hatch对象的子对象全部递归了一遍,以上是核心逻辑,下面则是最终判断,要为hatch定性:到底是不是循环引用垃圾?
    scan_gray(obj) {
      if obj.color == GRAY
        if obj.ref_cnt > 0
          paint_black(obj)        // 平反,因为如果真是循环引用垃圾,转一轮下来应该被引用的子对象回头来减过引用计数了
        else
          obj.color = WHITE   // 定罪,因为本身paint_gray时,并未减自身的计数,这里为0了,只可能是被引用的对象轮回回来减了,
          for child : children(obj)   // 既然本身已经确定是循环垃圾了,那么之前的尝试减有效,可以遍历子节点找出引用环了。
            scan_gray(obj)
    }
     
    最后,看一下“平反”的过程,很容易理解,在paint_gray中试减掉的引用计数要恢复回来:
     
    paint_black(obj) {
      obj.color = BLACK
      for child : children(obj)
        child.ref_cnt++              // 注意,这里也是当前对象没有加,从引用的子对象开始加。因为当证明当前非垃圾的情况下,当前对象当初也没有减
        if child.color != BLACK
          paint_black(child)         // 递归恢复
    }
    最后的最后,递归清理垃圾:
    collect_white(obj) {
      if obj.color == WHITE
        obj.color = BLACK  //防止循环不结束,并非真的非垃圾
        for child : children(obj)
          collect_whilte(child)
        reclaim(obj)             // 回收
    }
    上面这个算法虽然很精妙,但是毕竟遍历了3次对象:mark_gray, scan_gray, collect_whilte,最大暂停时间有影响。
     
    合并引用计数
    另一个需要讨论改良的,是引用计数的频繁变动的处理。比如a.pa = b; a.pa = c; a.pa = d; a.pa = a; a.pa = b这样,绕了半天,引用还是从a到b。
    考虑可以不关注过程,直接关注首尾结果,按这结果来生成一个阶段内的引用计数变化。
    write_barrier(obj, field, dst) {
      if !obj.dirty    // 引用源注册,注册,即是记录某一阶段起始的意思
        register(obj)
     
      obj.field = dst
    }
     
    register(obj) {
      if buff_full
        fail()
      // entry有两个字段,一个记录obj,另一个数组记录obj当前的所有引用子对象
      entry.obj = obj
      for child: children(obj)
        if child
          push(entry.children, child)
     
      push($buf, entry)      // entry存在buff中
      obj.dirty = true         // 完毕
    }
    可以看出,entry中,obj表明的是一个一直随着代码运行在变化的引用关系,而entry.children这个队列,则是保存着刚开始register时,obj的引用关系。
    显然,gc逻辑就是两者对比:
    garbage_collect() {
      for entry : buf
        obj = entry.obj
        for child : children(obj)
          inc_ref_cnt(child)               // 当前被引用的,+1
     
        for child : entry.children
          dec_ref_cnt(child)              // 曾经被引用的, -1。如果引用未变,那就不增不减,维持原样。
     
        obj.dirty = false
     
      clear(buf)
    }
    这方法对于频繁更新指针的情况能增加吞吐量,但因为要处理buf,他会加大暂停的时间。
     
    引用计数的内容就是这些。
  • 相关阅读:
    随机的爱
    在使用ASP.NET时进行页面重定向的3种方法。(转)
    [程序员必看]请不要做浮躁的人
    C# datetime 操作
    心碎的浪漫
    动网论坛 企业版

    内存映射文件(File Mapping)API
    内存映射文件
    已知进程句柄,如何知道此进程的主窗口句柄
  • 原文地址:https://www.cnblogs.com/qqmomery/p/6629524.html
Copyright © 2020-2023  润新知