垃圾回收算法(1)标记-清除
mark-sweep,标记-清除算法,问世已有半个世纪,是经典的垃圾回收算法。它的思想是把过程分两步,第一步找到内存中所有的活动对象,并标记(mark)出来;第二步是遍历整个堆,将第一步未mark的对象内存回收入堆。
即:
mark_sweep() {
mark()
sweep()
}
- mark阶段
要注意的一点是,如果在某轮mark中,已经认为是非活动对象的内存,在没有sweep之前,它一定不可能变成活动对象。
有个前提是如何找到堆中的活动对象。支持垃圾回收的语言需要实现一个类似所有活动对象的入口的一个东西,叫root,比如某全局变量。全部的活动对象都可以通过root出发来找到。这样,mark阶段大致是这样:
for(r : $roots) m(r)
对应的,m(r)必然是个递归操作:
m(r) { if (r.mark == false) r.mark = true for (child : children(r)) m(child) }
可见,mark过程是个对堆上所有活动对象进行深度遍历的操作,操作时间直接和活动对象数量相关。当然也能进行广度遍历,只是可能内存占用多些。
- sweep阶段
要考虑的是,sweep阶段后,回收的内存怎么组织?显然的方式是用freelist将回收的一块块内存串起来备用。那就有下面的逻辑:
sweep() { sweeping = $heap_start while (sweeping < $heap_end) if (sweeping.mark) sweeping.mark = false //回设 else //这是插入链表头,注意是头,即最后sweep的对象会在链表最前面; //next这字段,可以重用对象的字段,反正对象此时被回收,已经没用了。 sweeping.next = $free_list $free_list = sweeping sweeping += sweeping.size //size是存储对象的大小 }
另一方面,这些回收后的内存块怎么使用?
分配时,只需要遍历这个free_list,返回一个可用的内存块即可。如果遇到的比请求的大,可以将内存拆分开。
既然在分配时要将大内存拆开,那么其实在回收时,也可以将相邻的对象内存合并。
前面提到,刚刚sweep的对象内存是在freelist的最前面,那么,只需要下列逻辑,就可以判断是否连续内存并且合并:
if ($free_list.size + $free_list == &sweep) free_list.size += sweep.size
以上,便是朴素古典的mark-sweep过程。
优点:
- 实现简单
- 不需要移动对象
缺点:
- 分配速度:分配要遍历链表
- 碎片化:即可能堆上空闲内存总量是够的,但是,具体到某一个略大的分配请求,没有任一个内存块可以满足;并且,连续分配的有引用关系的对象可能位于内存中相隔较远的地方,不利于缓存命中。
- 破坏写时复制:mark过程中,对活动对象的内存都写了一次(在头内mark了)。如果是在具备写时复制的环境下,这会触发所有的写时复制。
- 时间:sweep过程,显然与堆大小相关,堆越大越慢。
针对上述缺点,引入以下改进思路:
- 分配速度:将free_list分割成按不同大小的一组free_list
- 碎片化:将堆组织成固定的区块,某一区内只分配固定大小的对象,这样堆内内存占用比较规整,但仍然无法避免碎片问题。
- 破坏写时复制:只需要将mark这一过程放到对象外。如将堆中划出一片内存来,单独做位图区。
- lazy-sweep:只在分配时进行sweep,一旦sweep到合适大小的对象,立即返回。如果sweep后也没有合适的对象,则转而进行mark。(这样也会有问题,后续讨论)