跳到主要内容

JEP 439:世代 ZGC

概括

通过扩展 Z 垃圾收集器 ( ZGC ) 来维护年轻对象和老对象的不同,从而提高应用程序性能。这将使 ZGC 能够更频繁地收集年轻对象(这些对象往往会在年轻时死亡)。

目标

使用 Generational ZGC 运行的应用程序应该享受

  • 降低分配停滞的风险,
  • 降低所需的堆内存开销,并且
  • 降低垃圾收集 CPU 开销。

与非分代 ZGC 相比,这些好处应该不会显着降低吞吐量。应保留非代 ZGC 的基本属性:

  • 暂停时间不应超过1毫秒,
  • 应支持从几百兆字节到数 TB 的堆大小,并且
  • 应该需要最少的手动配置。

作为最后一点的例子,应该不需要手动配置

  • 一代人的规模,
  • 垃圾收集器使用的线程数,或
  • 对象应该在年轻代中驻留多长时间。

最后,对于大多数用例来说,分代 ZGC 应该是比非分代 ZGC 更好的解决方案。我们最终应该能够用前者取代后者,以减少长期维护成本。

非目标

在年轻代中执行参考处理并不是目标。

动机

ZGC(JEP 333)专为低延迟和高可扩展性而设计。自 JDK 15 ( JEP 377 )起,它就可供生产使用。

ZGC 在应用程序线程运行时完成大部分工作,仅短暂暂停这些线程。 ZGC 的暂停时间始终以微秒为单位进行测量;相比之下,默认垃圾收集器 G1 的暂停时间范围从毫秒到秒。 ZGC 的低暂停时间与堆大小无关:工作负载可以使用从几百兆字节一直到数 TB 的堆大小,并且仍然享有较低的暂停时间。

对于许多工作负载来说,简单地使用 ZGC 就足以解决与垃圾收集相关的所有延迟问题。只要有足够的资源(即内存和 CPU)可确保 ZGC 回收内存的速度快于并发运行的应用程序线程消耗内存的速度,这种方法就可以很好地发挥作用。然而,ZGC 目前将所有对象存储在一起,无论年龄如何,因此它每次运行时都必须收集所有对象。

弱世代假说指出,年轻的物体往往会早逝,而老的物体往往会留下来。因此,收集年轻对象需要更少的资源并产生更多的内存,而收集旧对象需要更多的资源并产生更少的内存。因此,我们可以通过更频繁地收集年轻对象来提高使用 ZGC 的应用程序的性能。

描述

启用分代ZGC

为了确保顺利继承,我们首先将分代 ZGC 与非分代 ZGC 一起使用。命令-XX:+UseZGC行选项将选择非分代ZGC;要选择 Generational ZGC,请添加-XX:+ZGenerational选项:

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

在未来的版本中,我们打算将分代 ZGC 设置为默认值,届时-XX:-ZGenerational将选择非分代 ZGC。在以后的版本中,我们打算删除非分代 ZGC,届时该ZGenerational选项将变得过时。

设计

分代 ZGC 将堆分为两个逻辑_代_:_年轻_代用于最近分配的对象,而_老_一代用于长期存在的对象。每一代的收集都是独立的,因此 ZGC 可以专注于收集有利可图的年轻对象。

与非分代 ZGC 一样,所有垃圾收集都是在应用程序运行时同时完成的,应用程序暂停时间通常短于一毫秒。由于 ZGC 与应用程序同时读取和修改对象图,因此必须注意为应用程序提供一致的对象图视图。 ZGC 通过彩色指针、加载屏障和存储屏障来实现这一点。

  • _彩色指针_是指向堆中对象的指针,该对象与对象的内存地址一起包含对对象的已知状态进行编码的元数据。元数据描述了对象是否已知是活动的、地址是否正确等等。 ZGC 始终使用 64 位对象指针,因此可以容纳高达数 TB 的堆的元数据位和对象地址。当一个对象中的字段引用另一个对象时,ZGC 使用彩色指针实现该引用。

  • 负载_屏障_是 ZGC 在应用程序读取引用另一个对象的对象字段时注入到应用程序中的代码片段。负载屏障解释存储在字段中的彩色指针的元数据,并可能在应用程序使用引用的对象之前采取一些操作。

非分代 ZGC 同时使用彩色指针和负载屏障。分代 ZGC 还使用存储屏障来有效地跟踪从一代中的对象到另一代中的对象的引用。

  • 存储_屏障_是由 ZGC 注入到应用程序中的代码片段,无论应用程序将引用存储到对象字段中。分代 ZGC 向彩色指针添加新的元数据位,以便存储屏障可以确定正在写入的字段是否已被记录为可能包含代间指针。彩色指针使世代 ZGC 的存储屏障比传统的世代存储屏障更加高效。

存储屏障的添加允许世代 ZGC 将标记可达对象的工作从加载屏障转移到存储屏障。也就是说,存储屏障可以使用彩色指针中的元数据位来有效地确定在存储之前是否需要标记该字段先前引用的对象。

将标记移出负载屏障可以更轻松地优化它们,这一点很重要,因为负载屏障通常比存储屏障更频繁地执行。现在,当负载屏障解释彩色指针时,如果对象被重新定位,它只需要更新对象地址,并更新元数据以表明该地址已知是正确的。后续的加载屏障将解释此元数据,并且不再检查对象是否已重新定位。

分代 ZGC 在彩色指针中使用不同的标记和重定位元数据位集,以便可以独立收集各代。

其余部分描述了区分分代 ZGC 与非分代 ZGC 以及其他垃圾收集器的重要设计概念:

  • 没有多重映射内存
  • 优化障碍
  • 双缓冲记忆集
  • 无需额外堆内存的重定位
  • 密集堆区域
  • 大物体
  • 完整的垃圾收集

没有多重映射内存

非分代 ZGC 使用多映射内存来减少负载屏障的开销。相反,分代 ZGC 在加载和存储屏障中使用显式代码。

对于用户来说,这一更改的主要优点是可以更轻松地测量堆所使用的内存量。使用多重映射内存,相同的堆内存被映射到三个独立的虚拟地址范围,因此诸如此类的工具报告的堆使用情况ps大约是实际使用的内存量的三倍。

对于 GC 本身,此更改意味着彩色指针中的元数据位不再需要位于与堆的可访问内存地址范围相对应的指针部分中。这允许添加更多元数据位,并且还可以将最大堆大小增加到超出非分代 ZGC 的 16 TB 限制。

在分代 ZGC 中,存储在对象字段中的对象引用被实现为彩色指针。然而,存储在 JVM 堆栈中的对象引用在硬件堆栈或 CPU 寄存器中实现为_无色_指针,没有元数据位。加载和存储屏障在有色和无色指针之间来回转换。

由于彩色指针永远不会出现在硬件堆栈或 CPU 寄存器中,因此只要可以有效地完成彩色指针和无色指针之间的转换,就可以使用更奇特的彩色指针布局。 Generational ZGC 使用的彩色指针布局将元数据放在指针的低位中,将对象地址放在高位中。这最大限度地减少了负载屏障中的机器指令数量。通过仔细编码内存地址和元数据位,单个移位指令(在 x64 上)既可以检查指针是否需要处理,也可以删除元数据位。

优化障碍

随着存储屏障和加载屏障的新职责的引入,更多的 GC 代码将与已编译的应用程序代码混合在一起。为了最大限度地提高吞吐量,屏障需要高度优化。 Generational ZGC 的许多关键设计决策都涉及彩色指针方案和障碍。

用于优化障碍的一些技术是:

  • 快路径和慢路径
  • 最大限度地减少负载屏障的责任
  • 记住设置的障碍
  • SATB 标记障碍
  • 融合商店屏障检查
  • 存储屏障缓冲区
  • 修补障碍

快路径和慢路径

ZGC 将障碍分为两部分。快速_路径_检查在应用程序使用引用的对象之前是否必须完成一些额外的 GC 工作。_慢速路径_会完成额外的工作。所有对象访问都运行快速路径检查。顾名思义,它需要速度快,因此该代码被直接插入到即时编译的应用程序代码中。慢速路径只占用一小部分时间。当采取慢速路径时,所访问的对象指针的颜色会发生变化,以便后续对同一指针的访问在一段时间内不会再次触发慢速路径。因此,高度优化慢速路径并不那么重要。为了可维护性,它们在 JVM 中作为 C++ 函数实现。

在非分代 ZGC 中,这就是负载屏障的划分方式。在世代 ZGC 中,相同的方案应用于存储屏障及其相关的 GC 工作。

最大限度地减少负载屏障的责任

在非分代 ZGC 中,负载屏障负责

  • 更新 GC 重新定位的对象的陈旧指针
  • 将加载的对象标记为活动的——应用程序正在加载该对象,因此它被认为是活动的。

在 Generational ZGC 中,我们必须跟踪两代并在有色和无色指针之间进行转换。为了最小化复杂性,并允许优化加载屏障快速路径,标记的责任转移到存储屏障。

在世代 ZGC 中,负载屏障负责

  • 从彩色指针中删除元数据位并
  • 更新 GC 重新定位的对象的过时指针。

商店屏障负责

  • 添加元数据位以创建彩色指针,
  • 维护_记忆集_,跟踪老一代到年轻一代的对象指针,以及
  • 将对象标记为活着的。

记住设置的障碍

Generational ZGC收集年轻代时,只访问年轻代中的对象。然而,老年代中的对象可以包含指向年轻代中的对象的字段,即老年代到年轻代的指针。由于两个原因,必须在年轻一代收集期间访问这些字段。

  • GC 标记根——这样的字段可以包含唯一的引用,使年轻代对象图的一部分保持可达。 GC 必须将这些字段视为对象图的根,以确保找到所有活动对象并将其标记为活动。

  • 老年代中的陈旧指针——收集年轻代会移动对象,但指向这些对象的指针不会立即更新。相反,当应用程序遇到指针时,负载屏障会延迟更新指针。在某些时候,GC 必须更新应用程序未遇到的任何过时的老到年轻代指针。

老一代到年轻代指针的集合称为_记忆集_。记忆集包含老年代中的所有内存地址,这些地址可能包含指向年轻代中对象的指针。存储屏障将条目添加到记忆集中。每当一个引用被存储到一个对象字段中时,它就被认为可能包含一个老到年轻代指针。存储屏障慢速路径将存储过滤到年轻代中的字段,因为只有老一代中的地址才有意义。慢速路径不会根据写入该字段的值进行过滤,该值可能是指年轻代或老一代的值。 GC 在使用记忆集时会检查对象字段的当前值。

所有这些都确保了存储屏障在维护记忆集方面的_一次性属性_。这意味着,在两个连续的年轻代标记阶段开始之间,每个存储到对象字段仅采用一次存储屏障慢速路径。第一次写入字段时,会发生以下步骤:

  • 快速路径检查存储到字段的要覆盖的值,
  • 颜色表明自上一个年轻代标记阶段以来该字段尚未被写入,因此
  • 采取的是缓慢的路径,
  • 存储到字段的地址被添加到记忆集中,并且
  • 新的指针值被着色并存储在字段中。

新的指针值以这样的方式着色,以便后续的快速路径检查将看到该对象字段已经通过了慢速路径。

SATB 标记障碍

与非分代 ZGC 不同,分代 ZGC 使用_开始快照_(SATB) 标记算法。在标记阶段开始时,GC 会拍摄 GC 根的快照;在标记阶段结束时,保证在标记开始时从这些根可到达的所有对象都被找到并标记为活动状态。

为了实现这一点,当对象图中对象之间的引用被破坏时,需要通知 GC。因此,存储屏障向 GC 报告要被覆盖的字段值;然后,GC 标记所引用的对象,并访问并标记可从该对象访问的对象。

存储屏障只需要在标记周期内第一次存储字段时报告要覆盖的字段值。由于 SATB 属性,后续存储到同一字段只会替换 GC 保证找到的值。反过来,SATB 属性支持存储屏障在标记方面的一次性操作属性。

融合商店屏障检查

存储障碍的记忆集维护和标记功能有很多相似之处。两者都使用彩色指针快速路径检查及其各自的一次操作属性。我们没有对每种条件进行单独的快速路径检查,而是将它们融合为一个组合的快速路径检查。如果这两个属性中的任何一个失败,则采用慢速路径并完成所需的 GC 工作。

存储屏障缓冲区

将障碍分为快速路径和慢速路径,并使用指针着色,可以减少对 C++ 慢速路径函数的调用次数。分代 ZGC 通过在快速路径和慢速路径之间放置 JIT 编译的_中等路径_来进一步减少开销。中间路径将要覆盖的值和对象字段的地址存储在_存储屏障缓冲区_中,并返回到已编译的应用程序代码,而不需要采取昂贵的慢速路径。仅当存储屏障缓冲区已满时才采用慢速路径。这可以分摊从编译的应用程序代码转换到 C++ 慢路径代码的一些开销。

修补障碍

加载屏障和存储屏障都针对全局或线程局部变量中的值执行检查,GC 在转换到新阶段时会更改这些变量。读取屏障中的这些变量有不同的方法,并且在不同的 CPU 架构上这样做的开销也不同。

在世代 ZGC 中,我们通过在可能的情况下修补屏障代码来减少这种开销。全局值在屏障的机器指令中编码为立即值。这样就不需要取消引用全局或线程局部变量来获取当前值。当 GC 改变阶段后第一次调用方法时,立即值会被修补;例如,当 GC 开始年轻代标记阶段时。这进一步减少了屏障开销。

双缓冲记忆集

许多 GC 使用称为卡表标记_的记忆集技术来跟踪代间指针。当应用程序线程写入对象字段时,它还会在称为_卡表_的大字节数组中写入(即_脏)一个字节。通常,表中的一个字节对应于堆中跨越 512 字节的地址范围。为了找到所有老到年轻代对象指针,GC必须定位并访问卡表中脏字节对应的地址范围内的所有对象字段。

相比之下,分代 ZGC 通过使用位图来精确记录对象字段位置,其中每个位代表一个潜在的对象字段地址。每个老年代区域都有一对记忆集位图。其中一个位图处于活动状态,并由运行其存储屏障的应用程序线程填充,而另一个位图由 GC 用作所有记录的老一代对象字段的只读副本,这些字段可能指向年轻代中的对象。每次年轻代收集开始时,这两个位图都会自动交换。这种方法的一个好处是应用程序线程不必等待位图被清除。 GC 处理并清除其中一个位图,同时应用程序线程正在填充另一个位图。另一个好处是,由于这允许应用程序线程和 GC 线程在不同的位图上工作,因此消除了两种类型线程之间额外内存屏障的需要。其他使用卡表标记的分代收集器(例如 G1)在标记卡时需要内存栅栏,从而导致存储屏障性能可能更差。

无需额外堆内存的重定位

其他 HotSpot GC 中的年轻代回收使用_清理_模型,其中活动对象在一次传递中找到并重新定位。在 GC 完全了解哪些对象还活着之前,年轻代中的所有对象都必须重新定位。使用此模型的 GC 仅在所有对象都已重定位后才能回收内存。因此,这些 GC 需要猜测幸存对象所需的内存量,并确保在 GC 启动时该内存量可用。如果猜测错误,则需要更昂贵的清理操作;例如,就地固定未重定位的对象(这会导致碎片),或者停止所有应用程序线程的完整 GC。

分代 ZGC 使用两次传递:第一次访问并标记所有可达对象,第二次重新定位标记的对象。由于 GC 在重定位阶段开始之前拥有完整的活动信息,因此它可以按区域粒度划分重定位工作。一旦所有活动对象都被重新定位出某个区域,即该区域已被_疏散_,该区域就可以重新用作新的目标区域,以供应用程序线程重新定位或分配。即使没有更多的空闲区域可以将对象重新定位到其中,ZGC 仍然可以通过将对象压缩到当前重新定位的区域来继续进行。这使得分代 ZGC 能够重新定位和压缩年轻代,而无需使用额外的堆内存。

密集堆区域

当将对象移出年轻代时,存活对象的数量和它们占用的内存量会因区域而异。例如,最近分配的区域可能包含更多活动对象。

ZGC 分析年轻代区域的密度,以确定哪些区域值得疏散,哪些区域太满或疏散成本太高。未选择疏散的区域将就地老化:它们的对象保留在其位置,并且这些区域要么作为幸存者区域保留在年轻代中,要么提升到老年代。幸存区域中的对象获得第二次死亡机会,希望在下一个年轻代收集开始时,足够多的对象已经死亡,以使这些区域中的更多对象有资格撤离。

这种对密集区域进行老化的方法减少了收集年轻一代所需的工作量。

大物体

ZGC 已经可以很好地处理大型对象。通过将虚拟内存与物理内存解耦以及过度保留虚拟内存,ZGC 通常可以避免使用 G1 时有时难以分配大对象的碎片问题。

在分代 ZGC 中,我们更进一步,允许在年轻代中分配大对象。鉴于区域可以在不重新定位的情况下老化,因此不需要在老一代中分配大对象只是为了防止昂贵的重新定位。相反,如果它们寿命较短,则可以在年轻代中收集它们;如果寿命较长,则可以廉价地将它们提升到老年代。

完整的垃圾收集

当老年代被回收时,会有年轻代中的对象指向老年代中的对象的指针。这些指针被认为是老一代对象图的根。年轻代中的对象经常发生变化,因此年轻代到老一代的指针不会被跟踪。相反,这些指针是通过运行年轻代收集和老一代标记阶段来找到的。当年轻代集合找到指向老一代的指针时,它将它们传递给老一代标记过程。

这个额外的年轻代集合仍将作为正常的年轻代集合执行,并将活动对象留在幸存区域中。这样做的一个效果是,年轻代中幸存的对象不会受到收集老一代时进行的引用处理和类卸载。例如,应用程序可以观察到这一点,该应用程序释放对对象图的最后一个引用,调用System.gc(),然后期望清除或排队某些弱引用或卸载某些类。为了缓解这种情况,当应用程序代码显式请求 GC 时,会在老一代收集开始之前首先执行额外的年轻代收集,以将所有幸存对象提升到老一代中。

备择方案

更简单的屏障和指针着色方案

当前的加载和存储屏障实现很难理解。更简单的版本可能更容易维护,但代价是更昂贵的加载和存储屏障。我们评估了大约十种不同的屏障实现;没有一个比所选择的基于班次的负载屏障性能更好。对这种性能与复杂性权衡的持续调查和分析可能仍然值得考虑。

继续使用多映射内存

通过使用利用多映射内存的更简单的解决方案,可以跳过无色根方案。如果指针中需要比非分代 ZGC 更多的元数据位,则最大堆大小将受到限制。另一种方法可能是使用混合解决方案,其中一些位使用多重映射内存,而其他位则由加载和存储屏障删除和添加。

测试

ZGC 实现对无色和有色指针使用不同的 C++ 类型,这确保两种类型之间不会进行隐式转换。彩色指针仅限于 GC 代码和屏障。只要运行时系统使用 HotSpot 的访问 API 和屏障来访问对象指针,它就只能看到可解引用的无色指针。运行时可见的对象指针类型将始终包含无色指针。我们将大量的验证代码注入到不同的对象指针类型中,以快速查找指针何时被破坏或障碍是否丢失。

  • 垃圾收集算法的标准测试集将用于证明正确性。

风险和假设

实施复杂度

分代 ZGC 中使用的屏障和彩色指针比非分代 ZGC 中的更复杂。分代 ZGC 还同时运行两个垃圾收集器;这些收集器相当独立,但它们确实以一些复杂的方式进行交互,增加了实现的复杂性。

考虑到额外的复杂性,从长远来看,我们打算通过用分代 ZGC 完全替换原始的非分代 ZGC 版本来最大限度地降低维护成本。

分代 ZGC 的表现与非分代 ZGC 不同

我们相信,Generational ZGC 将比其前身更适合大多数用例。由于资源使用率较低,某些工作负载甚至可能会通过世代 ZGC 获得吞吐量提升。例如,当运行Apache Cassandra基准测试时,与非分代 ZGC 相比,分代 ZGC 需要四分之一的堆大小,但却实现了四倍的吞吐量,同时仍将暂停时间保持在一毫秒以下。

某些工作负载本质上是非分代的,并且可能会出现轻微的性能下降。我们认为,这是一组足够小的工作负载,不足以证明长期维护两个单独版本的 ZGC 的成本是合理的。

另一种开销来源是功能更强大的 GC 屏障。我们预计其中大部分将被不必频繁收集老一代对象的收益所抵消。

另一个额外的开销来源是同时运行两个垃圾收集器。我们需要确保平衡它们的调用率和 CPU 消耗,以便它们不会过度影响应用程序。

正如 GC 开发的正常情况一样,未来的改进和优化将由基准测试和用户反馈驱动。即使在第一个版本发布之后,我们也打算继续改进 Generational ZGC。