就如他的字面意思一样,由标记阶段和清除阶段构成。标记阶段是把所有的活动对象都做上标记的阶段。清除阶段是把那些没有标记的对象,也就是非活动对象回收的阶段。通过这两个阶段,就可以令不能利用的内存空间重新得到利用。
1、 标记阶段
mark_phase(){
for(r:$roots)
mark(*r)
}
在标记阶段中,collector会为堆里所有活动对象打上标记。为此,我们首先要标记通过根直接引用的对象。然后递归地标记通过指针数组能访问到的对象。这样就能把所有活动对象都标记上。
mark(obj){
if(obj.mark == false)
obj.mark = true
for(child :children(obj))
mark(*child)
}
如果标记未完成,则程序会在对象头部进行置位操作。这个位要分配在对象的头之中,并且能用obj.mark访问。
在标记阶段,程序会标记所有活动对象。毫无疑问,标记所花费的时间是与“活动对象的总数成正比”。
标记阶段结束时的堆如下图所示:
2、 清除阶段
在清除阶段中,collector会遍历整个堆,回收没有打上标记的对象(即垃圾),使其可以再次得到利用。
sweep_phase(){
sweeping = $heap_start
while(sweeping < $heap_end)
if(sweeping.mark == true) sweeping,mark = false
else
sweeping.next = $free_list
&free_list = sweeping
sweeping += sweeping.size
}
在此出现了叫做size的域,这是存储对象大小(字节数)的域,跟mark域一样,我们事先在各对象的头中定义他们。
在清除阶段,我们使用变量sweeping遍历堆,具体来说就是从堆首地址$heap_start开始,按顺序一个个遍历对象的标志位。
设置了标志位,就说明这个对象是活动对象。活动对象必然是不能回收的。因为我们取消标志位,准备下一次的GC。
我们必须把非活动对象回收再利用。回收对象就是把对象分块,链接到被称为“空闲链表”的单向链表。在之后进行分配时只要遍历这个空闲链表,就可以找到分块了。
在清除阶段,程序会遍历所有堆,进行垃圾回收。也就是说,所花费时间与堆大小成正比。堆越大,清楚阶段所花费的时间就会越长。
3、分配
接下来为大家讲解分配的相关内容。这里的分配是指将回收的垃圾进行再利用。那么,分配是怎样进行的呢?也就是说,当mutator 申请分块时,怎样才能把大小合适的分块分配给mutator 呢?
如前文所述,我们在清除阶段已经把垃圾对象连接到空闲链表了。搜素空闲链表并寻找大小合适的分块,这项操作就叫作分配。执行分配的函数 new_obj()如下所示。
new_obj(size){
chunk = pickup_chunk(size,$free_list)
if(chunk != NULL) return chunk
else allocation_fail()
}
这里的pickup_chunk()函数用于遍历$free_list,寻找大于等于size的分块。如果他找到和size大小相同的分块,则会直接返回改分块;如果找到比size大的分块,则会将其分割成size大小的分块和去掉size后剩余大小的分块,并把剩余的分块返回给空闲链表。
分配策略 | 描述 |
---|---|
First-fit | 发现大于等于size的分块立即返回结果 |
Worst-fit | 找出大于等于size的最大分块返回结果 |
Best-fit | 找出大于等于size的最小分块返回结果 |
4、合并
根据分配策略的不同可能会产生大量的小分块。但如果他们是连续的,我们就能把所有的小分块连在一起形成一个大分块。这种操作就叫做合并。
sweep_phase(){
sweeping = $heap_start
while(sweeping < $heap_end)
if(sweeping.mark == true) sweeping.mark = false
else
if(sweeping == $free_list + $free_list.size)
$free_list.size += sweeping.size
else
sweeping.next = $free_list
$free_list = sweeping
sweeping += sweeping.size
}
5、优缺点
优点
- 实现简单
- 与保守式GC算法兼容:不会移动对象
缺点
- 碎片化:使用过程中会逐渐产生被细化的分块,不久后就会导致无数的小分块散布在堆的各处。众所周知,Windows的文件系统也会产生这种现象。
- 分配速度底下:因为分块不是连续的,每次分配都必须遍历空闲链表,找到足够大的分块。最糟糕的情况就是每次进行分配都得把空闲链表遍历到最后。下面将要介绍的多个空闲链表和BiBOP法都是为了能在标记清除算法中高速进行分配而想出的方法。
- 与写时复制技术不兼容:即使没有重写对象,GC也会设置所有活动对象的标志位,这样就会频繁发生本不应该发生的复制,压迫到内存空间。
写时复制技术(copy-on-write)
在Linux等众多UNIX操作系统的虚拟存储中用的的高速化方法。比如:在Linux中复制进程,也就是使用fork函数时,大部分内存空间都不会被复制。只是复制进程,就复制了所有内存空间不太实际。因此写时复制技术只是装作复制了内存空间,实际上是将内存空间共享了。
在各个进程中访问数据时,能够访问共享内存就没什么问题了。
然而,当我们对共享内存空间进行写入时,不能重写共享内存。因为从其他程序访问时,会发生数据不一致的情况。在重写时,要复制自己私有空间的数据,对这个私有空间进行重写。复制后只访问这个私有空间,不访问贡献内存。像这样,因为这门技术是在写入时进行复制的,所以才称为写时复制技术。
6、多个空闲链表
之前的标记清除算法,我们只用到了一个空闲链表,在这个空闲链表中,对大的分块和小的分块进行了同样的处理,但这样一来,每次分配的时候都要遍历一次空闲链表来寻找合适大小的空闲链表。
因此,我们就利用分块大小不同的空闲链表,来分配不同的分块。
7、BiBOP法
Big Bag Of Page:将大小相近的对象整理成固定大小的块进行管理的做法。
我们把堆分割成固定大小的块,让每个块只能配置同样大小的对象。
BiBOP法原本是为了消除碎片化,提高堆效率而采用的方法,但像上面这样,在多个块中分散残留着同样大小的对象,反而会降低堆的使用效率。
8、位图标记
在单纯的标记清除法中,用于标记的位是被分配到各个对象的头中的,也就是说,算法是把对象和头一并处理的。所以这和写时复制技术不兼容。
对此我们有个方法,那就是只收集各个对象的标志位并表格化,不和对象一起管理,在标记的时候不在对象的头里置位,而是在这个表格中的特定场所置位。像这样集合了用于标记的位的表哥称为“位图表格”,利用这个表格进行标记的行为称为“位图标记”
这样做的优点就是与写时复制技术相兼容,不会产生无谓的复制。并且清除起来也更加高效。
9、延迟清除法
之前我们提到过,清除操作所花费的时间与堆大小成正比。也就是说堆越大,清除所花费的时间就越长。结果就会妨碍到mutator的处理。
延迟清除法,就是在标记操作结束后,不一并进行清除操作,而是延迟,来防止mutator长时间的暂停。
因为延迟清除法,不是一下遍历整个堆,他只在分配时执行必要的便利,所以可以压缩因清除操作导致的mutator的暂停时间。