【GC算法几人知?】三、引用计数法,直抵GC本质的方法

大家好,我是【小松与蘑菇】,即将毕业去深圳的大学生,致力于android,java相关领域,也对AI很感兴趣。正朝着写出通俗易懂而又有深度的文章而努力

前文地址
【GC算法几人知?】一、前置知识积累
【GC算法几人知?】二、标记清除法 全解析

一个追根溯源的问题:哪些对象算是垃圾对象?
答曰:从根开始无法引用的对象

GC方法此时已经显而易见——判定一个对象是否根可引用,如果不行,就是垃圾,进行回收
这就是引用计数法

文章目录

    • 步骤
    • 缺点
    • 策略
    • 总结

步骤

整个方法,我们关注的,是计数器,类似于标记清除法中的mark,我们在对象头中设置一个cnt,不过,这个是int型
当新建一个对象A时,就初始化cnt为1
以下两种情况会更新:

  1. 如果有指针ptr指向对象A,那么、cnt++,那A怎么知道有对象指向他呢? 显然,更新指针的方法不是对象内部的方法,而是将对象作为参数的update_cnt(ptr,object)
  2. 接第一条,ptr在指向A之前,可能指向过对象B或者Null,那么如果存在对象B,那么将对B进行cnt–操作。同时进行cnt==0?的判定,如果为0,那么现将其所有的孩子的cnt–(因为B成为垃圾后B所指向的所有对象都不算数了),然后回收B,引用计数法是一种即时回收垃圾的方法

上面两种情况实际上是一种情况,这是一种你增我减的情况,当出现指针指向A时,马上执行下面的函数

update_ptr(ptr, obj){
	inc_ref_cnt(obj)
	dec_ref_cnt(*ptr)
	*ptr = obj
}

可能你会有点奇怪,照理来说,应该是 *ptr的对象先cnt–,obj才能cnt++,但是这样会有问题,如果 *ptr就是obj呢?也就是ptr原本指向的就是obj,然后重新再指向一遍的话,那么先cnt–将有可能使得cnt为0,就成为了垃圾对象而惨遭回收,所以必须先加

inc_ref_cnt函数仅仅是对传入的对象进行cnt++而已,很简单,dec_ref_cnt函数由于承载了回收的功能,还要考虑对子对象的递归判定,实现较为复杂,这也是整个引用计数法中最重要的函数

dec_ref_cnt(obj){
	obj.ref_cnt--
	if(obj.ref_cnt == 0)
		for(child : children(obj))
			dec_ref_cnt(*child)
	reclaim(obj)
}

可以看到,cnt–操作下,也同时可以回收垃圾
这个方法就讲完了,是不是很简单?嘻嘻,才不是,不过到这里,其实你已经可以跟面试官扯皮很久了,下面是更加深入的解析

来我们先看看有什么优点

  1. 可以即时回收垃圾:这是显然的,只要出现cnt为0,先处理好其子节点(就是把子节点cnt统统减一)的后事后,直接将其回收
  2. 最大暂停时间短:这也是显然的,想想看,只要有0就回收,也就是每次回收得少,回收次数多,单次回收时间当然就少啦
  3. 无需遍历:这是相对标记清除法的,标记清除法每次都需要遍历列表找垃圾,这个不需要遍历了,毕竟发现就回收~

缺点

成也计数,败也计数
缺点也很明显

  1. 计数复杂,这并不是说代码复杂,毕竟就那么几行,而是在堆中,你会发现每次执行dec_ref_cnt这个函数时,被cnt–的对象如果减到0,那么其子节点将会递归的受到牵连cnt–,最坏情况下,修改一个指针,所有对象的cnt都得减,实在太耗费资源了
  2. 计数器占位,计数器设置多少位合适呢?1位计数器可以计2次,2位可以计4次,但是假如堆中有1024个对象,如果他们都指向对象A的话,A需要10位的计数器。那如果有10240个对象呢?所以,计数器的位数理论中应该是log(堆中最大对象数)才是最安全的
    可是有可能会出现所有堆中的对象都指向一个对象这种极端情况吗?非常罕见,为了这种情况白白设置了十几位的计数器很浪费空间,所以要么就浪费空间,要么计数器溢出,不安全
  3. 循环引用,假如两兄弟或者n兄弟互相引用,他们的cnt永远不为0,但是根是不可能找到他们的,这样的垃圾无法处理,比如下图,他们的cnt永远为1
    【GC算法几人知?】三、引用计数法,直抵GC本质的方法_第1张图片

策略

作为一个不屈不挠的程序员,我们的前辈们当然想办法解决了这些问题啦

  1. ZCT延迟计数 VS 计数复杂

由于计数是实时且递归更新的,往往会有牵一发而动全身的情况,所以我们设置一个ZCT表(像不像上篇的位图!)
什么时候会发生递归?是当前被cnt–的对象cnt为0时,那么我们只需要此时将这个对象链入ZCT表中记录一下即可
【GC算法几人知?】三、引用计数法,直抵GC本质的方法_第2张图片
这样,即使出现cnt==0的情况,也无需递归更新其子对象了,ZCT表的大小可以自己定,如果ZCT满了,那么再统一清除表内指向的对象们

这体现一个思想:对于多次变化的东西,我们可以建个表,记录清楚,然后一次性变化即可
不过这也导致了无法即时回收垃圾,如果ZCT表越大,单次回收的时间也越长,需要考虑清楚再设置

  1. Stick引用计数 VS 计数器溢出

计数器的位数一定不能是 log(最大对象数)的,不然每个对象的头都大了(计数器在对象的头中),所以一定会有溢出问题,一个简单的方法是,检测到将要溢出,计数器不再增加即可。因为如果一个对象的计数器大到将要溢出,证明又很多对象指向他,所以大概率不会是垃圾,以后也不会是
当然这个方法还是有风险的,所以我们可以使用Stick引用计数

如果一个计数器溢出,先用类似ZCT的表存一下,然后满了之后再将所有溢出的计数器置为0,重新从根开始遍历,如果有引用则cnt++(不管引用多少次,只加一次),最后谁的cnt为0,谁被回收

简单说就是将所有溢出的存一批,然后全设置为0,再遍历一次,如果有对象居然没有cnt++,证明他溢出后,所有之前指向他的指针都不再指向他了,证明他败光了所有的财产,成为了垃圾(全设置为0,就像让潮水退去一样,看看谁打着溢出的名号,其实没穿底裤)。

  1. 部分标记清除法 VS 循环引用

注意:这个方法过于硬核,需要保持10分钟专注
我先给个图
【GC算法几人知?】三、引用计数法,直抵GC本质的方法_第3张图片
使用两位,将对象分成了四种颜色,逻辑是这样的
新建的对象肯定是黑色的

如果出现某对象cnt–

  • 如果cnt ==0,将其设为白色
  • 如果cnt != 0,将其加入hatch_queue,也即阴影队列,存放可能是循环引用的垃圾(注意只是可能)

其伪代码如下dec_ref_cnt变成

dec_ref_cnt(obj){
	obj.ref_cnt--
	if(obj.ref_cnt == 0)
		delete(obj)
	else if(obj.color != HATCH)
		obj.color = HATCH
	enqueue(obj, $hatch_queue)
}

当hatch_queue不为空

  • 从队列中取出一个对象A,涂为灰色,再让其子对象cnt–(假装A是垃圾)然后递归进行,如果最后一个cnt–的是A,证明什么?证明A和其子对象循环引用了

方便理解,如下图,从hatch_queue中取出A,此时假设A中cnt==1,不是垃圾,但是我们明显看出,A,B,C有循环引用
【GC算法几人知?】三、引用计数法,直抵GC本质的方法_第4张图片
然后让A的子对象B进行cnt–,此时B为0,然后继续递归,对B的子对象C进行cnt–,使得C为0,C继续对子对象A和F进行cnt–
发现了吗?
原来的A被减为0了

这意味着什么?
这意味着A的cnt数值来自于其子对象的指向,而不是根,他和那个指向他的子对象,和他两之间的对象,是循环引用(注意F不是,循环在C处回到了A)

然后对循环引用的对象涂白色,表明他们是垃圾对象
【GC算法几人知?】三、引用计数法,直抵GC本质的方法_第5张图片
最后,这个方法有很多的实现细节,以后细讲,本文主要讲逻辑和必须的代码
不过他也有缺点

  1. 代价大,为了回收一个对象,前后涂了四种颜色(除了新建时涂和,遍历了三次)
  2. 最大暂停时间变大,原来可以回收三个对象的时间,现在只能回收一个……

总结

引用计数法作为直抵GC本质的方法实现起来是顺理成章的,也是最能直观理解的GC算法,但是对于计数的操作却有很多很多的问题,当然,与之对应的解决方法也很精彩,后面的垃圾回收算法也更加精彩哦

你可能感兴趣的:(算法)