JVM系列之垃圾回收器(下篇),最前沿的垃圾回收技术——ZGC

2,011 阅读13分钟

扫描下方二维码或者微信搜索公众号菜鸟飞呀飞,即可关注微信公众号,阅读更多Spring源码分析Java并发编程Netty源码系列MySQL工作原理JVM专题系列文章。

微信公众号
微信公众号

1. 前言

接上一篇文章JVM 系列之垃圾回收器(下篇)——Shenandoah 垃圾回收器,本文接下来介绍一款最前沿的垃圾回收器:ZGC。

2. ZGC 简介

ZGC 全称为 Z Garbage Collector,与 Shenandoah 一样,ZGC 也是一款在保证吞吐量的情况下,追求低延时的垃圾回收器。ZGC 是目前垃圾回收器中最前沿的技术,可惜的是目前 ZGC 还没有被正式使用,一直处于实验状态(Experiment)。从 JDK11 开始,被加入到了 OpenJDK 中,到目前 2020 年 4 月份发布的最新 Oracle JDK14 中,ZGC 依旧处于实验状态。

可以通过添加 JVM 参数:-XX:+UnlockExperimentalVMOptions 进行解锁实验状态。

ZGC 依旧沿用了 G1 和 Shenandoah 中的 Region 内存布局,以及局部为复制算法,整体为标记-压缩(整理)算法来进行垃圾回收,但是 ZGC 与它们又有很大的区别,ZGC 到目前为止都不支持分代收集,也就是说不再区分新生代和老年代。且采用的 Region 分区也与 G1、Shenandoah 大不相同,ZGC 中的 Region 分区具有动态性:动态创建和销毁、大小也不固定。

另外,ZGC 采用了读屏障、染色指针、内存多重映射等技术来实现在整个垃圾回收过程中,几乎都是并发执行的(注意:这里使用的是几乎来形容,也就是说还是存在停顿过程),这使得 ZGC 造成的停顿时间可以控制在 10 毫秒以内的愿望得以实现。

3. Region 分区

在 G1 和 Shenandoah 中,Region 的大小都是固定的,每一个 Region 区域的大小也都是一样的,当 JVM 启动以后,Region 的大小就确定了,不可再改变。然而在 ZGC 中,虽然也沿用了基于 Region 的内存布局,但是 ZGC 中每个 Region 的大小可以不一样。在 ZGC 中,Region 可以分为三类:小型 Region、中型 Region、大型 Region。

  1. 小型 Region。大小固定为 2MB,用来存放小于 256KB 的小对象。
  2. 中型 Region。大小固定为 32MB,用来存放大于等于 256KB,小于 4MB 的对象。
  3. 大型 Region。容量不固定,可以动态变化,但是必须为 2MB 的整数倍,用来存放大小为 4MB 以上的对象,因此大型 Region 的最小大小为 4MB。虽然称它为大型 Region,但有可能它的容量会比中型 Region 小。

4. 染色指针

ZGC 为了追求低延时,因此在整个回收过程中大部分阶段都是并发执行的,其中就包括并发整理阶段。在 G1 中,在 Region 回收整理阶段,需要暂停用户线程;在 Shenandoah 中,Region 的回收阶段,Shenandoah 采用了读屏障、写屏障、转发指针来实现 Region 的并发整理过程;而在 ZGC 中则是使用了读屏障以及染色指针来实现并发整理过程,那么什么是染色指针呢?

染色指针实际上就是对象的引用地址,在 64 位的机器中,对象引用地址的长度是 64bit,这 64bit 中,不是所有 bit 都表示对象的地址,而是仅有一部分 bit 表示对象的地址,具体是多少 bit 来表示对象的地址,这与具体的操作系统有关系

例如在 64 位的 linux 下,用 46bit 来表示物理地址空间,在 64 位的 windows 下,则用 44bit 来表示物理地址空间。以 64 位 linux 系统为例,用 46bit 来表示对象的物理地址,剩下 18bit 则空闲,不代表任何含义,可能会在未来被使用。而在 ZGC 中,将 46bit 进行了进一步的压榨,只用 42bit 来表示真实的物理地址空间,另外 4bit 被用来存储与对象相关的标志位信息,如:三色标记状态、是否被移动、是否只能通过 finalize()方法被访问。

这就是染色指针,总结来说就是:染色指针就是对象的引用地址,只不过引用地址中,有 4 个 bit 位表示了和对象相关的特殊标志位信息。示意图如下。

染色指针
染色指针

看到这里我们知道了 ZGC 通过染色指针可以知道对象是否被标记、是否被移动过,那么在 HotSpot 虚拟机中,其他几种垃圾回收器是如何来标记对象的呢?

实际上,有的垃圾回收器将对象的标记信息存放在了对象头中(对象头中还会存放锁信息、对象的哈希码、对象年龄等信息),例如:Serial 垃圾回收器。有的垃圾回收器则会采用单独的数据结构来存放对象的标记信息,如 G1 和 Shenandoah 垃圾回收器则是采用了一个被称为 BitMap 的结构来存放标记信息。

现在思考一个问题:在 Shenandoah 中,并发清除阶段,当 Region 中的存活被移动到新 Region 中后,当用户线程来访问旧对象时,可以通过转发指针来让用户线程读取到新对象。那么在 ZGC 的并发清除阶段,当存活的对象被移动后,仅仅只是修改了旧对象染色指针中的标志位,那么用户线程访问到旧对象时,又是如何被转发到新对象中的?

答案就是转发列表+内存多重映射技术。ZGC 会为 Region 维护一份转发列表,当对象被移动时,ZGC 会更新转发列表,当用户线程访问旧对象时,会使用内存多重映射技术(多个虚拟地址映射到同一个物理地址上),让用户线程访问到新的对象,同时将旧的引用关系更新为新对象的地址,这种现象也被称之为染色指针的 “自愈” 现象。

5. 染色指针的优点

染色指针是 ZGC 最显著的一个特点,ZGC 在垃圾回收方面的优异表现,可以说染色指针是最大功臣之一。那么使用染色指针到底有什么优点呢?

  1. 染色指针可以使得旧 Region 区域中的对象被复制到新的 Region 中后,该 Region 能被立马释放掉,可以继续进行新对象的分配,而不用等到整个堆中指向该 Region 的引用关系被修正后才释放。这是为什么呢?这得益于染色指针的治愈特点。而在 Shenandoah 中,在极端情况下,如果 Region 中所有的对象都存活,由于 Region 需要等到引用关系全部被更新后才能释放 Region,那么要回收回收集中所有 Region,那么就需要有和回收集等量的空闲的 Region 才能完成回收。这两者一对比,ZGC 显然更加优秀。
  2. 染色指针可以在垃圾回收过程中大幅减少内存屏障的使用数量,ZGC 中没有使用写屏障,只使用了读屏障。写屏障的目的是为了记录对象的引用变动情况,而在 ZGC 中,这些移动信息是直接维护在染色指针上的,因此不需要用到写屏障。在 ZGC 中只使用了读屏障,这一部分是染色指针的功劳,另一部分是属于 ZGC 目前不支持分代收集的功劳。
  3. 染色指针作为一种可以扩展的存储结构用来记录与对象标记、重定位过程相关的数据,日后可以进一步提升性能。以 64 位 linux 为例,由于 64 位的 linux 目前只支持 46bit 的寻址空间,有 18bit 没有使用,而染色指针使用的是 46bit 中的 4bit,这导致真实的寻址空间只有 42bit,如果未来 42bit 不够用了,那么我们可以把表示染色指针的 4bit 还回去,使用空闲的 18bit 中的其中几位来表示这些信息。另外,如果染色指针还想表达更多的信息,我们完全可以从这空闲的 18bit 中选取几位来使用。(不过这一优点,似乎对 ZGC 的优异性能并没有贡献,仅仅是未来可扩展而已)
  4. 支持“NUMA-Aware”分配。在 NUMA 架构下,ZGC 垃圾收集器优先尝试让请求线程在当前的处理器的本地内存上分配对象,以保证的内存的高效访问。(如果在其他处理上分配内存,那就需要跨处理器访问内存,这需要使用处理器的 Inter-Connect 通道完成,效率肯定没有直接访问本地内存快)。实际上,在目前的垃圾回收器中,除了ZGC,只有 Parallel Scavenge 回收器支持 NUMA 内存分配,另外在某个版本的 JDK 中,G1 也支持 NUMA 内存分配。(具体是哪一个版本,笔者也不太清楚,这一部分知识来源于周志明老师的《深入理解 Java 虚拟机》第三版)

6. ZGC 垃圾回收流程

ZGC 在垃圾回收过程中,大方向上,和前面介绍的 G1、Shenandoah 类似,都会经过初始标记、并发标记、重新标记、Region 回收等阶段,但在部分阶段的实现细节上有着很大的区别。下面大致介绍一下 ZGC 的垃圾回收过程。

  1. 并发标记。这一步是一个笼统的说法,实际上包含了初始标记、并发标记、重新标记三个阶段,当然初始标记和重新标记依旧需要暂停用户线程,这是为了保证可达性分析结果的正确性。在初始标记阶段停顿的时间长短,只与 GC Roots 的数量相关。在标记阶段,只需要修改染色指针的 Mark0 和 Mark1 的值。
  2. 并发预备重分配。这个阶段会根据特定的查询条件来计算出哪些 Region 可以被回收,然后这些 Region 构成一个重分配集。ZGC 的重分配集与 G1 中的回收集有点区别,G1 中的回收集是根据回收能得到的收益大小的优先级得到的,它不会扫描整个堆空间,只需要从优先级列表中获得即可。而 ZGC 中,需要扫描整个堆空间,也就是所有的 Region,然后再筛选出一部分 Region 回收,
  3. 并发重分配。并发重分配是 ZGC 的核心过程,这一步就是将 Region 中存活的对象复制到新 Region 后,再为重分配集的每个 Region 维护一个转发列表。在这阶段中,如果有用户线程来访问旧对象,那么就会经过染色指针的自愈过程,让请求转发到新对象上的同时,再修改对象的引用关系。
  4. 并发重映射。在上一步中只是进行了重分配,但是并没有进行存活对象的引用关系的修改,这个过程是放在并发重映射阶段中进行的。由于染色指针存在自愈的特点,因此 ZGC 也不急于立马去更新引用关系,因为更新引用关系需要遍历整个对象图,虽然是并发执行,但也会耗时。实际上, ZGC 将并发重映射过程放在了下一次垃圾回收中,和下一次垃圾回收的标记阶段一起进行,因为标记阶段也需要遍历整个对象图,如果放在一起进行,这样就省去了一次对象图遍历的时间。

7. ZGC 的优缺点

相比其他的垃圾回收器,ZGC 最大的优点就是低延迟了,它可以在任意堆内存大小下,将停顿时间控制在 10 毫秒以内,同时它的吞吐量也非常的高。

但是 ZGC 也存在缺点:目前为止,不支持分代收集。虽然 ZGC 可以在忽略堆内存大小的情况下,将停顿时间控制在 10 毫秒以内,但是如果堆内存非常大,完成一次垃圾回收可能需要 10 分钟(停顿时间仍然在 10 毫秒以内),如果此时系统分配新对象的速率特别大,在 10 分钟之内产生了很多新对象,而这些对象都是朝生夕灭的,而 ZGC 本次垃圾回收是无法回收这些垃圾的,那么这些对象就全是浮动垃圾。如果 ZGC 每次回收的内存又特别少,也就是说垃圾回收的速度跟不上对象的分配速度,那最终的后果就是内存被逐渐耗尽。那么碰到这种情况,解决的办法就是扩大堆内存,但是这种方法治标不治本,最终内存还是会被消耗完。最根本的解决方案还是得支持分代回收,对于那些朝生夕灭的对象,采用专门的回收方式。

8. 性能对比

文中一直强调 ZGC 的性能非常优秀,那么 ZGC 到底有多优秀呢?从《深入理解 Java 虚拟机》第三版)一书中,截取了几张图,从图表上感受一下 ZGC 的强悍吧。 图中将 ZGC 与 Parallel Scavenge、G1 分别从吞吐量和低延时两方面做了对比。在最大吞吐量的测试中,可以看到 ZGC 仅仅只是略逊于以吞吐量优先的 Parallel Scavenge,直接超过了 G1。

如果将停顿时间控制在某个值以内,ZGC 的吞吐量表现直接超过了 Parallel Scavenge 和 G1。

吞吐量性能测试
吞吐量性能测试

而在低延时性能测试中,ZGC 简直就是碾压 Parallel Scavenge 和 G1,无论是在平均停顿,还是 95%停顿、99%停顿、99.9%停顿,亦或是最大停顿,ZGC 的表现都是最优的,都控制在 10 毫秒以内。在测试结果图中,由于 ZGC 的表现实在是太优异了,以至于在纵坐标上都无法看到数据,因此将纵坐标换算为对数值来作图,如下。

低延时性能测试
低延时性能测试

9. 总结

ZGC 是目前性能表现最好的垃圾回收器,也是最前沿的垃圾回收器技术,可惜的是到目前为止,最新的 JDK14 中,ZGC 依旧还是处于实验状态

ZGC 采用了读屏障、染色指针以及内存多重映射等技术,使得它能在任何大小的堆内存下,均能将停顿时间控制在 10 毫秒以内,这是一个令人震惊的、具有革命性的垃圾回收器,这必将是未来最主流的垃圾回收器。

10. 参考

  • 周志明《深入理解 Java 虚拟机》第三版
微信公众号
微信公众号

本文使用 mdnice 排版