基于Tarjan算法和引用计数的垃圾回收算法
摘要
本文实现了一种垃圾回收算法,可以在不暂停工作线程的情况下,对内存中失去引用的内存进行回收。
1.引用计数法
引用计数通过给对象添加一个引用计数器来管理对象,每当有一个地方引用它时,计数器值就+1,当引用失效时,计数器值就-1。当计数为0时即表明对象不再使用,可以对该对象进行回收。引用计数法具有即时释放对象,实现简单高效的优点,但引用计数法有着一个严重的缺陷,它无法回收存在循环引用的对象。
2.可达性分析算法
可达性分析算法通过一系列的根对象作为起始点,从这些根节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到根对象没有任何引用链相连时,则证明此对象是不可用的,可以回收释放该对象。
图1.2
可达性分析优点是可以解决引用计数器所不能解决的循环引用问题。缺点是,运行算法时必须暂停运行工作线程的运行,运行效率较低。
3.1 Tarjan引用计数法
Tarjan引用计数法(即本论文实现的方法,后面简称T引用),在引用计数的方法上进行拓展,着手解决了引用计数的循环引用的问题。因为循环引用的存在,T引用额外的增加一个循环计数来表示对象中循环引用的情况,循环计数也可以理解为无效的引用数。如图,
对象A被两个指针A,B所指。其中A是在栈上的指针对象,B是属于对象A的成员指针,并且B指向对象A,形成循环,其中引用计数的情况就如图所示,其中的循环计数一定是小于等于引用计数的。为了确定一个对象的循环计数就必须对该对象的进行遍历,如果在遍历的过程中重新回到了对象,就对循环计数加一。求循环计数的过程,可以转化为图论问题来解决,对顶点A(对象A)进行深度优先遍历就可以求出顶点A的循环计数。
3.2出发点对循环计数的影响
循环计数不同于引用计数,循环计数受出发点的影响。如图:
如果从A点出发,那么A点引用为1,循环为1。如果从B点出发那么B的引用为1,循环为1,其余的节点均为引用1,循环0。循环计数是在遍历的过程中计算的,每次遍历前需要将全部循环计数清零。上次的计算结果,不能作为本次遍历的参考。
3.3 两类循环
在遍历顶点计算循环计数的过程,有两类情况会增加循坏计数。第一类情况如图:
上图从A点出发,经过B点,在回到A点。AB之间存在一条循环通路。
第二类节点菱形节点,如图:
同样从A点出发,有两条路径可以达到D点,但这两条路径都起源于A点。所以D的引用为2,循环为1。
这两种情况出现循环的情况都对对象的生命周期没有增益的效力,例如图1.4,A顶点的引用计数和循环计数相同,两者相消,可以看成A的引用数为0。其中菱形节点的情况只会出现在出发点的子节点中,出发点不可能出现菱形节点。
3.4第一次对象生命周期判断
当算法完成一次深度优先遍历,计算出各个顶点的引用和循环计数。此时就可以对对象的生命周期进行第一次判断。比较引用计数和循环计数,可以分为三种情况来处理:
引用计数,循环计数为0:这种情况下不需要进行遍历,可以直接判断对象可以释放。
引用计数大于循坏计数:对象还存在其他引用,判定对象续存。
引用计数等于循坏计数:这种情况较为复杂,不能简单的判断对象能否释放,还需要进行更近一步的分析。
3.5外部节点
对引用计数等于循坏计数的情况进行分析,如图
从A点出发,得到引用和循环计数相同的结果。上图中我们不能简单的判断A点失去引用,可以释放。因为上图中的C点存在被外部引用的情况,其他对象可以通过C点访问到A点,对于C点这种与外部有连接的顶点,我们简称它为外部节点。即由于外部节点的存在,A点没有失去引用,不能释放。利用图论我们可以更加准确的说明:ABC三点构成一个强连通分量,可以把三点看成是一点。上图所示的情况,可以化简成如下图:
其中D为ABC构成的强连通分量。于是在引用计数循坏计数相等的情况下,求判断对象能否释放的问题转为判断包含出发点的强连通分量内是否存在外部节点,其他子节点的强连通分量对出发点没有影响。外部节点具体定义为在强连通分量内,引用计数减去循环计数大于一的节点,因为强连通分量内的顶点引用数至少为1。通过之前深度遍历的计算结果,我们很快就能判断出有没有外部节点的存在。
3.6 Tarjan算法在T引用中的应用
对于求强连通分量,可以通过Tarjan算法来解决,并且Tarjan算法的求解过程即是一次深度遍历的过程,于是可以在求解强连通分量的同时计算循环计数。当Tarjan算法结束时,对出发点的循环计数和引用来进行判断,在引用计数等于零和引用计数大于循环计数的情况下,我们可以直接得出结论。对于引用计数等于循环计数的情况,我可以利用Tarjan得出的强连通分量进行判断分量中是否存在外部节点。我们只需对出发点所在的分量查找外部节点,其他分量一定是以出发点所在的分量为根节点的,不对出发点构成影响。
4.1单线程实现
在单线程下,我们可以在引用计数法的基础下进行拓展。以C++中的智能指(shared_ptr)针为例。当指针指针中的引用计数增加时,我们不进行任合操作,当引用计数减少时,不管引用计数有没有达到0,我们都执行一次T引用算法来判断对象的生命周期。在单线程的情况下,我们只需一个指针对象便能完成垃圾回收。但该方法不适用多线程的场景,因为如果在进行T引用算法时,指针对象发生变动,会引起不可预料的后果,故多线程下,实现需要相应的修改。
4.2多线程实现
在多线程情况下,T引用的实现类似Java等高级语言的垃圾回收方式,但与java等不同的是GC运行时,不影响工作线程的运行,回收对象时,不会暂定整个程序的运行。实现多线程T引用需要两个对象,指针对象,垃圾回收器对象,两者配合工作。其中指针对象负责提交对象给垃圾回收器,并且即时更新引用计数(与指针指针类似,只是多了提交对象到垃圾回收器这一功能)。垃圾回收器负责不断的对节点遍历进行T引用来判断对象的生命周期,对于失去引用的对象则可以直接释放对象。大致关系如图:
4.3 多线程中指针变动问题的解决方法
如同单线程多线程,多线程也会遇到指针变动的问题。这里有个简单的解决方法,在垃圾回收器中增加一个标志位,来表示指针是否发生变动。为每个对象额外的增加一个步进计数,在T引用遍历时更新该计数。每次开始T引用算法前将标志位设位false,每当完成一次T引用算法时步进计数加一。如图
步进计数表明T引用正在对哪个顶点进行计算。当指针发生变动,检查对象的步进计数是都相等,如果相等则跳过该顶点,从下一个新顶点开始扫描。我们不必担心指针频繁变动导致顶点频繁跳过,从而导致内存泄漏。因为内存中失去引用的对象是无法访问到的,意味着失去引用的对象引用关系(拓扑结构)是不会发生变动的,并且对该对象的回收(仅指内存回收)是不会影响程序运行。
C++ T引用实现见github:yechaoGitHub/GC