《垃圾回收的算法与实现》 一

  一、GC介绍

          1.GC就是为了回收对象,对象是GC的基本单位。一般对象由头和域组成

                (1)头主要包括对象大小,对象种类和运行GC所需要的信息。

                (2) 对象的域就是对象使用者可访问的部分。比如c语言的结构体成员,java的类成员。域主要包括两种指针和非指针。对于java就是引用类型和基本类型。

         2.对象存放在堆中

                  堆是用于动态分配内存的一个空间,比如在普通linux进程中使用brk()调整堆的大小,然后使用malloc分配堆内存,使用free释放堆内存。在jvm中使用了mmap系统调用,采用匿名映射方式在mmap区开辟了一段空间做为java堆,由jvm自己维护堆的分配和回收工作。

        3.活动对象和非活动对象

                 活动对象就是可以通过程序引用的对象就是活动对象,不能通过程序引用的对象就是非活动对象,比如java方法中的局部变量分配的对象,在方法调用完成后,就不可能对这个变量在访问了,此时的对象就是非活动对象就可以回收。GC需要保留活动对象,回收非活动对象。

        4.对象分配

                 对象分配就是对堆内存的分配,内存分配算法有很多种,比如linux内核的伙伴系统,slab,slub算法,首次适应,最佳适应,jvm的碰撞指针。这里面除了jvm的碰撞指针其他的都是基于类空闲链表法,也就是需要使用一个数据结构维护空闲内存区。

       5.根

               类似于树的根结点,在GC里就是指向对象的的指针起点,比如全局变量,局部变量,寄存器等。在java中静态变量,方法中局部变量都可以为根。          

  二 、GC标记清除算法

          GC标记清除算法由两阶段构成标记阶段和清除阶段,标记阶段是把所有的活动对象做上标记,清除阶段就是把没有被标记的对象进行清除。

         1.标记阶段就是从根节点出发进行遍历,访问到的结点做上标记遍历完成后,就将所有的活对象标记成功。一般使用图的深度优先搜索算法或者广度优先搜索算法。

  伪代码如下,就是图的深度优先搜索

mark_phase() {
    for(Root root:roots)
       mark(root);
}
mark(Object object) {
    if(object.visited == false) {
        object.visited = true;
        for(Object obj : list(object))
            mark(obj);
    }
}

          2.清除阶段,需要遍历整个堆,将未被标记的对象回收,已经被标记的对象清除标记位,为下次GC做准备。清除所需的时间和堆的大小成正比。

  三、GC引用计数算法

              引用计数算法引入了计数器概念,也就是当前对象被引用了多少次,第一次分配初始化为1,然后被引用一次就会递增,引用解除后就会递减,减到0就会被回收。python垃圾回收使用了引用计数,linux kernel的驱动开发框架对象分配回收也使用了引用计数。

               引用计数会在对象头添加一个计数器,一般为无符号整形。

           

              在标记清除算法中,GC模块会在一定条件下触发,去调用gc函数,但是引用计数算法没有明确的GC函数,一般会调用更新引用的函数。

             对象分配伪代码如下:

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

首先会从空闲链表寻找合适的内存块,如果分配成功则将引用计数器设置为1并返回。

      更新引用计数器伪代码如下:

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

当ptr引用指向对象obj时,需要将obj被引用次数加一,ptr原来指向的对象引用数减一,然后将obj赋值给ptr,此处先将obj引用次数加一,后将ptr次数减一,是为防止ptr原本就指向obj,如果是这种情况,先将ptr-1的话可能会将obj的引用计数器减到0,这样obj会被回收掉,就会变为了null,发生bug。所以必须先将obj的引用次数+1,然后将ptr指向的对象引用数-1。

inc_ref函数伪代码如下:

inc_ref_cnt(obj){
obj.ref_cnt++
}

直接将obj的引用次数加一。

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)
}

首先将obj的引用数减一,如果obj的引用数变为0,此时就需要将obj成员指向的对象引用次数减一,所以此时需要递归调用dec_ref_cnt。然后回收obj。

引用计数算法优点:

    1.可以即刻被回收。在程序运行中每个对象都知道自己被引用次数,当引用次数变为0时立即被回收,重新加入空闲链表。好处就是内存没有垃圾,只分为空闲内存和正在使用的内存,不存在不再使用但是也不能被分配的垃圾。使得内存利用率最高。

   2.最大暂停时间短。只有在更新引用时才会暂停应用程序执行。

   3.不需要沿根指针查找。

引用技术算法缺点:

    1.计数器的增减处理繁重,只要涉及引用变更的代码,都需要更新计数器,所以需要插入大量更新引用代码。

    2.计数器需要占很多位,比如在32位机器中,引用计数器占32位,可以被2^32次方个对象引用,而这个对象只有2个域,那计数器就会占用1/3的内存。

     3.循环引用无法回收。

    

class Person{
   string name
   Person lover
}
xm = new Person(" 晓明")
baby = new Person(" baby ")
xm.lover = baby
baby.lover = xm  //图中第一个图
taro = null
hanako = null    //第二个图

对象A的成员指向了对象B,对象B的成员指向看对象A,根中有引用变量指向了两个对象,此时AB的引用计数都是2。会变成图1,如果根中的引用变量置为null,则变成了图二,此时AB的引用计数都是1.这样就不能被回收,会发生内存泄漏。

 所以基础版本的引用计数算法不可用,需要改良。改良版的引用计数算法包括:延迟引用计数法,Sticky引用计数法,1位引用计数法,部分标记-清除算法。

四、GC复制算法——深度优先搜索

     GC 复制算法是利用 From 空间进行分配的。当 From 空间被完全占满时,GC 会将活动对象全部复制到 To 空间。当复制完成后,该算法会把 From 空间和 To 空间互换,GC 也就结束了。From 空间和 To 空间大小必须一致。这是为了保证能把 From 空间中的所有活动对象都收纳到 To 空间里。如下图:

   

灰色表示占用,白色表示空闲,首先如果from空间占满了,就会触发GC,将存活的对象复制到to空间,然后交换from和to的指针,将to空间清空。

其中最需要注意的是复制完之后对象的地址会发生变化,地址发生变化后我们需要更新各个引用的地址。看伪代码:

copying(){
  $free = $to_start
  for(r : $roots)
     *r = copy(*r)
  swap($from_start, $to_start)
}

首先将to空间的起始地址指向free,然后遍历根集合调用copy函数,copy函数会返回复制后的新的地址,这样就更新了引用。最后交换from和to的起始地址。再看copy函数伪代码:

copy(obj){
  if(obj.tag != COPIED)
    copy_data($free, obj, obj.size)
    obj.tag = COPIED
    obj.forwarding = $free
    $free += obj.size
    for(child : children(obj.forwarding))
       *child = copy(*child)
  return obj.forwarding
}

对象头有一个tag标记,对象是否复制过,类似深度优先搜索的是否访问过,防止有多个指向对象的引用造成重复复制。

  1.如果未被复制过,则将obj复制到以free为起始地址的空间,比如对象A复制后在to空间生成对象A'。

 2.将tag标记为已经复制过,然后将forwarding设置为to空间的新地址。将free指向新的空闲起始地址。然后递归遍历从A出发的其它对象。

 3.最后返回obj的forwarding值,这样所有的引用都指向了to空间的新地址。

执行完成后如下图:

   

图中的活动对象 A、C、D 保持着原有的引用关系被从 From 空间复制到了 To 空间。此外,从根指向 A 的指针也被换成了 AꞋ。留在 From 空间里的对象 A BC D 都被回收了。

优点:

   1.优秀的吞吐量,GC标记清除算法首先要搜索活动对象标记,然后遍历整个堆进行清除,而复制算法只需要遍历活动对象然后进行复制即可。

    2.分配速度快,GC复制算法不使用空闲链表。这是因为分块是一个连续的内存空间。所以只要申请的内存小于分块大小就可以直接分配,直接移动起始地址指针,时间复杂度O(1)。比如jvm的新生代内存分配就是使用碰撞指针。而空闲链表法需要遍历链表。

   3.不会发生碎片,复制算法在GC的时候把活动对象移动到to区的时候是按照to区的起始地址从小到大复制,然后释放掉所有的from区空间,所以不存在内存缝隙。而空闲链表法,回收的时候不能移动对象的地址,所以会存在碎片。

缺点:

  1.堆的使用效率低下,GC算法通常将堆进行二等分,通常只能利用其中的一半来存放对象,1GB的内存只能利用500mb,所以空间浪费严重,GC复制和GC标记清除算法搭配可以解决这个缺点,例如hotspot的实现。

  2.递归调用函数,在复制对象的时候需要递归的去复制子对象。

五.Cheney的GC复制算法——广度优先搜索

       深度优先搜索会大量使用递归,效率低下,而且有爆栈的危险,所以可以使用迭代式的广度优先搜索。

copying(){
  scan = $free = $to_start
  for(r : $roots)
    *r = copy(*r)
  while(scan != $free)
    for(child : children(scan))
      *child = copy(*child)
    scan += scan.size
  swap($from_start, $to_start)
}

   初始化scan和free指向to区的起始地址,首先复制所有根引用对象,复制完后free指向新的空闲空间起始地址,然后开始复制刚才加入to区的对象的孩子。再看copy函数

copy(obj){
   if(is_pointer_to_heap(obj.forwarding, $to_start) == FALSE)
     copy_data($free, obj, obj.size)
     obj.forwarding = $free
     $free += obj.size
 return obj.forwarding
}

首先判断obj.forwardin是否属于to区,如果是则说明已经复制过了直接返回,否则复制obj到to区,然后将obj.forwarding指向to区的新地址,更新to区free指针。然后返回。在广度优先搜索中不需要tag标签标记是否复制,只需要使用forwarding字段即可。

  执行过程:

1.根指向了B和G,B指向了A,G指向了B和E


2.首先复制根直接引用的对象,将B和G复制到to区,scan 仍然指着 To 空间的开头, $free 从 To 空间的开头向右移动了 B 和 G 个长度,关键是 scan 每次对复制完成的对象进行搜索时,以及 $free 每次对没复制的对象进行复制时,都会向右移动。剩下就是重复搜索对象和复制,直到 scan 和 $free 一致。
 

3.然后对B'开始搜索,将B'指向的对象A复制到to区,同时把 scan 和 $free 分别向右移动了。

4.然后对G'开始搜索,将G'指向的对象E复制到to区,注意G'也指向B,但是B已经复制过了所以此时直接把B的forawrding指针赋值给G',然后搜索A'和E',他们没有指向的对象直接退出循环返回。然后复制完成后如下图所示


在一般的图的广度搜索算法中,会需要一个FIFO的队列存储上一层遍历的顶点,在这个算法中没发现,是因为while(scan != $free)直接把to区当做了一个队列。因为to区的插入就是一个FIFO的类数组。直接使用scan指针当做下标即可。

优点:

       Cheney的GC复制算法既没有递归调用栈带来的额外时间消耗,也没有普通广度优先搜索的占用内存(需要一个FIFO的队列)。

缺点:

       深度优先搜索会将两个相邻的引用放到一块,这样可以充分利用cache(cache是成块替换的),比如A引用B,在深度优先搜索下会将AB放到堆中相邻的位置,在缓存到cache的时候,AB可能会缓存到同一个cache块,这样在A访问B的时候不会发生cache miss,但是广度优先搜索,会将引用的两个对象放到不相邻的地址,不能充分利用cache。

六.GC标记压缩算法

        GC复制算法和GC标记清除算法相结合就是GC标记压缩算法,GC标记压缩算法在标记阶段和GC标记清除算法一样,在压缩阶段和GC复制算法的结果类似就是将对象按照地址从小到大的顺序排列,不会产生内存碎片。

      Lisp2算法

           lisp2算法通过操作对象头forwarding指针达到压缩。

                                                                                                    初始状态

                      

                                                                                               标记完成后状态

                                                                                              压缩完成后状态

          标记阶段和标记清除算法标记阶段一样,看压缩阶段伪码:

compaction_phase(){
  set_forwarding_ptr()
  adjust_ptr()
  move_obj()
}

主要有三个步骤:1.设置forwarding指针。2.更新指针。3.移动对象

程序会搜索整个堆,给活动的对象设置forwarding指针。forwarding初始状态是null。看set_forwarding_ptr函数。

set_forwarding_ptr(){
  scan = new_address = $heap_start
  while(scan < $heap_end)
    if(scan.mark == TRUE)
        scan.forwarding = new_address
        new_address += scan.size
    scan += scan.size
}

   scan 是用来搜索堆中的对象的指针, new_address 是指向目标地点的指针。一旦 scan 指针找到活动对象,就会将对象的 forwarding 指针的引用目标从 NULL 更新到new_address,将 new_address 按对象长度移动。set_forwarding_ptr() 函数执行完毕后,堆的状态如图所示:

这里处理forwarding和复制算法有不同,复制算法设置完forwarding之后,就开始移动,这里不行,因为标记清除是在同一个空间内进行,如果此时进行移动会把原来的域覆盖掉,比如B先移动,此时C的新位置还未确定,当c移动之后,再去更新B指向C的指针就找不到C了。所以第一轮遍历先确定各个对象的新地址。

下面看更新指针:

adjust_ptr(){
  for(r : $roots)
    *r = (*r).forwarding
  scan = $heap_start
  while(scan < $heap_end)
    if(scan.mark == TRUE)
      for(child : children(scan))
         *child = (*child).forwarding
      scan += scan.size
}

首先更新根结点的引用。然后对堆进行搜索,将所有标记为活的对象的引用更新。

                                                                                                  更新完如图所示

最后开始移动对象:

move_obj(){
  scan = $free = $heap_start
  while(scan < $heap_end)
    if(scan.mark == TRUE)
      new_address = scan.forwarding
      copy_data(new_address, scan, scan.size)
      new_address.forwarding = NULL
      new_address.mark = FALSE
      $free += new_address.size
    scan += scan.size
}

将堆中活动的对象移动到新地址处,移动完成后将forwarding指针置为null,将标记为活动的标志恢复。更新空闲区起始地址。

                                                                                      对象移动完成后状态

优点:堆的利用效率高

缺点:压缩耗费时间长,需要三次遍历整个堆。堆越大耗时越长。
 

    GC标记压缩算法还有Two-Finger 算法,表格算法,ImmixGC算法。

七.分代垃圾回收——Ungar的分代垃圾回收

        由于大部分对象分配后马上又变成了垃圾,很少有对象能活很久。所以需要引入年龄的概念。把刚生成的对象叫做新生代对象,达到一定年龄的对象称为老年代对象。

        新生代的GC叫做minorGC。另外新生代GC将存活了一定次数的新生代对象做为老年代去处理,新生代对象上升为老年代对象叫做晋升。因为老年代的对象很少成为垃圾,所以老年代GC的频率很低,老年代GC叫做major GC。

        分代垃圾回收不能单独用来进行GC,需要和以上算法结合使用,这样可以提高以上算法的效率。也就是说,分代垃圾回收不是跟 GC 标记 - 清除算法和 GC 复制算法并列在一起供我们选择的算法,而是需要跟这些基本算法一并使用。

       David Ungar美国的计算机科学家发表的一篇论文,描述了分代垃圾回收的算法过程,在 Ungar 的分代垃圾回收中,堆的结构我们总共需要利用 4 个空间,分别是生成空间、2 个大小相等的幸存空间以及老年代空间,并分别用$new_start、$survivor1_start、 $survivor2_start、$old_start 这 4 个变量引用它们的开头。我们将生成空间和幸存空间合称为新生代空间。新生代对象会被分配到新生代空间,老年代对象则会被分配到老年代空间里。Ungar 在论文里把生成空间、幸存空间以及老年代空间的大小分别设成了 140K字节、28K 字节和 940K 字节。堆结构如下图:


图中生成空间就是进行对象分配的空间。当生成空间满的时候新生代GC就会启动,将生成空间的所有对象进行复制,目标是幸存空间,和GC复制算法原理一样。2 个幸存空间和 GC 复制算法里的 From 空间、To 空间很像,我们经常只利用其中的一个。在每次执行新生代 GC 的时候,活动对象就会被复制到另一个幸存空间里。在此我们将正在使用的幸存空间作为 From 幸存空间,将没有使用的幸存空间作为 To 幸存空间。不过新生代 GC 也必须复制生成空间里的对象。也就是说,生成空间和 From 幸存空间这两个空间里的活动对象都会被复制到 To 幸存空间里去。这就是新生代 GC。只有从一定次数的新生代 GC 中存活下来的对象才会得到晋升,也就是会被复制到老年代空间去。

新生代GC过程如下图:


GC将生成空间和from区活对象复制到to,然后交换from和to。

   在执行新生代 GC 时有一点需要注意,那就是我们必须考虑到从老年代空间到新生代空间的引用。新生代对象不只会被根和新生代空间引用,也可能被老年代对象引用。因此,除了一般 GC 里的根,我们还需要将从老年代空间的引用当作根(像根一样的东西)来处理。
 

                                                                                         老年代对象指向新生代对象

   分代垃圾回收的优点是只将垃圾回收的重点放在新生代对象身上,以此来缩减 GC 所需要的时间。不过考虑到从老年代对象的引用,结果还是要搜索堆中的所有对象,这样一来就大大削减了分代垃圾回收的优势。所以需要将老年代引用新生代的对象记录下来,这样就只需要搜索这类对象。

   记录集就是存放记录老年代引用新生代的老年代对象,这样就能通过记录集搜索发出引用的对象,进而晋升引用的目标对象,再将发出引用的对象的指针更新到目标空间。

我们需要将老年代引用新生代对象的对象放到记录集,这个函数和引用计数的update_ptr函数类似,需要在引用赋值的时候调用伪代码如下:

   

write_barrier(obj, field, new_obj){
  if(obj >= $old_start && new_obj < $old_start && obj.remembered == FALSE)
    $rs[$rs_index] = obj
    $rs_index++
    obj.remembered = TRUE
  *field = new_obj
}

参数 obj 是发出引用的对象, obj 内存在要更新的指针,而 field 指的就是 obj 内的域,new_obj 是在指针更新后成为引用目标的对象。 首先判断obj是否在老年代,并且new_obj是否在新生代,并且obj未被放到记录集(防止重复放置)。如果obj在老年代并且new_obj在新生代,并且obj未被放置到老年代。则将obj的引用放到记录集,记录集游标++,将obj放置到记录集的标记置为true,最后将new_obj赋值给obj的filed域。

内存分配是在生成空间分配的,伪代码:

new_obj(size){
  if($new_free + size >= $survivor1_start)
    minor_gc()
  if($new_free + size >= $survivor1_start)
    allocation_fail()
  obj = $new_free
  $new_free += size
  obj.age = 0
  obj.forwarded = FALSE
  obj.remembered = FALSE
  obj.size = size
  return obj
}

  如果空间不够则触发minor_gc,如果还不够则分配失败。否则将空闲空间的首地址赋值给obj,更新空闲空间首地址,obj的年龄为0,forwarded标志(是否进行了复制标志,防止重复复制)是false,rembered标志(是否存在记录集)是false。

新生代GC需要调用minor_gc,然后minor_gc调用copy函数,伪代码如下:

copy(obj){
  if(obj.forwarded == FALSE)
    if(obj.age < AGE_MAX)
      copy_data($to_survivor_free, obj, obj.size)
      obj.forwarded = TRUE
      obj.forwarding = $to_survivor_free
      $to_survivor_free.age++
      $to_survivor_free += obj.size
      for(child : children(obj))
        *child = copy(*child)
    else
      promote(obj)
  return obj.forwarding
}

遍历生成空间的对象,1.如果对象还未发生过复制,如果对象的年龄小于晋升到老年代的年龄:则把对象复制到幸存者区的to空间,设置forwarded复制标志位true,forwarding指针为to空间新地址,obj年龄+1。更新to空间的空闲起始地址。然后递归遍历obj的所引用的结点。

2.如果对象还未发生过复制,如果对象的年龄大于等于晋升到老年代的年龄:则直接将obj晋升到老年代。

3.最后返回obj新地址。

再看晋升函数伪代码:

promote(obj){
  new_obj = allocate_in_old(obj)
  if(new_obj == NULL)
    major_gc()
    new_obj = allocate_in_old(obj)
  if(new_obj == NULL)
    allocation_fail()
  obj.forwarding = new_obj
  obj.forwarded = TRUE
  for(child : children(new_obj))
    if(*child < $old_start)
      $rs[$rs_index] = new_obj
      $rs_index++
      new_obj.remembered = TRUE
      return
}

1.在老年代分配一个和obj一样的对象。2.如果空间不够则启动major_gc()。3.再次分配。4.如果还不够则分配失败。5.如果分配成功则将obj在老年代的新地址赋值给forwarding指针,复制标志置为true。6.遍历obj引用的对象如果在新生代则将obj加入记录集,加入记录集标志为true,更新记录集下标,最后返回。

再看minor gc函数:

minor_gc(){
  $to_survivor_free = $to_survivor_start
  for(r : $roots)
    if(*r < $old_start)
      *r = copy(*r)
  i = 0
  while(i < $rs_index)
    has_new_obj = FALSE
    for(child : children($rs[i]))
       if(*child < $old_start)
         *child = copy(*child)
          if(*child < $old_start)
             has_new_obj = TRUE
    if(has_new_obj == FALSE)
         $rs[i].remembered = FALSE
         $rs_index--
         swap($rs[i], $rs[$rs_index])
    else
         i++
swap($from_survivor_start, $to_survivor_start)
}

1.将to区首地址给$to_survivor_free。2.复制根指向新生代的存活对象。3.遍历记录集复制老年代指向新生代的对象。4.如果还有老年代指向的对象还在新生代则复制,如果复制完还是在新生代则将has_new_obj置为true。5.如果has_new_obj是false说明当前老年代对象已经没有指向新生代对象了。所以需要将记录集当前老年代对象删除。首先将remebered标志设置为false。然后将记录集大小减一,最后将记录集最后一个对象和当前对象调换位置。6.全部复制完成后,交换form和to指针。

minor gc过程如下图:

    

老年代GC算法:Ungar使用的GC标记清除。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值