前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >《垃圾回收的算法与实现》 一

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

作者头像
用户4415180
发布2022-06-23 14:31:09
8430
发布2022-06-23 14:31:09
举报
文章被收录于专栏:高并发高并发

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

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

代码语言:javascript
复制
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函数,一般会调用更新引用的函数。

             对象分配伪代码如下:

代码语言:javascript
复制
new_obj(size){
obj = pickup_chunk(size, free_list)
if(obj == NULL)
allocation_fail()
else
obj.ref_cnt = 1
return obj
}

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

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

代码语言:javascript
复制
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函数伪代码如下:

代码语言:javascript
复制
inc_ref_cnt(obj){
obj.ref_cnt++
}

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

dec_ref_cnt函数伪代码如下:

代码语言:javascript
复制
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.循环引用无法回收。

代码语言:javascript
复制
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空间清空。

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

代码语言:javascript
复制
copying(){
  $free = $to_start
  for(r : $roots)
     *r = copy(*r)
  swap($from_start, $to_start)
}

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

代码语言:javascript
复制
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复制算法——广度优先搜索

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

代码语言:javascript
复制
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函数

代码语言:javascript
复制
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 和

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指针达到压缩。

                                                                                                    初始状态

                                                                                               标记完成后状态

                                                                                              压缩完成后状态

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

代码语言:javascript
复制
compaction_phase(){
  set_forwarding_ptr()
  adjust_ptr()
  move_obj()
}

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

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

代码语言:javascript
复制
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了。所以第一轮遍历先确定各个对象的新地址。

下面看更新指针:

代码语言:javascript
复制
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
}

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

                                                                                                  更新完如图所示

最后开始移动对象:

代码语言:javascript
复制
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函数类似,需要在引用赋值的时候调用伪代码如下:

代码语言:javascript
复制
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域。

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

代码语言:javascript
复制
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函数,伪代码如下:

代码语言:javascript
复制
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新地址。

再看晋升函数伪代码:

代码语言:javascript
复制
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函数:

代码语言:javascript
复制
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标记清除。

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2018-11-01,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  •   一、GC介绍
    •           1.GC就是为了回收对象,对象是GC的基本单位。一般对象由头和域组成
      •          2.对象存放在堆中
      •         3.活动对象和非活动对象
      •         4.对象分配
      •        5.根
      •   二 、GC标记清除算法
      •   三、GC引用计数算法
      • 四、GC复制算法——深度优先搜索
      • 优点:
      • 缺点:
      • 五.Cheney的GC复制算法——广度优先搜索
      • 六.GC标记压缩算法
        •       Lisp2算法
        • 优点:堆的利用效率高
        • 缺点:压缩耗费时间长,需要三次遍历整个堆。堆越大耗时越长。
        • 七.分代垃圾回收——Ungar的分代垃圾回收
        •         由于大部分对象分配后马上又变成了垃圾,很少有对象能活很久。所以需要引入年龄的概念。把刚生成的对象叫做新生代对象,达到一定年龄的对象称为老年代对象。
        领券
        问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档