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 的目标是将加载指令的数量限制到 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
时跳转。
该指令的操作过程可以见图:
每次加载均会将地址右移,同时由于 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 有两个阶段:
- 访问并标记所有可达对象
- 重分配标记的对象
由于 GC 在重分配之前就知道对象是否存活,因此可以按区域粒度划分工作。一旦存活对象都被重分配出某个区域,即该区域已被清除,该区域就被当作新的目标区域,继续用于重分配或被应用使用。即使没有额外的堆空间,ZGC 仍可通过将压缩对象到当前区域来继续重分配。这使得分代 ZGC 能够重分配并压缩年轻代,而无需使用额外的堆内存。
堆区域密度
如果一个区域的存活对象很多,将它们一个个移到老年代堆的操作是不值得的。ZGC 会分析年轻代存活对象的密度,以此为一句来判断是否有机会就地升级为老年代。否则,这个区域会保留为年轻代。
该方法可以减小移动年轻代的开销。
大对象处理
ZGC 已经可以很好地处理大型对象。通过将虚拟内存与物理内存解耦,并提前保留虚拟内存,大对象的碎片问题通常可以避免。
在分代 ZGC 中,允许在年轻代中分配大对象。鉴于该区域现在可以在不重分配的情况下老化,因此不再需要在老一代中分配大对象。相反,如果大对象寿命较短,则可以在年轻代中收集它们;如果寿命较长,则可以廉价地将它们提升到老年代。
以上,就是分代 ZGC 带来的性能提升和背后实现。