当前位置: 首页 > 文档资料 > larva 中文文档 >

引用计数

优质
小牛编辑
137浏览
2023-12-01

引用计数是垃圾收集中一种直接方式,其基本策略是每个对象维护一个引用计数,用于表示外界有多少地方引用自己,当有新的引用到这个对象的时候,计数+1,反之一个到这个对象的引用被拆除的时候,计数-1,当计数为0的时候,说明没有人引用这个对象了,这个对象就被销毁。由于直观,容易理解,引用计数在最早实现垃圾收集的lisp,以及早期的java中都使用过,python则是到现在还都一直在用 注:由于上篇已经论述过的原因,之后的讨论中一律用对象来表示一次申请的堆内存单元,两者是一一对应的

除了直观,引用计数最大的优势在于,内存的管理是实时的,一个对象只要计数为0,就可以立即被释放,而其他垃圾收集算法往往做不到这一点,实时性带来了很多好处:

一、对象的生存周期相对来说可控,使得析构函数在一定条件下能起到作用,例如在python中:

def write_file(s):   
    f = open("a.txt", "w")   
    f.write(s)

这里无需调用f.close(),因为python采用引用计数机制,在函数返回时f立即释放,在文件对象的析构函数中会执行善后工作,更简洁地可以写成: open("a.txt", "w").write(s) 而在其他语言中,例如java,虽然提供了finalize方法,但由于对象生命周期不可控,因此用得不多,一般也不推荐用 注意,上面说了,只是在“一定条件”能较好起到作用,比如python写个简单脚本什么的,事实上只要是带垃圾收集的语言,都不推荐用析构函数,python在这方面也有不可控的情况

二、实时响应,这对于实时系统比较重要,比如一个客户端程序,如果采用标记清除之类的算法,可能需要暂停程序运行,如果内存较小,可能感知不到,但如果稍慢一些,就会卡顿,影响用户体验;另外,有些程序比如游戏,对实时性要求是毫秒级别,占内存又很大,影响会比较严重。这个问题留到后面再说

三、符合局部性,由于一个对象在需要释放的时候会被立刻释放,整个进程的内存使用会控制在很小的范围,反复释放申请也能充分利用cache等,提高运行效率

四、研究表明,在分布式系统中,引用计数是非常好的选择

尽管如此,很多时候引用计数并不被垃圾收集理论(不妨称为“狭义垃圾收集”)承认,或至少不是重点,只是作为一种很原始的方案来介绍,原因是一个致命的弱点:循环引用

引用计数机制的垃圾回收,是需要对象的引用计数为0,但是存在一种情况,有一些对象从根集无法到达,即已经是垃圾了,但是它们之间互相引用,形成带环的有向图,这种情况下,引用计数机制无能为力,比如python中:

class A:   
    pass   
a = A()   
b = A()   
a.b = b   
b.a = a   
del a, b

执行完del后,根集只有一个A(python的类也是对象),这个忽略,而A的两个实例互相引用,因此它们的计数都不为0,这就造成了内存泄露,还有更小的环,自己引用自己:

a = A()   
a.a = a

当然可以想见,更大和更复杂的环,或带环有向图是可能的 关于循环引用问题的解决,一般是引入追踪式垃圾回收机制来辅助解决,关于如何结合的问题,后面再专门讨论

如果不考虑循环引用,或可以辅助解决,则引用计数机制就是一个完整可行的垃圾回收机制了,但从实用角度讲,它还有一些其他缺陷,不过这些缺陷一般来说不是那么严重

一,虽然引用计数的机制很容易理解,但不代表做起来简单,这里的问题是,引用计数和程序执行耦合太紧密了,实现虚拟机时,每次赋值(包括传参数等),计算都要正确更新,这无疑是很繁琐的,更大的麻烦是,出错成本和风险不成比例,很可能一个很小的失误就造成悬空指针或内存泄露,而且还很难调试,找这类bug基本是大海捞针,最典型的例子就是写python的C扩展,必须仔细维护引用计数,防止出现问题,因此我一般都尽量用接口,哪怕慢一点 到了C++中,这个问题有了缓解,只需要写好一个智能指针各种情况(构造,拷贝构造,赋值等)的实现,编译器会在对应的地方对这些方法自动触发,就避免了手工维护引用计数的麻烦,不过,这个方式和下面说的这条有些冲突

二、维护引用计数需要额外开销,后面会看到,跟踪式垃圾收集消耗的时间一般和内存中对象数量相关,而引用计数消耗的时间和计算量相关,因为每次赋值甚至计算,都会频繁更新计数,每次虽然只是做加减和判断,积少成多也很可观了 为缓解这个问题,可以结合实际代码和虚拟机实现,优化掉不必要的引用计数,例如栈虚拟机中计算c=a+b:

load a //a->ref++,为和源语言的属性区别,用->符号表示内部属性   
load b //b->ref++   
add //top=a+b,a->ref--,b->ref--,top初始ref为1   
store c //c->ref--,先拆除c原先的引用,top->ref++,c=top,top->ref--

如果严格按照规定实现,引用计数的变化应该是按上面的注释进行,经过分析可以发现,其中很多操作都是不必要的,比如说最后的store,可以看做是把top“移动”给c,而不是先复制再删除,因此top->ref先加再减就省了;再进一步,a和b入栈做运算也是先加后减,如果a和b不会被其他线程拆除引用,也可以省略 函数调用也有类似的情况,由于调用栈是严格后进先出:

func f():   
    a = 1   
    g(a)

由于a是局部变量,需要等到f结束,引用计数才会为0,那么在g的执行中,a肯定不会被释放,因此这个传参也可以优化,即调用g和从g返回时,a的引用计数都不变 但是,做这种优化是比较麻烦的,需要考虑各种情况,比如上面的f中调用的g(a+1),则临时对象的引用计数也需要处理,另外,也不能用上面说的C++的智能指针,需要自己手动维护引用计数 其实从实际来看,这个问题并没有想象中那么严重: 1 很多高级语言在虚拟机解释执行,本身速度就比较慢,维护引用计数的消耗不是那么明显 2 如果换用简单的追踪式垃圾收集机制,的确可以省下这个消耗,但是为了缓解简单的追踪收集引入的卡顿问题,改进为短停顿收集的时候,对于每次引用的拆除和建立也都是需要维护工作的 3 虽然引用计数的消耗和计算量相关,但这个额外附加的时间是倍数级别的,不会影响程序本身的复杂性,比如cpu消耗从60%涨到70%,那多投入一点硬件,在工程上也能接受

三、引用计数本身是一个整数,这个整数的范围如果越界,可能造成一些意想不到的后果,而如果用比较长的整数类型,则又耗费内存空间,不过这个问题在现在看来不是很严重,因为现在一般用32位int做计数,只有在超出21亿多个地方引用一个对象的时候才会溢出,如果不是变态程序,基本不会出问题

四、如果一个对象是容器,则当这个对象的引用减为0的时候,释放之前会对它所引用的对象做计数减一的操作,伪代码表示:

void dec_ref()   
{   
    this.ref --;   
    if (this.ref == 0)   
    {   
        for item in this   
        {   
            item.dec_ref();   
        }   
    }   
    delete this;   
}

这里出现了递归调用,有些时候,这个调用可能会很深,例如一个很长的单链表,如果栈空间不够,搞不好就崩溃了,这不是危言耸听,linux下栈空间一般8M还好点,vs默认只有1M,递归深度很有限,一般写C++的时候,如果习惯在析构函数里面释放空间,也可能出现这个问题;另外,一次释放这么多对象也是耗时的,虽然大多数情况下引用计数的实时性表现很好,但如果需要释放的东西太多,也会造成卡顿 这个问题可以通过一个专门的释放器来解决,即当一个对象的引用计数为0时,将其交给释放器,在释放器中可以通过BFS之类的算法来逐步释放空间,避免栈溢出和卡顿,在跟踪式垃圾回收机制中,也有类似的渐进式