qqwx

导航

垃圾回收算法(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,他会加大暂停的时间。
 
引用计数的内容就是这些。

posted on 2017-03-27 23:41  qqwx  阅读(1014)  评论(0编辑  收藏  举报