JVM系列之垃圾回收器(下篇)——Shenandoah垃圾回收器

1. 前言

虽然目前大部分系统使用的是 JDK8,使用的垃圾回收器也大概率为 G1 或者更古老的垃圾回收器,但是截止到目前为止,JDK 已经更新到 JDK14 了,垃圾回收器也几乎在每一次迭代中被更新,目前最前沿的垃圾回收器为 Shenandoah 和 ZGC,这两款垃圾回收器都是以低延时为主要目的。

由于内容较多,本文先只介绍 Shenandoah,下一篇再介绍 ZGC。

2. Shenandoah 概述

Shenandoah 的目标是将垃圾回收的停顿时间控制在 10ms 以内,这意味着 Shenandoah 不仅需要在并发标记阶段实现并发,还需要在标记清除阶段实现并发。

2.1 题外话

Shenandoah 垃圾回收器是 RedHat 公司发明的,非 Oracle 公司官方实现,不是 Oracle 的亲儿子,因此在一定程度上遭到了“排挤”,只在开源的 OpenJDK12 中开始出现,而在商业版的 Oracle JDK12 中则没有。

2.2 与 G1 的异同点

Shenandoah 与 G1 有很多相同点,都采用了基于 Region 的内存布局,在标记阶段均采用了并发标记。事实上,Shenandoah 在代码实现上使用了很多 G1 的代码,因此 Shenandoah 有很多特点和 G1 是一样的。

另外,Shenandoah 在 G1 的基础上做了很多改变,至少存在下面 3 处改进。

  1. 在最终的回收阶段,采用的是并发整理,由于和用户线程并发执行,因此这一过程不会造成 STW,这大大缩短了整个垃圾回收过程中系统暂停的时间。

  2. 默认情况下不使用分代收集,也就是 Shenandoah 不会专门设计新生代和老年代,因为 Shenandoah 认为对对象分代的优先级并不高,不是非常有必要实现。(至于不实现分代,对 Shenandoah 性能能带来什么好处,笔者也不是很清楚,猜测原因可能是,不进行分代可能在设计上更简单吧。毕竟 Shenandoah 是 RedHat 公司设计实现的,不是 Oracle 的官方团队,他们从零开始设计,工作量巨大)

  3. 采用“连接矩阵”代替记忆集。在 G1 以及其他经典垃圾回收器中均采用了记忆集来实现跨分区或者跨代引用的问题,每个 Region 中都维护了一个记忆集,浪费了很多内存,且导致系统负载也更重,「因此在 Shenandoah 中摒弃了这种实现方式,而是采用连接矩阵来解决跨分区引用的问题」

    连接矩阵可以理解为一个二维数组,当 Region N 的对象引用了 Region M 中的对象,那么就将二维数组 array[N][m]设置一个标志位。

3. 工作流程

Shenandoah 的工作流程大致可以分为初始标记、并发标记、最终标记、并发清理、并发回收、引用更新、并发清理这几个步骤,其中引用更新还可以细分为初始引用更新、并发引用更新、最终引用更新三个小步骤。

  1. 「初始标记、并发标记、最终标记」这三个步骤和 G1 一样。初始标记标记的是和 GC Roots 直接相关联的对象,会造成 STW,停顿的时间长短与 GC Roots 的数量成正比。并发标记阶段是垃圾回收线程和用户线程并发执行,不会造成 STW,这一步需要遍历整个对象图,耗时较长。最终标记阶段是对并发标记阶段进行修正,处理那些因为用户线程同时运行导致引用关系改变的对象,这一步需要暂停用户线程,会造成 STW,但暂停时间不会太长。

  2. 「并发清理」。这一步和 G1 就不同了,G1 中是多个 GC 线程并行清理,而 Shenandoah 中是并发清理,GC 线程和用户线程并发执行,不会造成 STW。而且这一步清理仅仅只是清理一个存活对象都没有的 Region(也就是说 Region 中的对象都是垃圾)。

  3. 「并发回收」。将回收集中,所有存活的对象复制到空闲的 Region 中,这一步也是并发执行,不会造成 STW,执行时间的长短与回收集的大小以及存活对象的数量相关。并发回收是 Shenandoah 与其他垃圾回收器相比,最大的不同之处了。Shenandoah 在回收时使用的是复制算法,而复制算法的特点是:在移动完存活对象后,还需要修改所有指向这些存活对象的引用指向,而这个过程很难一瞬间就改变过来。由于是并发执行,用户线程也在运行,当我们将存活对象移动到新的 Region 中时,如果引用指向还没有修改为最新的对象地址,那就可能导致程序出错。Shenandoah 为了实现并发回收,采用了 「Brooks Pointers」 转发指针来解决该问题。

  4. 「引用更新」。该阶段就是让堆中所有指向旧对象的地址修正为指向新地址,具体可以细分为 3 个小阶段。在「初始引用更新」阶段,实际上什么也没干,它就是为了提供一个线程的集合点,确保所有的垃圾回收线程都完成了复制对象到新 Region 的任务。在「并发引用更新」阶段是真正的更新引用,该过程不需要遍历整个对象图,只需要按照内存的物理地址顺序,线性地搜索出引用类型,然后更新为新地址,这一步是和用户线程一起并发执行。「最终引用更新」指的是更新 GC Roots 中的指向旧地址的对象到新地址。

  5. 「并发清理」。将回收集中所有的 Region 清除,该过程和用户线程并发执行,不会产生 STW。

从 Shenandoah 的工作流程来看,大部分阶段都是并发执行,仅有初始标记和最终标记会造成 STW,并且这两个阶段停顿的时间都十分短暂,因此 Shenandoah 在进行垃圾回收时造成的系统延时非常低,确实是一款以低延时为目标的垃圾回收器。

4. Brooks Pointer 转发指针

在传统的对象布局中,每个对象由对象头和对象数据组成,而在 Shenandoah 中改变了对象的布局,它为每个对象在对象头之前添加了一个 Brooks Pointer 转发指针,「默认情况下,这个转发指针指向自己」,当用户线程访问对象时,每次先访问的是转发指针,然后通过转发指针再去访问真实的对象。示意图如下。

JVM系列之垃圾回收器(下篇)——Shenandoah垃圾回收器_第1张图片

转发指针示意图

「当 GC 线程对存活对象进行复制时,旧的对象的转发指针会指向新对象的地址」,这样当用户线程访问的对象是旧对象时,会通过转发指针转向到访问新对象。

JVM系列之垃圾回收器(下篇)——Shenandoah垃圾回收器_第2张图片

对象移动示意图

通过转发指针「可以解决垃圾线程和用户线程并发读」的问题,但是「对于并发写的问题,却没法解决」。例如如下场景:

  1. 最初转发指针指向的是自己,垃圾回收线程复制对象到新的地址;

  2. 用户线程修改了旧对象的数据;

  3. 垃圾回收线程更新旧对象的转发指针。

在这个场景中,用户线程对数据的修改最终会丢失,因此转发指针无法解决并发写的问题。实际上,在 Shenandoah 中最终是通过 CAS 算法来解决并发写的问题的。熟悉 Java 并发编程的同学,应该对 CAS 已经很熟悉了,毕竟在 JUC 包下,有很多工具类都是用到了 CAS 算法。

转发指针虽然解决了并发回收阶段的问题,但是它带来的缺点也很明显。对于一个对象而言,它的读操作的频率是远远高于写操作的,而有了转发指针,每次读操作都需要经过一次转发指针,如果读频率很高,那这样积累下来,对系统资源的消耗影响是十分明显的。因此在 JDK13 中,Shenandoah 将内存屏障模型改为了「基于引用访问屏障」来实现,对于基本数据类型的数据读写操作,并不会拦截,它只会拦截数据类型为引用类型的读写操作。

5. 性能测试

虽然 Shenandoah 研发团队的目标是将停顿时间控制在 10 毫秒以内,但是在 2016 年的性能测试结果中显示,并没有达到这个目标,但是相比其他几款垃圾回收器而言,Shenandoah 已经实现了质的飞越。从《深入理解 Java 虚拟机》第三)一书中,截取了一份 Shenandoah 在 2016 年的性能测试结果,如果图所示。测试的背景为:使用 ElasticSearch 对维基百科 200G 的数据进行索引。

【Shenandoah 性能测试数据结果图】

从结果中可以看到,Shenandoah 的确对停顿时间进行了大幅的降低,但是它的执行时间却是最久的,这说明 Shenandoah 的吞吐量有所下降。

6. 总结

Shenandoah 从特点上看,更像是 G1 垃圾回收器的继承者,它是一款以低延时为目标的垃圾回收器。

与 G1 相比,它的低延时主要归功于在最后的整理阶段(清除和回收),Shenandoah 是并发执行的。在 Shenandoah 中,通过 Brooks Pointer 转发指针来实现对象访问的问题,这种解决方式最明显的缺点就是每次访问对象都需要经过一次指针转发,对系统资源消耗过大。

另外,在 Shenandoah 中,默认不使用垃圾分代,它也没有像 G1 那样使用卡表来维护记忆集,而是采用了“连接矩阵”来记录对象之间跨分区的引用关系,这在很大程度下降低了垃圾回收器对系统内存的占用以及负载。

你可能感兴趣的:(Gc&垃圾回收)