垃圾回收算法(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 }
引用计数的经典逻辑就是这么简单,看一下它的优缺点:
优点:
- 内存完全不会被垃圾占用,一有垃圾可以立即加收;
- 没有一个“最大暂停时间”;
当然缺点也很明显:
- 每次更新指针(覆盖)都会伴随引用计数的流程,计算量比较大;
- 计数器本身需要占位,如果每个对象占内存空间,内存空间与最大被引用数相关;
- 实现烦琐。算法虽然简单,但修改代码将pa=pb换为update_ptr时容易遗漏导致问题;
- 循环引用无法处理。
下面针对这些缺点看一下对应的一些方法。
- 计算量大
可以缩减计算范围,比如,从根(如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,再重新分配一遍即可。
这个方法会增大最长暂停时间。
- 计数器本身的内存占用:
这个问题的含义是,如果因为计数器内存占用考虑而设得太小,比如5位,那么只能记录被32个对象引用,超过后计数器就溢出了。
“sticky"引用计数法是处理是处理这种问题的思路。研究表明,很多对象一生成立即就会死了,也就是说大多数对象的计数是在0,1之间,达到32的本身很少。另一方面,如果真有达到32个引用的对象,那么很大程度上这个对象在执行的程序中占有重要的位置,甚至可以不需要回收!
另一个方法是适当时候启动mark-sweep来进行一轮清理,这时mark不需要额外使用标志位,直接使用引用计数就可以。这样不仅可以将溢出的引用计数回收,也可以将循环引用的垃圾回收。
此外,还可以引出一个极端的方法,1位计数法。这种方法将引用计数从对象中剥离,而放在引用对象的指针中(由于字节对齐,指针的最后几位用不到)。这样不仅有上述sticky引用计数的优点,而且可以带来更高的缓存命中率,因为对象引用关系变化时,对象本身的内存是不变的。
- 循环引用问题
第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,他会加大暂停的时间。
引用计数的内容就是这些。