Reference Counting GC (Part one)

引用计数法

George E.Collins.1960.数组

引用计数算法中引入了一个概念计数器。计数器表明对象被引用的次数,它是无符号正整数用于计数器的位数根据算法和实现有所不一样。本文结构以下图所示。缓存

在变动数组元素等的时候会进行指针的更新。经过更新指针,可能会产生没有被任何程序引用的垃圾对象。引用计数法中会监督在更新指针的时候是否有产生垃圾,从而在产生 垃圾时将其马上回收。这样,能够说将内存管理和 mutator 同时运行正是引用计数法的一大特征。分布式

计数器值的增减

引用计数算法中,mutator没有明确启动GC的语句。它在mutator的处理过程当中经过增减计数器的值来进行内存管理,这两种状况下计数器的值会发生改变,这使用到了new_obj()函数和update_ptr()函数。函数

new_obj()和update_ptr()函数

new_obj()生成对象

new_obj(size){
    obj = pickup_chunk(size, $free_list)
    
    if(obj == NULL)
        allocation_fail()
    else
        obj.ref_cnt = 1
        return obj
}

这是生成新的对象,使用pickup_chunk()函数。当函数返回NULL是表明分配失败。在引用计数法中,除了链接到空闲链表的对象,其余全部对象都是活动对象。也就是说,一旦返回NULL就表明没有任何多余的空间了。也就没法进行分配。指针

当pickup_chunk()函数返回合适大小的对象时,讲他的计数器ref_cnt置为1,表明其被引用了1次。code

update_ptr()更新指针ptr,对计数器进行增减

update_ptr(ptr, obj){
    inc_ref_cnt(obj)  //计数器+
    dec_ref_cnt(*ptr) //计数器-
    *ptr = obj  // 从新指向 obj
}

在mutator更新指针时会执行此程序。其、*ptr = obj是指针更新的部分。orm

增长引用和删除引用

inc_ref_cnt(obj)对指针ptr新引用对象obj计数器进行增量操做。对象

inc_ref_cnt(obj){
    obj.ref_cnt++
}

仅仅是对对象计数器进行自增操做。blog

dec_ref_cnt(*ptr)对以前ptr引用的对象进行计数器减量操做。

dec_ref_cnt(*ptr){
    obj.ref_cnt--
    if (obj.ref_cnt == 0)
        for(child : children(obj)) // 当本身被清除时,本身所引用的孩子的计数器必须减一。进行递归操做。
            dec_ref_cnt(*child)
        reclaim(obj)
}
  • 首先对更新指针以前引用的对象*ptr的计数器进行减量操做。减量操做后,计数器的值为0的对象变成了“垃圾”。所以,这个对象的指针会所有被删除。
  • 而后递归调用dec_ref_cnt(*ptr)对孩子进行计数器减量的操做。
  • 而后经过reclaim()函数,将obj链接到空闲链表上面。

疑问为何要先inc_ref_cnt(obj)而后再dec_ref_cnt(*ptr)呢?
以为应该先调用 dec_ref_cnt() 函数,后调 用 inc_ref_cnt() 函数才合适。

答案就是“为了处理ptr和obj是同一对象时的状况”。若是按照先dec_ref_cnt()后inc_ref_cnt()函数的顺序调用ptr和 obj又是同一对象的话执行dec_ref_cnt(ptr)时ptr的计数器的值就有可能变为0而被回收。这样一来,下面再想执行inc_ref_cnt(obj)时obj早就被回收了,可能会引起重大的BUG。

update_ptr()函数执行状况

初始状态下从根引用A和C,从A引用B。A持有惟一指向B的指针(代指状态a),假设如今将A指针更新到了C(状态b)。以下图。

B的计数器值变成了0,所以B被回收。且B链接上了空闲链表,可以再被利用了。又由于新造成了由A指向C的指针,因此C的计数器的值增量为2。

优势

可便可回收垃圾

在引用计数法中,每一个对象始终都知道本身的被引用数(就是计数器的值)。当被引用数的值为0时,对象立刻就会把本身做为空闲空间链接到空闲链表。也就是说,各个对象在变成垃圾的同时就会马上被回收。要说这有什么意义,那就是内存空间不会被垃圾占领。垃圾 所有都已链接到空闲链表,能做为分块再被利用。

最大暂停时间短

在引用计数法中,只有当经过mutator更新指针时程序才会执行垃圾回收。也就是说,每次经过执行mutator 生成垃圾时这部分垃圾都会被回收,于是大幅度地削减了mutator的最大暂停时间。

没有沿着指针查找

引用计数法和GC标记-清除算法不同,不必由根沿指针查找。当咱们想减小沿指针查找的次数时,它就派上用场了。 打个比方,在分布式环境中,若是要沿各个计算节点之间的指针进行查找,成本就会增大,所以须要极力控制沿指针查找的次数因此有一种作法是在各个计算节点内回收垃圾时使用GC标记-清除算法在考虑到节点间的引用关系时则采用引用计数法。

缺点

计数器的增减处理频繁

在大多数状况下指针都会频繁地更新,特别是有根的指针,会以近乎使人目眩的势头飞速地进行更新。这是由于根能够经过mutator直接被引用。在引用计数法中,每当指针更新时,计数器的都会随之更新,所以值的增减处理必然会变得繁重。

计数器占用不少位

用于引用计数的计数器最大必须能数完堆中全部对象的引用数。打个比方,假如咱们用的是32位机器,那么就有可能要让2 的32次方个对象同时引用一个对象。考虑到这种状况,就有必要确保各对象的计数器有32位大小。也就是说,对于全部对象,必须留有32位的空间。这就害得内存空间的使用效率大大下降了。打比方说,假如对象只有2个域,那么其计数器就占了它总体的1/3。

实现繁琐复杂

进行指针更新操做 update_ptr()函数是在mutator这边调用的打个比方咱们须要把以往写成*ptr=obj的地方都重写成updat_ptr(ptr,obj)。由于调用update_ptr()函数的地方很是多,因此重写过程当中很容易出现遗漏。若是漏掉了某处,内存管理就没法正确进行,就会产生BUG。

循环引用问题

由于两个对象互相引用,因此各对象的计数器的值都是1。可是这些对象组并无被其余任何对象引用。所以想一并回收这两个对象都不行,只要它们的计数器值都是1,就没法回收。

延迟引用计数

在讲到引用计数法缺点时候,咱们提到了计数器增减处理繁重。下面就对改善此缺点进行说明即延迟引用计数法(Deferred Reference Countin [L. Peter Deutsch 和 Daniel G. Bobrow] )。

什么是延迟引用计数

咱们就让从根引用的指针的变化不反映在计数器上。打个比方,咱们把重写全局变量指针的 update_ptr(ptr,obj) 改写成 *ptr = obj(直接更改引用对象,没有使用方法也就没有对计数器进行操做。)。如上所述,这样一来即便频繁重写堆中对象的引用关系,对象的计数器值也不会有所变化,于是大大改善了“计数器值的增减处理繁重”这一缺点。然而,这样内存管理仍是不能顺利进行。由于引用没有反映到计数器上,因此各个对象的计数器没有正确表示出对象自己的被引用数。所以,有可能发生对象仍在活动,但却被错当成垃圾回收的状况。 以下图所示该对象其实还正在活动。

以后咱们在延迟计数法中使用ZCT(Zero Count Table)。ZTC是一个表,他会事先记录下计数器值在dec_ref_cnt()函数的做用下变为0的对象

由于计数器值为0的对象不必定都是垃圾,因此暂时先将这些对象保留。由图也能看出,咱们必须修正dec_ref_cnt() 函数,使其适应延迟引用计数法。

dec_ref_cnt()函数

dec_ref_cnt(obj){
    obj.ref_cnt--
    if(obj.ref_cnt == 0)
        if(is_full($zct) == TRUE)
            scan_zct()
        push($zct, obj)
}

当obj计数器为0,就把obj添加到$zct中。不过当$zct爆满,首先要经过scan_zct()函数来减小$zct中的对象。

new_obj()函数

也要修改一线new_obj()函数。当没法分配空间时,执行scan_zct()清理一遍$zct对象(表)。

new_obj(size){
    obj = pickup_chunk(size, $free_list)
    if(obj == NULL)
        scan_zct()
        obj = pickup_chunk(size, $free_list)
        if(obj == NULL)
            allocation_fall()
    obj.ref_cnt = 1
    return obj
}

若是第一次分配没有顺利进行,就意味着空闲链表中没有了大小合适的分块。此时程序要搜索一遍$zct,以再次分配分块。若是这样还不行,分配就失败了。分配顺利进行以后的流程一般与引用计数法彻底同样。

scan_zct()函数

scan_zct(){
    for(r :$roots)  // 以前咱们说过,把根的引用不反应在计数器上,如今要清表了天然要加上。
        (*r).ref_cnt++
    for(obj :$zct)  // 查水表
        if(obj.ref_cnt == 0)  // 判断值
            remove($zct, obj)  // 减计数
            delete(obj)  //清对象
    for(r :$root)
        (*r).ref_cnt-- // 用完了在给根加回去。
}
  • 程序把全部经过根直接引用的对象的计数器都进行增量。这样才算把根引用反映到了计数器的值上
  • 调查全部与$zct相连的对象,若是存在计数器值为0的对象,则将此对象从$zct中删除(delete)。
// delete代码清单
delete(obj){
    for(child :children(obj))
        (*child).ref_cnt--
        if((*child).ref_cnt == 0)
            delete(*child)  // 一样要递归的去操做孩子的计数器
    reclaim(obj) // 加到空闲链表上
    
}

优缺点

优势在延迟引用计数法中,程序延迟了根引用的计数,将垃圾一并回收。经过延迟,减轻了 因根引用频繁发生变化而致使的计数器增减所带来的额外负担。

缺点为了延迟计数器值的增减,垃圾不能立刻获得回收,这样一来垃圾就会压迫堆,咱们也 就失去了引用计数法的一大优势(即刻回收垃圾)。

缺点scan_zct() 函数致使最大暂停时间延长了,执行scan_zct() 函数所花费的时间 $zct的大小成正比。$zct 越大,要搜索的对象就越多,妨碍mutator运做的时间也就越长。要想缩减因scan_zct()函数而致使的暂停时间,就要缩小 $zct。可是这样一来调用scan_ zct()函数的频率就增长了,也压低了吞吐量。很明显这样就本末倒置了。

Stricky引用计数法

什么是Stricky引用计数法

在引用计数法中,咱们有必要花功夫来研究一件事,那就是要为计数器设置多大的位宽。 假设为了反映全部引用,计数器须要1个字(32位机器就是32位)的空间。可是这样会大量消耗内存空间。打个比方,2 个字的对象就要附加1个字的计数器。也就是说,计数器害得对象所占空间增大了1.5 倍。

对此咱们有个方法,那就是用来减小计数器位宽的“Sticky引用计数法”。举个例子咱们假设用于计数器的位数为5位,那么这种计数器最多只能数到2的5次方减1也就是31个引用数。若是此对象被大于31个对象引用,那么计数器就会溢出。这跟车辆速度计的指针爆表是一个情况。

针对计数器溢出(也就是爆表的对象),须要暂停对计数器的管理。对付这种对象,咱们主要有两种方法

什么都不作

对于计数器溢出的对象,咱们能够这样处理:再也不增减计数器的值,就把它放着,什么也不作。不过这样一来,即便这个对象成了垃圾(即被引用数为 0),也不能将其回收。也就是说,白白浪费了内存空间。然而事实上有不少研究代表,不少对象一辈子成立刻就死了。也就是说, 在不少状况下,计数器的值会在0到1的范围内变化,鲜少出现 5 位计数器溢出这样的状况。 此外,由于计数器溢出的对象在执行中的程序里占有很是重要的地位,因此可想而知,其将 来成为垃圾的可能性也很低也就是说,不增减计数器的值,就把它那么放着也不会有什么大问题。 考虑到以上事项,对于计数器溢出的对象,什么也不作也不失为一个可用的方法。

使用GC标记-清除算法进行管理

另外一个方法是,能够使用标记清除算法来辅助。可是须要对标记清除进行修改。
mark_sweep_for_counter_overflow()

// mark_sweep_for_counter_overflow()
mark_sweep_for_counter_overflow(){
    reset_all_ref_cnt()
    mark_phase()
    sweep_phase()
    
}

首先把全部对象的计数器都置为0。下面进入标记阶段和清除阶段。

mark_phase()

// mark_phase()
mark_phase(){
    for(r :roots)
        push(*r, $mark_stack)
    
    while(is_empty($mark_stack) == FALSE)
        obj = pop($mark_stack)
        obj.ref_cnt++
        if(obj.ref_cnt == 1)
            for(child :children(obj))
                push(*child, $mark_stack)

}

首先把由根直接引用的对象堆到标记栈里,而后按顺序从标记栈取出对象。对计数器进行增量操做。不过这里必须把各个对象及其子对象堆进标记栈一次。while里的if是检测各个对象是否是只进栈一次。一旦栈为空,则标记阶段结束。
sweep_phase()

// sweep_phase()
sweep_phase(){
    sweeping = $heap_top
    while(sweeping < $heap_end)
        if(sweeping.ref_cnt == 0)
            reclaim(sweeping)
        sweeping +=sweeping.size
}

在清除阶段,程序会搜索整个堆,回收计数器值仍为0的对象

这里的标记清除,和以前的标记清除主要有如下3点不一样之处。

  1. 一开始把全部对象的计数器置为0.
  2. 不标记对象,而是对计数器进行增量操做。
  3. 为了对计数器进行增量操做,算法对活动对象进行了不止一次的搜索。

像这样,只要把引用计数法和 GC 标记 - 清除算法结合起来,在计数器溢出后即便对象 成了垃圾,程序仍是能回收它另外还有一个优势,那就是还能回收循环的垃圾

可是在进行标记处理以前,必须重置全部的对象和计数器。此外,由于在查找对象时没 有设置标志位而是把计数器进行增量,因此须要屡次(次数和被引用数一致)查找活动对象。 考虑到这一点的话,显然在这里进行的标记处理比以往的 GC 标记 - 清除算法中的标记处理 要花更多的时间。也就是说,吞吐量会相应缩小。

1位引用计数法

1位引用计数法(1bit Reference Counting)是Sticky引用计数法的一个极端例子。由于计数器只有1位大小,因此瞬间就会溢出。

据Douglas W. Clark和C. Cordell Green观察,“几乎没有对象是被共有的,全部对象都能被立刻回收”。考虑到这一点,即便计数器只有 1 位,经过用 0表示被引用数为1用1表示被引用数大于等于2,这样也能有效率地进行内存管理。以下图示。

咱们用1位来表示某个对象的被引用数是1个仍是多个。通常引用计数法是让对象持有计数器,可是W.R.Stoye、T.J.W.Clarke、A.C.Norman 3我的想出了1位引用计数法,以此来让指针持有计数器。

设对象引用数为1时标签位为0,引用数为复数时标签位为1。咱们分别称以上2种状态为UNIQUE和MULTIPLE,处于UNIQUE状态下的指针为“UNIQUE指针”,处于MULTIPLE状态下的指针为“MULTIPLE 指针”。

copy_ptr()函数

1 位引用计数法也是在更新指针的时候进行内存管理的。不过它不像以往那样 指定要引用的对象来更新指针,而是经过复制某个指针来更新指针。进行这项操做的就是 copy_ptr() 函数。下图示。

在这里更新以前A引用D的指针,让其引用C。能够看到,B由UNIQUE变为了MULTIPLE。伪代码以下。

copy_ptr(dest_ptr, src_ptr){
    delete_ptr(dest_ptr)
    *dest_ptr = *src_ptr
    set_multiple_tag(dest_ptr)
    if(tag(src_ptr) == UNIQUE)
        set_multiple_tag(src_ptr)
        
}

数 dest_ptr 和 src_ptr 分别表示的是目的指针和被复制的原指针。在上图图 中,A 的指针就是目的指针,B 的指针就是被复制的原指针。

  1. 首先调用delete_ptr()函数,尝试回收dest_ptr引用对象。
  2. 把src_ptr复制到dest_ptr。
  3. 把指针src_ptr以及dest_ptr的标签更新为MULTIPLE。
  • tag()函数返回实参的标签,返回UNIQUE或者MULTIPLE的任意一值。
  • set_multiple_tag()函数则把指针变成MULTIPLE指针。

最后把mutator的update_ptr()函数的调用全换成copy_ptr()就能实现1位引用计数法。

delete_ptr()函数

delete_ptr(ptr){
    if(tag(ptr) == UNIQUE)
    reclaim(*ptr)
}

只有当指针ptr的标签是UNIQUE时,它才会回收根据这个指针所引用的对象。由于当标签是MULTIPLE时,还可能存在其余引用这个对象的指针,因此它没法回收对象。

优缺点

优势
不容易出现高速缓存缺失。缓存做为存储空间,比内存读取的快得多。若是须要的数据在缓存中,计算机就能进行高速处理。但若是缺失了的话就须要从内存中去读。这样一来浪费许多时间。

也就是说,当某个对象A要引用在内存中离他很远的B时,以往的引用计数法会在增减计数器的值时候去读B,从而致使高速缓存缺失,浪费时间。

1位引用计数法,它不须要在更新计数器的时候读取要引用的对象。指针直接复制就行,不必读取对象。由于不必给计数器留出多余的空间,因此节省了内存消耗量。这也不失为1位引用计数法的一个优势。

缺点 没办法处理计数器益处的对象。虽说,计数器的值通常都不足为2,可是若是少许固然能够放置无论。但咱们不能保证某一个任务不会出现不少计数器溢出的现象。

相关文章
相关标签/搜索