以下是标记算法的伪码:
markingDeque = [] overflow = false def markHeap(): for root in roots: mark(root) do: if overflow: overflow = false refillMarkingDeque() while !markingDeque.isEmpty(): obj = markingDeque.pop() setMarkBits(obj, BLACK) for neighbor in neighbors(obj): mark(neighbor) while overflow def mark(obj): if markBits(obj) == WHITE: setMarkBits(obj, GREY) if markingDeque.isFull(): overflow = true else: markingDeque.push(obj) def refillMarkingDeque(): for each obj on heap: if markBits(obj) == GREY: markingDeque.push(obj) if markingDeque.isFull(): overflow = true return
标记算法结束时,所有的活跃对象都被染为了黑色,而所有的死对象则仍是白的。这一结果正是清理和紧缩两个阶段所期望的。
标记算法执行完毕后,我们可以选择清理或是紧缩,这两个算法都可以收回内存,而且两者都作用于页级(注意,V8的内存页是1MB的连续内存块,与虚拟内存页不同)。
清理算法扫描连续存放的死对象,将其变为空闲空间,并将其添加到空闲内存链表中。每一页都包含数个空闲内存链表,其分别代表小内存区(<256字)、中内存区(<2048字)、大内存区(<16384字)和超大内存区(其它更大的内存)。清理算法非常简单,只需遍历页的位图,搜索连续的白对象。空闲内存链表大量被scavenge算法用于分配存活下来的活跃对象,但也被紧缩算法用于移动对象。有些类型的对象只能被分配在老生区,因此空闲内存链表也被它们使用。
紧缩算法会尝试将对象从碎片页(包含大量小空闲内存的页)中迁移整合在一起,来释放内存。这些对象会被迁移到另外的页上,因此也可能会新分配一些页。而迁出后的碎片页就可以返还给操作系统了。迁移整合的过程非常复杂,因此我只提及一些细节而不全面讲解。大概过程是这样的。对目标碎片页中的每个活跃对象,在空闲内存链表中分配一块其它页的区域,将该对象复制至新页,并在碎片页中的该对象上写上转发地址。迁出过程中,对象中的旧地址会被记录下来,这样在迁出结束后V8会遍历它所记录的地址,将其更新为新的地址。由于标记过程中也记录了不同页之间的指针,此时也会更新这些指针的指向。注意,如果一个页非常“活跃”,比如其中有过多需要记录的指针,则地址记录会跳过它,等到下一轮垃圾回收再进行处理。
增量标记与惰性清理
你应该想到了,当一个堆很大而且有很多活跃对象时,标记-清除和标记-紧缩算法会执行的很慢。起初我研究V8时,垃圾回收所引发的500-1000毫秒的停顿并不少见。这种情况显然很难接受,即使是对于移动设备。