Back
Featured image of post 分代 ZGC

分代 ZGC

Java 21 ZGC 带来了哪些升级?

OpenJDK 21 是下一个 LTS 版本,将于 2023 年 9 月 19 日正式发布。其中包括的一个重要更新就是 JEP 439,分代 ZGC。

本文假设你已对先前的分代 GC,如 Parallel、G1;和低延迟 GC,如 Shenandoah GC、ZGC 等有所了解,如果不了解或记不太清了,请先查看过往文章:

我们都知道,先前的 ZGC 是不分代的,因此 ZGC 需要将所有对象存储在一起,无论对象存活时间或长或短,并且每次 GC 运行时,收集器都需要扫描所有对象。

基于「大部分对象朝生夕死」的弱分代假说,ZGC 显然可以更进一步优化当前的实现。

根据 JEP 439 中的说法,分代 ZGC 不仅能达到亚毫秒级延迟,甚至在很多情况下会比非分代 ZGC 使用更少的内存,且有更少的吞吐量损失。

在 Java 21 中,我们可以这样开启分代 ZGC:

java -XX:+UseZGC -XX:+ZGenerational ...

同时,分代 ZGC 中不再需要设置年轻代大小、年轻代进入老年代所需要的 GC 次数、GC 线程数等,ZGC 将这些全部动态化,并在内部自动调优。现在只需要设置一个参数,即最大内存大小 -Xmx

不再使用多重映射内存

由于分代 ZGC 的元数据比较多,使用多重映射内存的方法不再能行得通。因此,在寄存器和栈中的内存地址需要为普通的无色指针。分代 ZGC 不再能通过此减少加载或存储内存屏障的开销,需要在有色和无色指针之间转换,即:

  • 在加载时移除元数据(加载屏障)
  • 在存储时恢复元数据(存储屏障)

为了最大程度减少开销,ZGC 需要尽可能优化染色指针的布局,以减少整体开销。

染色指针布局

分代 ZGC 染色指针布局

如图所示,分代 ZGC 相比不分代 ZGC 多了很多元数据。分代 ZGC 的目标是将加载指令的数量限制到 2 个,和不分代 ZGC 一致。该指针布局是专门为此设计的。

不分代 ZGC 判断指针处于哪一个收集阶段很简单,只需要简单的位移:

movq  rax, 0x10(rbx)
testq rax, 0x20(r15)
jnz   slow_path

testq 即等价于 & 操作,是一般的 bitflag 做法。

而分代 ZGC 的代码是这样的:

movq rax, 0x10(rbx)
shrq rax, $address_shift
ja   slow_path

shrq 是右移操作,同时会设置 Carry Flag 为最后移除的一位,同时如果右移的结果为 0,Zero Flag 也会被设为 0。

ja 是 jump if above 指令,仅在 CF == 0 && ZF == 0 时跳转。

该指令的操作过程可以见图:

分代 ZGC 加载屏障示意图

每次加载均会将地址右移,同时由于 8 字节对齐,JVM 保证了最低三位的值一定为 0,因此若该指针被更新(最后被移除的位值为 1),则会跳入 slow path 分支处理下一个 GC 阶段。

目前的实现还带来了一些好处:

内存屏障优化

分代 ZGC 需要存储屏障,因此可能会引入额外开销,ZGC 团队同样也在这方面努力优化。

快路径和慢路径

这是常见的优化策略,正如上面加载屏障一样。快路径检测是否需要额外的 GC 工作,当需要时,会跳转进入慢路径,开始相关工作。快路径由 JIT 实现,会直接插入 GC 代码至 JIT 编译后的程序。而慢路径不经常调用,所以使用 C++ 实现。

最小化加载屏障职责

在之前的 ZGC 中,加载屏障负责:

  • 更新已被 GC 更新的过时指针
  • 将加载的对象标记为存活对象

在分代 ZGC 中,我们需要监控年轻代和老年代,并且在有色指针和无色指针间转换。为了简化加载屏障的复杂性,并引入优化加载屏障的空间,标记的职责交给了加载屏障。

在分代 ZGC 中,加载屏障负责:

  • 转换有色指针为无色指针
  • 更新已被 GC 更新的过时指针

存储屏障负责:

  • 转换无色指针为有色指针
  • 维护记忆集
  • 标记对象存活

记忆集和 SATB

类似其他分代 GC,分代 ZGC 也使用记忆集和 SATB,这在先前的文章中已经提到,这里不再赘述。

存储屏障缓冲

除了快路径和慢路径,分代 ZGC 还进一步对加载屏障加入 JIT 编译的中间路径。中间路径将待改写的值和对象字段的地址存储在存储屏障缓冲区中,然后返回编译后的应用代码,而不使用昂贵的慢速路径。只有当存储屏障缓冲区满时,才会执行慢速路径。这样可以摊销调用 C++ 慢路径代码的部分开销。

双重缓冲记忆集

ZGC 的记忆集不使用卡表实现,而是由两个 bitmap 实现。一个 bitmap 用于用户线程,在加载屏障中修改,另一个只读的 bitmap 用于 GC。这样做有两个好处:

  • 用户线程无需等待 bitmap 被清除
  • 因为分了两个 bitmap,所以不需要额外的内存屏障,造成额外的内存开销

无需多余堆空间的重分配

其他 HotSpot GC 中的年轻代回收使用清理模型,GC 一次性找到存活对象并重分配。在 GC 完全了解哪些对象还活着之前,年轻代中的所有对象都必须重分配,在这之后才能回收内存。因此,这些 GC 需要猜测存活对象所需的内存量,并确保在 GC 启动时该内存量可用。如果猜错了,则需要更昂贵的清理操作:例如,就地固定未重分配的对象,这会导致内存碎片,或者 Full GC。

分代 ZGC 有两个阶段:

  1. 访问并标记所有可达对象
  2. 重分配标记的对象

由于 GC 在重分配之前就知道对象是否存活,因此可以按区域粒度划分工作。一旦存活对象都被重分配出某个区域,即该区域已被清除,该区域就被当作新的目标区域,继续用于重分配或被应用使用。即使没有额外的堆空间,ZGC 仍可通过将压缩对象到当前区域来继续重分配。这使得分代 ZGC 能够重分配并压缩年轻代,而无需使用额外的堆内存。

堆区域密度

如果一个区域的存活对象很多,将它们一个个移到老年代堆的操作是不值得的。ZGC 会分析年轻代存活对象的密度,以此为一句来判断是否有机会就地升级为老年代。否则,这个区域会保留为年轻代。

该方法可以减小移动年轻代的开销。

大对象处理

ZGC 已经可以很好地处理大型对象。通过将虚拟内存与物理内存解耦,并提前保留虚拟内存,大对象的碎片问题通常可以避免。

在分代 ZGC 中,允许在年轻代中分配大对象。鉴于该区域现在可以在不重分配的情况下老化,因此不再需要在老一代中分配大对象。相反,如果大对象寿命较短,则可以在年轻代中收集它们;如果寿命较长,则可以廉价地将它们提升到老年代。

以上,就是分代 ZGC 带来的性能提升和背后实现。

comments powered by Disqus