垃圾回收算法看这一篇就够了

3,093 阅读15分钟

简介

本文介绍了常见的三种垃圾回收算法(mark-sweep,mark-compact,mark-copy),是java虚拟机各种垃圾收集器的算法基础,为之后学习hotspot虚拟机的垃圾收集器打下基础

垃圾收集器解决的问题

1 开发者可能过早的回收依然在引用的对象,这种情况将引发悬挂指针问题。

2 开发者可能在程序将对象使用完毕之后未将对象释放,从而导致内存泄漏;

垃圾收集的好处

1 不会有二次释放的问题;

2 降低了程序的耦合度,开发者只需关注自身模块,或只关注其他相关模块的少量代码,显示的内存管理无法满足软件工程的低耦合的原则,需要引入一些额外的接口。

3 完整性与及时性

理想情况下,垃圾收集过程应当是完整的,即堆中的所有垃圾都应当得到回收,但这通常是不现实的,从性能方面考虑,再一次回收过程中只处理堆中部分对象或许更加合理,例如分代回收器会依照堆中的年龄将其划分为两代或者更多代,并把经历集中在年轻代,这样不仅提高了回收效率,也可以减少单次回收的停顿时间;在并发垃圾回收器中,赋值器与回收器同时工作,其目的在于避免或者尽量减少用户程序的停顿,此类回收器会遇到浮动垃圾的问题,即对象在回收过程启动后才变成垃圾,那么该对象只能在下一个回收周期内得到回收,因此在并发回收器中,衡量完整性更好的方法是统计所有垃圾的最终回收情况,而不是单个回收周期的回收情况。

4停顿时间

许多回收器在进行垃圾回收时需要终端赋值器线程,因此会导致在线程执行过程中停顿,回收器应当尽量减少对程序的执行过程的影响,因此停顿时间越短越好,例如分代式回收器通过频繁且快速的回收较小的,较为年轻的对象来缩短停顿时间,而较大的,较为年老的对象回收则只是偶尔进行(真是个喜欢年轻对象的渣男)。

5 空间开销

内存管理的目的是安全且高效的使用内存空间,不论是显示的内存管理还是自动的内存管理,不同的管理策略均会产生不同程度的空间开销,某些垃圾收集器需要在每个对象内部占用一定的空间(例如保存引用计数),还有些收集器会服用 对象现有的布局上已经存在的域(转发指针记录在用户数据上)。回收器也可能引入堆级别的内存开销,比如copying式回收器需要将对分为两个半区,任何时候赋值器只能使用一个半区,另一个半区会被回收器所保留,并在回收过程中将存活对象复制到其中;回收器有时也需要一些辅助的数据结构,比如追踪式的回收器要通过标记栈来引导堆中指针图表的遍历,也就是通常所说的根节点枚举,根据gc root set遍历全堆,回收器标记对象时也可以使用额外的位图(bitmap),对于一些需要将堆分为数个独立区域的回收器,需要额外的记忆集来保存赋值器所修改的指针和跨区域的指针的位置。

4种垃圾收集策略

标记-清扫(mark-sweep)、标记-复制(mark-copy)、标记-整理(mark-compact),引用计数时4种最基本的垃圾回收策略,大多数回收器会以不同的方式对这些基本回收策略进行组合

1 标记-清扫(sweep)

标记-清扫算法与赋值器的接口十分简单:如果线程无法分配对象,则唤起收集器,然后再次尝试分配

回收器在遍历对象图之前必须先构造标记过程需要用到的起始工作列表(gc root set),即对每个根对象进行标记并将其加入工作列表,回收器可以通过标记对象头的某个位(或者字节)的方式对其进行标记,该位(字节)也可位于一张额外的表中

需要注意的是:标记-清扫回收器要求堆布局满足一定的条件:

  • mark-sweep回收器不会移动对象,因此内存管理器必须能够控制堆内存碎片,因为过多的内存碎片可能会导致分配器无法满足新分配的请求,从而增加垃圾回收的频率
  • 清扫器必须能够遍历堆中每一个对象;

对sweep回收器的优化

1 位图标记

回收器可以将对象标记位保存在器头部的某个字中,也可以使用一个独立的位图来维护标记位,位图可以有一个,也可以存在多个,比如在块结构的堆中,回收器可以为每个内存块维护一个独立的位图,这一方式可以避免由于堆不连续导致的内存浪费。

使用位图标记可以减少回收过程中的换页次数,任何由回收器导致的换页行为通常都是不可接受的,对象往往成簇诞生并成批死亡,而许多分配器也会吧这些对象分配在相邻的空间。使用位图来引导清扫可以批量读取/清空一批对象的标记位,而且通过位图标记可以简单的判断某一内存块中的所有对象是否都是垃圾,进而可以一次性回收整个内存块

对于使用标记位放置在对象头部这一策略,位图可以使得标记位更加密集;对于使用sweep的回收器,标记过程只需读取存活对象的指针域而不会修改任何对象;清扫器不会对存活对象进行任何的读写操作,只会在释放垃圾对象的过程中覆盖某些域,因此位图可以减少内存中需要修改的字节数,而且减少了对高速缓存的写入

2 懒惰清扫

标记过程的时间复杂度是O(L),其中L是堆中存活对象的数量,清扫过程的时间复杂度是O(H),H为堆大小,虽然H>L,但mark过程的内存访问时不可预测的,而清扫过程的可预测性就要高的多,而且清扫对象的开销也比追踪对象的开销小的多,mark-sweep算法中通常会将大小相同的对象分布在连续的空间内,此时回收器可以依照固定的步幅对大小相同的对象进行清扫。

Lazy sweeping 该方案利用分配器来扮演清扫器的角色,即把寻找可用空间的任务转移到allocate过程中,从而不需要单独的清扫阶段,最简单的清扫策略是,allocate简单的向前移动清扫指针,直到在连续的未标记对象中找到一块足够大的空间

分配器通常只会在一个内存块中分配相同大小的对象,每个空间大小分级都会对应一个或多个用于分配的内存块,以及一个待回收内存块链表,在回收过程中,回收器依然需要将堆中所有存活对象标记,但标记或回收器不急于清扫整个堆,而是简单的将完全为空的内存块归还给块分配器,同时将其他内存块添加到其所对应空间大小分级的队列中,一旦stop the world结束,赋值器立刻开始工作,对于任意内存的分配需求,allocate方法首先尝试从合适的空间大小分级中分配一个空闲槽,如果失败则调用清扫器执行懒惰清扫,即从该空间大小分级的回收队列中取出一个或多个内存块进行清扫,知道满足分配要求为止,如果没有内存块可供清扫,或者清扫的内存块不包含热河空闲槽,分配器便尝试从更低级别的块分配器中获取新内存块

mark-sweep算法需要考虑的问题

1 吞吐量

mark-sweep式回收器在执行过程中需要刮起所有赋值器线程,回收停顿时间取决于应用程序的运行及其输入

2 空间利用率

与mark-copy相比,mark-sweep具有更高的空间利用率,但sweep回收会产生一些空间碎片,cms的做法是可以隔一段时间采用compact算法整理一次空间碎片

3 是否移动对象

sweep最大的优势就是不会移动对象,

分代回收中,年老代被GC时对象存活率可能会很高,而且假定可用剩余空间不太多,这样copying算法就不太合适,于是更可能选用另两种算法,特别是不用移动对象的mark-sweep算法。

不过HotSpot VM中除了CMS之外的其它收集器都是会移动对象的,也就是要么是copying、要么是mark-compact的变种。

标记-整理(mark-compact)

为了解决sweep中的内存碎片问题,需要对堆中存活对象进行整理以降低内存碎片的回收策略,compact可以极为快速的顺序分配

Mark-compact算法执行要经过数个阶段:首先是标记阶段,然后是整理阶段,即移动存活对象,同时更新存活对象中执行被移动对象对象的指针,有一线三种compact顺序

  • 任意顺序:对象的移动方式与它们的原始排列顺序和饮用关系无关
  • 线性顺序:将具有关联关系的对象排列在一起,如具有引用关系的对象,或者统一数据结构中的相邻对象
  • 滑动顺序: 将对象滑动到堆的一顿啊,挤出垃圾,从而保证对象在堆中的原有分配顺序

我们所了解的整理式回收器大多遵循任意顺序和滑动顺序

任意顺序实现简单,且执行速度快,特别是所有对象大小相等的情况,但任意顺序整理可能会将原来相邻的对象分散到不同的高速缓存行或者虚拟内存页中,从而降低赋值器空间局部性。所以现代compact回收器均使用滑动整理顺序

下面介绍几种不同类型的整理算法

1 双指针整理算法

起始阶段,指针free指向区域始端,指针scan指向区域末端,在第一次遍历过程中,回收器不断向后移动指针free,直到在堆中发现空隙为止;指针scan从后往前移动,知道发现存活对象,如果两个指针交错,则该阶段结束,否则便将指针scan所指向的对象移动到指针free的位置,同时将原对象中的某个域修改为转发地址,然后继续移动指针。双指针的有点事简单快速,且遍历的过程操作较少,但确定是双指针重排序对象的顺序是任意式的,因此破坏了赋值器的局部性,然后由于相关对象总是成簇诞生,成批死亡,我们可以将连续存活对象整体移动到较大空隙中,而不是逐个移动

lisp2算法

该算法要经过三次遍历,但每次遍历做的工作不多;

在标记结束的第一次遍历中,回收器计算出每个存活对象的最终地址,并保存在对象的forwardingAddres域中,

第二次遍历过程中,回收器将使用对象头域中记录转发地址来更新赋值器线程根以及被标记对象的引用,该操作将确保他们指向对象的新位置

第三次遍历过程中,relocate最终将每个存活对象移动到新的目标位置

需要考虑的问题

吞吐量

与标记mark-sweep相比,mark-compact算法需要更多次遍历,因此吞吐量较差,每次遍历的开销都很大,一个通用的解决方案是,尽量长久的使用mark-sweep算法,在碎片化达到一定程度后,才使用mark-compact算法回收一次

长寿数据

Mark-compact算法可以选择不去整理“沉积区”内的对象,付出的代价是存在少量的内存碎片

局部性

采用不同的compact算法会得到不同的局部性,在虽然随机算法简单高效,但会破坏赋值器的局部性,所以滑动式的整理算法对局部性更友好

标记-复制(mark-copy)

sweep回收的开销较低,但其存在内存碎片的问题,在一个良好的系统中,垃圾回收通常只占整体执行时间的一小部分,赋值器的执行开销将决定整个程序的性能,因此应设法降低赋值器的开销,特别是尽量提升它的分配速度,compact可以消除内存碎片,但需要多次遍历,copy算法回收器在复制过程中进行堆整理,从而提升了赋值器的分配速度,但是堆的可用空间降低了一半

需要考虑的问题

分配

经过整理的堆分配内存速度很快,分配过程简单;

空间与局部性

copy算法的确定就是需要维护第二个半区,在内存大小一定,半区复制算法的可用空间是整堆回收的一般,这导致复制式的回收器所需的回收次数要比其他回收器更多;

而局部性首遍历方式所影响广度优先遍历顺序有将父子节点分开的趋势,而深度优先遍历则趋向于子节点预期赋节点排列的更近

移动对象

是否使用复制式取决于移动对象的开销,在分代式的回收器中,老年代存活数量多,并且有大对象,不适合使用copy算法,单年轻代存活数量少,且对象比较小,适合使用mark-copy

三种算法对比

分代式GC里,年老代常用mark-sweep;或者是mark-sweep/mark-compact的混合方式,一般情况下用mark-sweep,统计估算碎片量达到一定程度时用mark-compact。这是因为传统上大家认为年老代的对象可能会长时间存活且存活率高,或者是比较大,这样拷贝起来不划算,还不如采用就地收集的方式。Mark-sweep、mark-compact、copying这三种基本算法里,只有mark-sweep是不移动对象(也就是不用拷贝)的,所以选用mark-sweep(cms)。

简要对比三种基本算法:

mark-sweep mark-compact copying
速度 中等 最慢 最快
空间开销 少(但会堆积碎片) 少(不堆积碎片) 通常需要活对象的2倍大小(不堆积碎片)
移动对象?

关于时间开销: mark-sweep:mark阶段与活对象的数量成正比,sweep阶段与整堆大小成正比 mark-compact:mark阶段与活对象的数量成正比,compact阶段与活对象的大小成正比 copying:与活对象大小成正比

如果把mark、sweep、compact、copying这几种动作的耗时放在一起看,大致有这样的关系: compaction >= copying > marking > sweeping 还有 marking + sweeping > copying (虽然compactiont与copying都涉及移动对象,但取决于具体算法,compact可能要先计算一次对象的目标地址,然后修正指针,然后再移动对象;copying则可以把这几件事情合为一体来做,所以可以快一些。 另外还需要留意GC带来的开销不能只看collector的耗时,还得看allocator一侧的。如果能保证内存没碎片,分配就可以用pointer bumping方式,只有挪一个指针就完成了分配,非常快;而如果内存有碎片就得用freelist之类的方式管理,分配速度通常会慢一些。)

在分代式假设中,年轻代中的对象在minor GC时的存活率应该很低,这样用copying算法就是最合算的,因为其时间开销与活对象的大小成正比,如果没多少活对象,它就非常快;而且young gen本身应该比较小,就算需要2倍空间也只会浪费不太多的空间。 而年老代被GC时对象存活率可能会很高,而且假定可用剩余空间不太多,这样copying算法就不太合适,于是更可能选用另两种算法,特别是不用移动对象的mark-sweep算法。

不过HotSpot VM中除了CMS之外的其它收集器都是会移动对象的,也就是要么是copying、要么是mark-compact的变种。

这一篇写了好久,参考了一些垃圾算法的数据和文献,下一篇想写hotspot的垃圾收集的细节,和hotspot中的垃圾数据器的优缺点以及查看gc日志,优化垃圾收集时代码中的注意点。