【Java】JVM - 垃圾回收算法

461 阅读25分钟

垃圾回收的目的

  • 垃圾回收(GC)的目的在于回收垃圾对象所占用的内存空间供程序重复使用。

  • 垃圾回收算法的目的在于最大化垃圾回收的效益。

各种场景对于垃圾收集器的需求永远都是围绕这三个性能指标:内存占用(Footprint)、吞吐量(Throughput)和时延(Latency)。

  • 内存占用:小内存设备程序限制了内存的使用量,这些程序要求垃圾收集器占用更少的内存。

  • 吞吐量:非交互程序追求更高的 CPU 利用率,即更高的吞吐量;像科学运算服务、数据处理服务等。这些程序要求垃圾收集器对 CPU 的占用更低。

  • 时延:交互性程序追求更高的及时响应,即追求更低的延迟;像 Web 服务,微服务等,甚至有非常多限时的事务操作,如果垃圾收集需要停止用户服务的执行,无疑会带来非常大的影响。这些程序要求垃圾收集器所带来的阻塞延迟更短,甚至没有。随着网络应用的发展,存在时延需求的服务与日俱增,因此垃圾收集的方向也往这一边走。

这三者共同构成一个“不可能三角”,垃圾收集器永远无法同时让这三个性能到达极致。限制内存就意味着 GC 无法做出更多的操作,必定会降低程序的吞吐量和增加时延。在任务量固定的情况下,程序吞吐量的提高意味着垃圾收集器工作效率的降低,这必然会延长垃圾收集的工作时间,带来更大的时延。

不同场景对于不同垃圾收集器的需求不同,但是判断一款垃圾收集器能否正常工作的标准是一致的:内存回收的速度不得慢于内存分配的速度。一旦无法达到这个标准,内存中的垃圾必定会越来越多而导致 OOM。而 GC 也不能满足于仅仅让内存回收速度达到内存分配的平均速度,像 Web 应用突发的流量剧增将在一瞬间创建非常多的临时对象,此时如果没有采用合适的垃圾回收算法,内存会马上被撑爆。

什么样的对象是垃圾?

需要回收的对象就是不需要再使用到的对象,如果一个对象没有被其他任何对象引用到,那这个对象就不会再被使用到,即判定为垃圾。判断对象是否存活主要有引用计数法可达性分析算法

引用计数法

引用计数法(Reference Counting)会在对象中添加一个计数器,每当有一个地方引用它,计数器值就加一;当引用失效时,计数器值就减一。当计数器值为 0 时,就表明对象没有被引用到,可以宣告死亡。

引用计数法虽然简单直观,但是存在循环引用的问题。引用计数法必须配合大量额外的处理才能保证正确工作,使用引用计数法的有微软的 COM、FlashPlayer 和 Python 等,但依旧比较少见。

可达性分析算法

JVM 就是使用可达性分析算法来寻找存活对象的。可达性算法的思路是:从一个称为 GCRoot 的根对象结点集出发,根据引用关系向下搜索,遍历出整张对象图,搜索时走过的路径称为“引用链(Reference Chain)”。如果对象不可达就不会出现在图中,就证明对象已经死亡。

回收方法区?

JVM 规范没有要求一定要在方法区中实现垃圾收集,因为方法区垃圾收集性价比通常很低。方法区的垃圾收集主要回收废弃的常量和不再使用的类型。对于废弃的常量,如果判断当前系统没有一个字符串的值等于常量池中字符串对象的值,就可以回收这个常量。而对于废弃类的判断则更加严格:

  • 该类的所有实例对象都被回收。

  • 加载该类的类加载器已经被回收。这就表示类的命名空间被收回。

  • 该类的 java.lang.Class 实例没有被任何地方引用。这就表示无法通过反射找到该类。

满足这三个条件之后,虚拟机被允许可以对这些类型进行回收,但是要进行类型回收,需要设置 -Xnoclassgc 参数。在使用大量反射、动态代理、CGLib 等字节码框架、动态生成 JSP 以及 OSGi 这类频繁自定义类加载器的场景中,通常需要开启类型卸载功能。

可达性分析

可达性分析这一过程是标记存活对象的过程,在收集算法中称为“标记(Mark)”步骤。

根节点枚举

GCRoot 节点的枚举过程就称为根节点枚举。为了保证垃圾收集的准确性,根节点枚举必须在一个能保障一致性的快照中进行,所以所有垃圾收集器在根节点枚举这一步都必须暂停用户线程

暂停用户线程这一步使得用户程序无法执行,在用户视角仿佛程序被中断了一样,因此这一步称为“Stop The World(STW)

既然无法避免要 Stop The World,要减少根节点枚举的影响只能从减少停止时间这一点出发。

GCRoot 对象

能够作为根节点的对象肯定是我们需要使用到的对象,Java 中固定作为根节点的对象包括:

  • 栈帧中本地变量表中的对象,当前调用栈中用到的变量肯定不能清除掉。

  • 方法区中类静态属性引用的对象,由于普通 GC 不会回收方法区,因此方法区中的类成员变量自然不能被回收。

  • 方法区中常量引用的对象,像字符串常量池中的引用(这一个我不太明白,有空好好探究下)。

  • 本地方法栈中 Native 方法引用的对象,与栈帧同理。

  • JVM 内部的引用,JVM 的常驻对象(如常见对象对应的 Class 实例)和其他必须维护的对象。

  • 被同步锁(Synchronized)持有的对象。

除了固定根节点对象之外,根据垃圾收集算法的不同和需求,它们会额外选择将一些重要的对象添加到 GCRoot 中的。

普通对象指针

由于主流 JVM 都是使用准确式垃圾收集(即对象所属的类型和大小都是已知的),因此暂停时 JVM 是有办法直接得到哪些地方存在着对象引用的。普通对象指针(OOP)就是用于记录内存区域、寄存器或者栈中的对象引用的指针。

HotSpot 就采用 OopMap 来存储运行过程中存在对象引用的位置:在类加载动作完成时,HotSpot 就会把对象内什么偏移量上是什么数据类型的指针计算出来,在即时编译时,也会在特定位置记录下栈里和寄存器里哪些位置是引用。这样垃圾收集器就不需要一个不漏的进行扫描,直接使用 OopMap 就可以获取所有对象的位置。以此提高根节点枚举的速度。

虽然 OopMap 可以快速地完成根节点枚举,但这相当于是进行了预处理,将根节点枚举的消耗转移到了程序运行期间,会降低应用程序的吞吐量。而且,如果每一次对象引用发生变化都生成 OOP,这肯定会消耗非常大的内存,这就与垃圾收集的目的背道而驰了。

安全点

因为每一次引用发生变化都生成 OOP 是不可能的,所以 HotSpot 仅会在特定的位置记录这些引用,这些位置就叫做安全点(Safepoint),安全点与安全点之间引用如何变化都不关心,只关心处于安全点时对象引用的最终结果。

有了安全点的设定,为了减少根节点枚举的时间消耗,垃圾收集就只有在程序运行到安全点时才能执行了。所以,安全点的选定既不能太少以至于让垃圾收集器等待的时间过长,又不能太多而增加程序运行的负担。通常安全点是以“能否让程序长时间运行”为标准来选定的,最常见的就是指令序列的复用,如方法调用、循环跳转和异常跳转等。

同时,在进行 GC 时,怎么样让其他线程也停在安全点是个问题。这个问题有两个解决方案:

  1. 抢先式中断(Preemptive Suspension):在垃圾收集时,系统把所有用户线程全部中断,如果有线程没有在安全点上,就恢复该线程执行一段时间再中断,直到所有线程都处于安全点。

    这种方式影响十分巨大且效率低下,基本没有 JVM 使用这种方式来使线程进入安全点。

  2. 主动式中断(Voluntary Suspension):JVM 设置一个标志位,所有线程在执行过程中都会轮询此标志,一旦该标志为真则所有线程在自己最近的安全点主动中断挂起。轮询标志点和安全点是重合的,另外在所有需要在 Java 堆上分配内存的操作时都会轮询标志点,以检查是否需要执行垃圾收集,防止没有足够的内存分配对象。

    HotSpot 采用内存保护陷阱的方式实现主动式中断。在轮询处会执行 test 指令,该指令用于读取 0x160100 内存页,若 JVM 准备进行 GC 操作,会将该内存页设置为不可读,线程调用该指令会产生一个自陷异常信号,异常处理器就可以捕获该信号并挂起线程。

安全区域

安全区域(Safe Region)用于解决阻塞态线程无法进行进入安全点的问题,因为被阻塞的线程没办法进行主动式中断。

安全区域是指能够保证引用关系不会变化的执行区域,那么在这个区域中所有地方执行根节点枚举都是安全的,其实就是范围扩大了的安全点。当线程执行到安全区域的时候,需要标识自己处于安全区,根节点枚举就不需要考虑这个线程的执行状况。线程只有在收到可以离开安全区域的信号时才能离开安全区域。

事实上,我个人的思考是,在线程进入阻塞时,为它创建 OopMap 应该也可以吧?

并发标记算法

OopMap 使得根节点枚举的耗时变得非常短暂和固定,所以标记垃圾对象的主要消耗就在遍历对象图这个过程中了。

三色标记法

可达性分析算法属于搜索型算法,标记阶段通常采用深度优先搜索进行。通常使用三色标记法来遍历对象图,思路是将对象分为三种颜色:

  • 白色:未被遍历到的对象,可能是暂时没被遍历到的对象,也可能是无法遍历到的对象。

  • 灰色:已被遍历到的对象,但是成员变量未被完全遍历。

  • 黑色:自身包括所有成员变量都被遍历过的对象。

在可达性分析结束后,标记为白色的对象不在图中,就是不可达的对象。

一致性快照

标记算法要求和根节点枚举一样,要在一个能保证一致性的快照中进行。在图遍历过程中不会接入或者断开节点,标记就不会出错,最简单的做法就是暂停所有用户线程。但是标记算法的任务量非常大,需要遍历整个图结构,所以使用 Stop The World 的方式是不可能的。所以我们需要让标记算法与用户线程并行执行。

漏标多标问题

采用并发标记算法的话,在标记的过程因为存在着新增引用和断开引用的情况,会导致出现漏标多标的问题:

  • 漏标:也称为“对象消失问题”,在标记过程中黑色对象引用了白色对象,我们会误以为白色对象是不可达的。

  • 多标:在标记过程中,黑色对象断开对其他对象的引用,如果被断开对象被引用数为 0,我们会误以为对象是可达的。

多标只会让我们无法回收部分垃圾而且,漏标带来的问题就比较严重,有可能让我们正在使用中的对象被回收。Wilson 于 1994 年在理论上证明,漏标必须满足这两个步骤:

  1. 黑色对象引用了该白色对象。

  2. 灰色对象到该白色对象的引用全部被断开。

只有按顺序执行这两个步骤才会出现漏标的情况,如果 2 先执行了,那就没有任何对象能够访问白色对象了。所以如果我们想避免漏标问题,只需要破坏其中一个步骤就可以了。

增量更新与原始快照

由于破坏两个步骤中的一个就可以解决漏标问题,所以出现了两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning,SATB)。

  • 增量更新:破坏步骤一,黑色对象如果引用了白色对象,就在并发标记结束后将该黑色对象或白色对象变为灰色对象重新扫描。因为这种方式相当于在增加引用时将新对象加入到图中,所以称为增量更新。

  • 原始快照:破坏步骤二,如果灰色对象要断开与白色对象的引用,就在并发标记结束后将白色对象加入到 GCRoot 中重新扫描。这样做比较简单直接,但是可能会造成以这个白色对象为根节点的浮动垃圾。因为这种方式相当于保留了灰色对象被扫描时的状态,所以称为原始快照。

为了保证标记算法能够在一个一致性的快照中进行,增量更新和原始快照第二轮重新标记的过程必须要暂停用户线程,否则永远无法保证一致性。但是这个 Stop The World 的时间已经非常短了,不会对用户程序造成太大的影响。

增量更新是在增加引用时进行操作,原始快照是在断开引用时操作。对于并发标记期间新增的对象,增量更新会一视同仁直接记录起来。但是原始快照不会,原始快照需要专门去标识新创建的对象。但如果既在引用断开进行额外操作,又在增加引用时进行额外操作,这样的消耗就太高了。通常的解决办法是,专门划分一块内存区域用于分配并发标记时新创建的对象。

由于程序运行时引用增加的操作比引用断开更为频繁,因此在并发标记期间增量更新的消耗比原始快照大。同时增量更新重新标记阶段会连同新加入对象一起遍历,重新标记阶段消耗也比原始快照大。但是由于不存在浮动垃圾的问题和不需要单独为新对象划分区域,因此内存占用比原始快照要少。

写屏障与读屏障

读屏障(Read Barrier)和写屏障(Write Barrier)指的是程序从堆中读取引用或者更新引用时执行的额外操作,相当于执行读/写引用时加上了一个环形切面。在更新引用前执行称为写前屏障(Pre-write Barrier),在更新引用后执行称为写后屏障(Post-write Barrier),如伪代码所示:

void oop_field_store(oop* field, oop new_value) {
    pre_write_barrier(field, new_value);  // 写前屏障
    *field = new_value;
    post_write_barrier(field, new_value); // 写后屏障
}

GC 就是通过写屏障实现增量更新与原始快照的。读屏障的使用非常少,因为读操作是程序无时无刻不在进行的操作,增加读屏障对程序吞吐量的消耗非常高,所以使用读屏障时需要进行非常谨慎的考量。

分代收集理论

分代收集理论,虽名为理论,但是是一套符合多数程序的经验法则:

  1. 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。

  2. 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集的对象越难以消亡。

    基于弱分代假说和强分代假说,内存区域通常被分为新生代和老年代两个区域。在垃圾收集时不需要一次性收集所有内存区域,在新生代满了的情况下对新生代进行垃圾收集,在老年代满了的情况下对老年代进行垃圾收集,以此减少单次垃圾收集的工作量。

    新创建的对象显然应该放在新生代里,因此新生代的垃圾收集会比老年代更加频繁,同时只有能够熬过很多次新生代垃圾收集的对象才会晋升到老年代。结合新生代和老年代的特点,我们可以分别选择不同的算法,使垃圾收集的效益最大化。

  3. 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。

    跨代引用指强弱分代之间的对象可能会存在相互引用。因此在收集某个分代的时候不得不将另一个分代的对象全部加入到 GCRoot 中去,老年代的收集还好,新生代的收集十分频繁,这将为垃圾收集工作带来极大的负担。事实上不止划分新老年代的垃圾收集算法会出现此问题,所有涉及部分收集的算法都将存在此问题,不过他们称之为跨区引用

    跨代引用假说基于弱分代假说和强分代假说推导而来:存在引用关系的两个对象,更倾向于同时存活和同时灭亡;若老年代对象引用新生代对象,那这个新生代对象必然会在多次垃圾收集之后晋升为老年代对象。

    因此,我们不必为了极少量的跨代引用而将整个旧分代中的对象加入到 GCRoot 中,为了保证可达性分析的准确性,我们只需要将存在跨代引用的对象加入到 GCRoot 中就行。所以我们需要将存在跨代引用对象记录起来,我们将这种记录的数据结构称为“记忆集”(Remembered Set)。这是典型的空间换时间的优化方法。

分代收集方式

对于采用分代收集机制的垃圾收集器,由于对不同区域的收集方式不同,因此命名方式也不同:

  • 整堆收集(Full GC)

  • 部分收集(Partial GC)

    • 新生代收集(Minor GC/Young GC)

    • 老年代收集(Major GC/Old GC),有部分虚拟机将整堆收集也称为 Major GC,因此请注意分辨。

    • 混合收集(Mixed GC):收集整个新生代和部分老年代内存。

分代收集的本质也是优化算法,对收集效益更高的区域进行更多次的收集,针对对不同区域选择适合的回收算法,这都能极大地提高 GC 的效益。因此采用新旧分代设计可以轻易达到“内存回收的速度不慢于内存分配的速度”这一标准。但也有垃圾收集器目前没有使用该算法,不是说该算法不好,只是该算法实现起来难度比较大。

分区内存布局

通过基于 Region 的堆内存布局,将 Java 堆分为多个 Region,可以实现每次 GC 都只回收一部分内存区域。

Region 的划分有这几种方式:

  1. 按分区大小划分可以划分为大 Region、中 Region 和 小 Reigon,分别用于存储大对象、中对象和小对象。由于大对象的复制是非常耗时的,采用分代操作通常要将对象在不同分代中间转移,消耗非常大,划分大对象 Region 可以有效解决这个问题。

  2. 按分代划分可以划分为新生代 Region、老年代 Region。这样可以结合分区与分代的优势,精确地对热点地区进行收集而不会对程序造成太大影响。

  3. 按存储对象大小划分同样可以划分出大对象 Region、中对象 Region 和小对象 Region,虽然 Region 的大小一致,但对象的存放依旧可以按大小区分。

按区域拆分就会带来跨区引用的问题,此时同样是使用记忆集来记录跨区引用。

记忆集与卡表

前面说过,记忆集(Remembered Set)是用于存储跨代引用或跨区引用的数据结构。但记忆集并不是单纯地记录存在跨代引用的对象,它的记录粒度由虚拟机实现自行决定,通常有以下三个粒度:

  • 字长精度:每个记录精确到该跨代引用的内存地址,由机器字长决定。

  • 对象精度:每个记录精确到一个对象,表示该对象的某个数据域存在跨代引用。

  • 卡精度:每个记录精确到一片内存区域,表示该区域内有对象存在跨代引用,基于该精度实现的记忆集称为卡表(Card Table)。每个内存块被称为“卡页”(Card Page)

    也就是说,卡表是记忆集的一种实现形式,最简单的实现可以设置为一个字节数组,卡页大小的设置肯定是以 2 的幂方设置,这样可以使用位运算直接计算跨区指针所在的卡页位置,如:

    CARD_TABLE[addr >> 9] = 0;
    

    这样卡页的大小就是 2 ^ 9 = 512Byte,数组元素为 0 表示不存在跨代引用,根据索引偏移量可以算出卡页的地址。而且 byte 最大是 256,如果卡页内的跨代引用数量在 256 个以内,卡表就可以记录跨代引用的数量。

对于分代收集的内存管理方式,记忆集中通常仅记录老年代对新对象的引用关系,并不会记录新生代对象对旧对象的引用关系。因为 Minor GC 的发生次数远远多于 Major GC 的发生次数,因此借助记忆集来加快新生代收集过程是意义重大的;而在 Major GC 时将所有新生代存活对象加入 GC Root 进行遍历并不会对程序总体性能带来太大影响;而且新生代中的对象朝生夕灭,引用关系变更非常频繁,记录新生代对象对旧对象的引用关系将花费非常多的内存,收益性价比非常低。

伪共享问题

HotSpot 通过写屏障技术来进行卡表页状态的维护,除了写屏障会带来的开销之外,高并发场景下卡表还面临着伪共享(False Sharing)的问题:CPU 的缓存系统以缓存行为单位,当多线程修改处于同一缓存行的变量时,需要进行额外的操作(写回、无效化或同步)而导致性能降低。如果多个线程写屏障同时更新卡表上同一个卡表页时,就会导致写入同一个缓存行而降低性能。通常采用非无条件的写屏障来解决伪共享问题,思路是先检查卡表页是不是脏页,再决定是否更新。

if (CARD_TABLE[addr >> 9] != 0) // 如果是脏页,就修改为非脏页
    CARD_TABLE[addr >> 9] = 0;

JDK7 之后 HotSpot 开放 -XX:UseCondCardMark 参数用于控制是否开启卡表更新条件判断,开启了就能解决伪共享问题,但是会有判断的性能损耗。

垃圾回收算法

标记-清除算法

标记-清除算法分为标记和清除两个阶段。首先需要标记所有需要回收的对象,标记完成之后,统一回收掉所有被标记的对象。也可以反过来,标记存活的对象,再回收无标记的对象。

此方法的特点是简单直接,但有两个主要缺点:

  1. 执行效率不稳定,标记和清除两个阶段的时间随对象的数量增长而增长。

  2. 内存空间碎片化问题,标记清除会产生大量不连续的内存碎片,内存碎片过多会导致程序在需要分配大内存对象时无法找到足够的连续内存,导致频繁触发垃圾收集。

标记-复制算法

1969 年由 Fenichel 提出的半区复制算法(Semispace Copying),用于解决标记-清除算法中面对大量可回收对象执行效率低和内存空间碎片问题。它将内存划分为 1:1 的两个空间,每次只使用其中的一块,使用的那块叫做“活动空间”。当活动空间内存达到一定阈值就会将存活的对象复制到空闲空间中去,并清空原来的活动空间,这样交替使用这两块内存区域。这种“先标记”、“再复制”、“再清除”的算法我们称为标记-复制算法

绝大多数基于分代的垃圾收集器都会使用标记-复制算法收集新生代,因为这个方法十分简单高效,在标记阶段完成复制,复制之后不存在内存碎片问题,同时不需要逐个对象判断是否需要清除,直接释放整个内存空间,十分高效。但是它的问题就是内存浪费很严重。同时,如果存活的对象越多,它的效率就越低。

1989 年,基于弱分代假说,Andrew Appel 提出一种更优化的半区复制策略,称为“Appel 式回收”:将新生代分为一块较大的伊甸区(Eden)和两块较小的幸存区(Survivor);每次仅使用伊甸区和一块幸存区;发生垃圾收集时,将伊甸区和幸存区中存活的对象复制到另一块空闲的幸存区上,并直接释放其他两块区域的内存;不断交替使用这两块幸存区来存储存活对象。

HotSpot 中伊甸区和幸存区的比例为 8:1,这就表示只浪费了 10% 的新生代内存。虽然绝大多数对象都是朝生夕灭的,但是由于墨菲定律,幸存区内存不足的事情是肯定会发生的。所以 Appel 式回收设置了一个“逃生门”策略,当 Minor GC 时幸存区内存不够时,会使用其他内存区域进行分配担保(Handle Promotion),一般是将多出来的对象直接送进老年代中。

标记-整理算法

1974 年 Edward Lueders 提出标记-整理算法:标记阶段标记存活的对象或者死亡的对象,然后将所有存活的对象向内存空间的一端移动,移动完就清理边界外的内存空间。

整理算法与清除算法的本质区别是,前者是移动式的回收算法,后者是非移动式的。对于老年代而言,移动存活对象并更新所有该对象的引用变量是一项负担极重的操作,同时在不使用其他技术辅助的情况下, 这个移动操作必须全程暂停用户程序才能进行,因此该操作被称为“Stop The World(STW)”。非移动方式的标记-清除算法虽然停顿时间更短,但内存分配时只能通过更复杂的内存分配器和内存访问器来解决,但因为内存访问是程序最频繁的操作,所以使用标记-清除算法会直接降低程序的吞吐量。

虚拟机根据各自的特性选择使用标记-整理算法和标记-清除算法回收老年代内存。Parallel Scavenge 关注应用程序吞吐量,因此采用标记-整理法。CMS 关注时延,因此采用标记-清除法,但在内存空间碎片化程度开始影响对象分配时,CMS 将采用标记-整理法收集一次,这意味着还是会带来 STW。