内存碎片化是非移动式回收器无法解决的问题之一,即:堆中仍有可用空间,但是内存管理器却无法找到一块连续内存块来满足较大对象的分配需求,或者需要花费较长时间才能找到合适的空闲内存。
堆整理的最大优势在于,它允许极为快速的顺序分配,即简单的进行堆上限判断,然后根据所需空间的大小阶跃式移动空闲指针。
标记——整理算法的执行需要经过数个阶段:首先是标记阶段;然后是整理阶段,即移动存活对象,同时更新存活对象中所有指向被移动对象的指针。在不同算法中,堆的遍历次数、整理过程所遵循的顺序、对象的迁移方式都有所不同。整理顺序会影响到程序的局部性。移动式回收器重排堆中对象时所遵循的顺序包括以下三种:
- 任意顺序:对象的移动方式与它们的原始排列顺序和引用关系无关;
- 线性顺序:将具有关联关系的对象排列在一起,如具有引用关系的对象,或者同一数据结构中的相邻对象;
- 滑动顺序:将对象滑动到堆的一端,“挤出”垃圾,从而保持对象在堆中原有的分配顺序。
我们所了解的整理式回收器大多遵循任意顺序或者滑动顺序。任意顺序整理实现简单,且执行速度快,特别是对于所有对象均大小相等情况。但任意顺序可能会将原本相邻对象分散到不同的高速缓存行或者虚拟内存页中,从而降低赋值器空间局部性。所有现代标记——整理回收器均使用滑动整理顺序,它不改变对象的相对排列顺序,因此不会影响赋值器局部性。复制式回收器甚至可用通过改变对象排布顺序的方式将对象与其父节点或者兄弟节点排列得更近,从而提升赋值器的局部性。
1. 整理算法存在的限制:
- 任意顺序算法只能处理单一大小的对象,或者只能对不同大小的对象分别进行整理;
- 整理过程需要两次甚至三次整堆遍历;
- 对象头部可能需要一个额外的槽来保存迁移信息,这对于通用内存管理器来说是一个显著的额外开销。
- 整理算法可能堆指针有特定限制,如指针的引用方向是什么?是否允许使用内部指针?
2.几种常见的整理算法:
- 双指针回收算法:
实现简单且执行速度快,但它打乱了堆中对象的原有布局。 - Lisp 2算法
该算法需要在每个对象头部增加一个额外的槽来保存转发地址,即对象移动的目标地址。 - 引线整理算法
该算法可以在不引入额外空间开销的情况下实现对象的滑动整理,但它需要两次堆遍历过程,且每次遍历的开销都很高。 - 单次遍历算法
该算法是一种现代的滑动回收算法,其执行速度快,且不需要在每个对象上引入额外的空间开销。该算法中的转发地址可以实时计算得出。
所有整理式回收算法的执行都遵从如下范式:
atomic collect():
markFromRoots()
compact()
2.1双指针整理算法
双指针整理算法属于任意顺序整理算法,其需要两次堆遍历过程,最佳适用场景为只包含固定大小对象的区域。该算法的原理为:对于某一区域中的待整理存活对象,回收器可以事先计算出该区域整理完成后存活对象的“高水位标记”(hign-water mark),地址大于该阈值的存活对象都将被移动到该阈值以下。
在算法的初始阶段,指针free指向区域初始端,指针scan指向区域末端。在第一次遍历过程中,回收器不断向前移动指针free,直到在堆中发现空隙(未标记对象)为止;类似的不断向后移动指针scan直到所指向的对象移动到指针free的为止,同时将原有对象中的某个域(指针scan所指向的)修改为转发地址,然后继续进行处理。上图描述了这一过程,其中对象A被移动到新的为止A’,且在对象A中的某个槽(即第一个槽)中记录了A’的地址。值得注意的是,该算法的整理质量取决于指针free所指向的空隙与指针scan所指向的存活对象大小的匹配程度。除非对象大小固定,否则碎片的整理程度一定很低。该阶段完成后,指针free将位于存活对象边界。回收器的第二次遍历过程回将指向存活对象边界之外的指针更新为其目标对象中所记录的转发地址,即对象的新位置。
双指针算法的优势在于简单快速,且每次遍历过程的操作较少。转发地址是在对象移动之后才写入的,所以不会存在任何信息的丢失,因此算法无需使用额外的空间来记录转发地址。
2.2Lisp 2算法
Lisp 2算法可用于管理包含多种大小对象的空间,尽管该算法需要三次堆遍历,但是每次遍历要做的工作都不多(只是相对而言,如与引线整理器相比)。Lisp 2算法的主要缺陷在于,它需要在每个对象头部额外增加一个完整的头域来记录转发地址(标记位也可以复用该域)。
三次堆遍历过程如下:
- 在标记阶段结束之后的第一次堆遍历过程中,回收器将会计算出每个存活对象的最终地址(即转发地址),并且将其保存在对象的forwardingAddress域中。computeLocations方法需要三个参数:堆中待整理区域的起始地址、结束地址、整理目标区域起始地址。目标区域通常与待整理区域相同,但并行回收器可能会为每个线程设定不同的来源和目标区域。用computeLocations方法在堆中移动两个指针:指针scan对来源区域中的所有(存活的或死亡的)对象进行迭代,指针free指向目标区域中的下一个空闲位置。如果指针scan遍历到的对象是存活的,意味着该对象(最终)会被移动到指针free所指向的位置。此时回收器将指针free写入对象的forwardingAddress域,然后根据对象的大小向前移动指针free(需要考虑对齐填充)。如果遍历到死亡对象,则将其忽略。
- 在第二次堆遍历过程(updateReferences方法)中,回收器将使用对象头域中记录的转发地址来更新赋值器线程根以及被标记对象中的引用,该操作将确保它们指向对象的新位置。
- 在第三次遍历过程中,relocate最终将每个存活对象移动到其新的目标位置。
三次遍历过程伪代码如下:
compact():
computeLocations(HeapStart,HeapEnd,HeapStart)
updateReferences(HeapStart,HeapEnd)
relocate(HeapStart,HeapEnd)
computeLocations(start,end,toRegion):
scan <- start
free <- toRegion
while scan<end
if <