一、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标记清除。