JEP 439: Generational ZGC | 代际 ZGC
摘要
通过扩展 Z 垃圾收集器(ZGC)以维护年轻对象和老年对象的独立代,从而提升应用程序性能。这将允许 ZGC 更频繁地收集倾向于快速消亡的年轻对象。
目标
使用分代 ZGC 运行的应用程序应享受以下优势:
- 更低的分配停滞风险,
- 更低的所需堆内存开销,
- 更低的垃圾收集 CPU 开销。
与非分代 ZGC 相比,这些优势应在不显著降低吞吐量的情况下实现。应保留非分代 ZGC 的基本属性:
- 停顿时间不应超过 1 毫秒,
- 应支持从几百兆字节到多个太字节的堆大小,
- 应尽量减少手动配置。
关于最后一点,作为示例,无需手动配置:
- 代的大小,
- 垃圾收集器使用的线程数,
- 对象在年轻代中的驻留时间。
最后,对于大多数用例而言,分代 ZGC 应比非分代 ZGC 提供更好的解决方案。我们最终应能够用前者替换后者,以降低长期维护成本。
非目标
在年轻代中执行 引用处理 不是本项目的目标。
动机
ZGC(JEP 333)旨在实现低延迟和高可扩展性。自 JDK 15 起(JEP 377),它已可用于生产环境。
ZGC 在应用程序线程运行时完成大部分工作,仅短暂暂停这些线程。ZGC 的停顿时间通常以微秒为单位进行测量;相比之下,默认垃圾收集器 G1 的停顿时间范围从毫秒到秒不等。ZGC 的低停顿时间与堆大小无关:工作负载可以使用从几百兆字节到多个太字节的堆大小,并仍然享受低停顿时间。
对于许多工作负载而言,仅使用 ZGC 就足以解决与垃圾收集相关的所有延迟问题。只要有足够的资源(即内存和 CPU)来确保 ZGC 能够比并发运行的应用程序线程更快地回收内存,这种方法就能很好地工作。然而,ZGC 目前将所有对象(无论年龄大小)存储在一起,因此每次运行时都必须收集所有对象。
弱分代假设 指出,年轻对象倾向于快速消亡,而老年对象则倾向于长期存在。因此,收集年轻对象所需的资源更少,且能回收更多内存,而收集老年对象则需要更多资源,且回收的内存较少。因此,我们可以通过更频繁地收集年轻对象来提高使用 ZGC 的应用程序的性能。
描述
启用分代 ZGC
为确保顺利过渡,我们最初将同时提供分代 ZGC 和非分代 ZGC。使用 -XX:+UseZGC
命令行选项将选择非分代 ZGC;要选择分代 ZGC,请添加 -XX:+ZGenerational
选项:
$ java -XX:+UseZGC -XX:+ZGenerational ...
在未来的版本中,我们打算将分代 ZGC 设为默认选项,届时 -XX:-ZGenerational
将用于选择非分代 ZGC。在更晚的版本中,我们计划移除非分代 ZGC,届时 ZGenerational
选项将不再使用。
设计
分代 ZGC 将堆分成两个逻辑上的“代”:年轻代 用于最近分配的对象,而 老年代 用于长期存活的对象。每一代都独立于另一代进行收集,因此 ZGC 可以专注于收集有利可图的年轻对象。
与非分代 ZGC 一样,所有垃圾收集都在应用程序运行时并发进行,应用程序的停顿时间通常短于一毫秒。由于 ZGC 在应用程序同时读写对象图时,必须确保应用程序对对象图的一致视图。ZGC 通过彩色指针、加载屏障和存储屏障来实现这一点。
彩色指针 是 指向堆中对象的指针,除了对象的内存地址外,还包括编码对象已知状态的元数据。元数据描述了对象是否已知存活、地址是否正确等信息。ZGC 始终使用 64 位对象指针,因此可以容纳高达多个太字节堆的元数据位和对象地址。当对象中的一个字段引用另一个对象时,ZGC 会使用彩色指针来实现该引用。
加载屏障 是 ZGC 在应用程序读取引用其他对象的对象字段时注入到应用程序中的代码片段。加载屏障会解释存储在字段中的彩色指针的元数据,并在应用程序使用引用对象之前可能执行一些操作。
非分代 ZGC 使用彩色指针和加载屏障。分代 ZGC 还使用存储屏障来有效地跟踪一个代中的对象到另一个代中的对象的引用。
- 存储屏障 是 ZGC 在应用程序将引用存储到对象字段中时注入到应用程序中的代码片段。分代 ZGC 在彩色指针中添加了新的元数据位,以便存储屏障可以确定正在写入的字段是否已被记录为可能包含跨代指针。彩色指针使分代 ZGC 的存储屏障比传统的跨代存储屏障更高效。
添加存储屏障允许分代 ZGC 将标记可达对象的工作从加载屏障转移到存储屏障。即,存储屏障可以使用彩色指针中的元数据位来有效地确定在存储之前由字段引用的对象是否需要被标记。
将标记工作移出加载屏障有助于优化加载屏障,这一点很重要,因为加载屏障的执行频率通常高于存储屏障。现在,当加载屏障解释彩色指针时,它只需在对象被重新定位时更新对象地址,并更新元数据以指示地址已知正确。后续的加载屏障将解释此元数据,并不再检查对象是否已被重新定位。
ZGC 的分代机制在彩色指针中使用不同的标记和重定位元数据位集合,以便可以独立收集各个代。
以下部分介绍了区分分代 ZGC 与非分代 ZGC 以及其他垃圾收集器的重要设计概念:
- 无多映射内存
- 优化的屏障
- 双缓冲的记忆集
- 无需额外堆内存的重定位
- 密集的堆区域
- 大对象
- 完整垃圾收集
无多映射内存
非分代 ZGC 使用 多映射内存 来减少加载屏障的开销。而分代 ZGC 则在加载和存储屏障中使用显式代码。
对于用户而言,此更改的主要优势是更容易测量堆使用的内存量。使用多映射内存时,相同的堆内存会映射到三个单独的虚拟地址范围,因此 ps
等工具报告的堆使用量大约是实际使用内存量的三倍。
对于垃圾收集器本身而言,此更改意味着彩色指针中的元数据位不再需要位于指针的与堆的可访问内存地址范围相对应的部分。这允许添加更多元数据位,并且还有可能将最大堆大小增加到非分代 ZGC 的 16TB 限制之上。
在分代 ZGC 中,存储在对象字段中的对象引用被实现为彩色指针。然而,存储在 JVM 堆栈中的对象引用则作为 无色 指针实现,即它们在硬件堆栈或 CPU 寄存器中不包含元数据位。加载和存储屏障在彩色指针和无色指针之间进行转换。
由于彩色指针永远不会出现在硬件堆栈或 CPU 寄存器中,因此只要彩色指针和无色指针之间的转换可以高效完成,就可以使用更独特的彩色指针布局。分代 ZGC 使用的彩色指针布局将元数据放在指针的低阶位中,将对象地址放在高阶位中。这减少了加载屏障中的机器指令数量。通过仔细编码内存地址和元数据位,单个移位指令(在 x64 上)既可以检查指针是否需要处理,也可以移除元数据位。
优化的屏障
随着存储屏障的引入以及加载屏障的新职责,更多的 GC 代码将与编译后的应用程序代码交织在一起。为了最大化吞吐量,屏障需要高度优化。分代 ZGC 的许多关键设计决策都涉及彩色指针方案和屏障。
用于优化屏障的一些技术包括:
- 快速路径和慢速路径
- 最小化加载屏障职责
- 记忆集屏障
- SATB 标记屏障
- 合并存储屏障检查
- 存储屏障缓冲区
- 屏障修补
快速路径和慢速路径
ZGC 将屏障分为两部分。快速路径 检查在应用程序使用引用对象之前是否必须执行一些额外的 GC 工作。慢速路径 则执行这些额外工作。所有对象访问都会运行快速路径检查。顾名思义,它需要快速执行,因此该代码直接插入到即时编译的应用程序代码中。只有一小部分时间会走慢速路径。当走慢速路径时,会更改所访问对象指针的颜色,以便在一段时间内对同一指针的后续访问不会再次触发慢速路径。因此,慢速路径的高度优化并不那么重要。为了可维护性,它们在 JVM 中以 C++ 函数的形式实现。
在非分代 ZGC 中,加载屏障就是这样被划分的。在分代 ZGC 中,相同的方案也应用于存储屏障及其相关的 GC 工作。
最小化加载屏障职责
在非分代 ZGC 中,加载屏障负责:
- 更新 GC 重定位后过时的对象指针
- 将加载的对象标记为存活——应用程序正在加载该对象,因此它被视为存活。
在分代 ZGC 中,我们必须跟踪两个代,并在彩色指针和无色指针之间进行转换。为了降低复杂性并优化加载屏障的快速路径,将标记职责转移到了存储屏障。
在分代 ZGC 中,加载屏障负责:
- 从彩色指针中移除元数据位
- 更新 GC 重定位后过时的对象指针
存储屏障负责:
- 添加元数据位以创建彩色指针
- 维护记忆集,该集合跟踪从旧代到新代的对象指针
- 将对象标记为存活
记忆集屏障
当分代 ZGC 收集年轻代时,它只访问年轻代中的对象。然而,老年代中的对象可能包含指向年轻代中对象的字段,即老到年轻的代指针。出于以下两个原因,在年轻代收集期间必须访问这些字段。
GC 标记根——这样的字段可能包含使年轻代对象图的一部分保持可达的唯一引用。GC 必须将这些字段视为对象图的根,以确保找到并标记所有存活的对象。
老年代中的过时指针——收集年轻代会移动对象,但不会立即更新指向这些对象的指针。相反,当应用程序遇到这些指针时,加载屏障会延迟更新它们。在某个时刻,GC 必须更新应用程序未遇到的任何老到年轻代的过时指针。
老到年轻代指针的集合被称为记忆集。记忆集包含老年代中所有可能包含指向年轻代对象指针的内存地址。存储屏障向记忆集添加条目。每当将引用存储到对象字段中时,都认为它可能包含老到年轻的代指针。存储屏障的慢速路径会过滤掉对年轻代字段的存储,因为只有老年代中的地址才感兴趣。慢速路径不会根据写入字段的值进行过滤,该值可能指向年轻代或老年代。当 GC 使用记忆集时,它会检查对象字段的当前值。
所有这些都确保了存储屏障在维护记忆集方面的一次执行属性。这意味着,在两个连续的年轻代标记阶段开始之间,存储屏障的慢速路径对每个存储到的对象字段只执行一次。当第一次写入字段时,会发生以下步骤:
- 快速路径检查要覆盖的存储字段的值,
- 颜色表明自上次年轻代标记阶段以来该字段尚未被写入,因此
- 执行慢速路径,
- 将存储字段的地址添加到记忆集,
- 将新指针值着色并存储在字段中。
新指针值以这样的方式着色,以便后续的快速路径检查将看到该对象字段已经通过了慢速路径。
SATB 标记屏障
与非分代 ZGC 不同,分代 ZGC 使用开始时快照(SATB)标记算法。在标记阶段开始时,GC 会捕获 GC 根的快照;到标记阶段结束时,保证从那些根开始可达的所有对象都将被找到并标记为存活。
为了实现这一点,GC 需要在对象图中对象之间的引用被打破时得到通知。因此,存储屏障会向 GC 报告即将被覆盖的字段值;然后 GC 会标记引用的对象,并访问和标记从它可达的对象。
存储屏障只需在标记周期内首次将字段存储时报告即将被覆盖的字段值。对同一字段的后续存储只会替换 GC 无论如何都会找到的值,因为 SATB 属性。反过来,SATB 属性支持存储屏障在标记方面的一次执行属性。
合并存储屏障检查
存储屏障的记忆集维护和标记功能之间存在许多相似之处。两者都使用彩色指针快速路径检查及其各自的一次执行属性。我们不是为每个条件设置单独的快速路径检查,而是将它们合并为一个组合的快速路径检查。如果两个属性中的任何一个失败,则执行慢速路径并完成所需的 GC 工作。
存储屏障缓冲区
将屏障分为快速路径和慢速路径,并使用指针着色,可以减少对 C++ 慢速路径函数的调用次数。Generational ZGC 通过在快速路径和慢速路径之间放置一个即时编译(JIT)的中等路径来进一步减少开销。中等路径将在存储屏障缓冲区中存储待覆盖的值和对象字段的地址,并返回到编译后的应用程序代码,而无需采用昂贵的慢速路径。仅当存储屏障缓冲区已满时,才会采用慢速路径。这分摊了从编译后的应用程序代码过渡到 C++ 慢速路径代码的部分开销。
屏障修补
加载屏障和存储屏障都会根据全局或线程局部变量中的值进行检查,这些值在 GC 过渡到新阶段时会发生变化。在屏障中读取这些变量的方式有多种,不同 CPU 架构下的开销也不同。
在 Generational ZGC 中,我们尽可能通过修补屏障代码来减少这种开销。全局值被编码为屏障的机器指令中的立即值。这消除了为了获取当前值而需要取消引用全局或线程局部变量的需要。当方法在 GC 改变阶段后首次被调用时(例如,当 GC 开始年轻代标记阶段时),会修补这些立即值。这进一步减少了屏障的开销。
双缓冲的已记录集
许多 GC 使用一种称为卡片表标记的已记录集技术来跟踪跨代指针。当应用程序线程写入对象字段时,它还会在称为卡片表的大型字节数组中写入(即弄脏)一个字节。通常,表中的一个字节对应于堆中跨越 512 字节的地址范围。为了找到所有从旧代到新代的对象指针,GC 必须定位和访问与卡片表中脏字节对应的地址范围内的所有对象字段。
相比之下,Generational ZGC 通过使用位图来精确记录对象字段的位置,其中每个位表示一个潜在的对象字段地址。每个旧代区域都有一对已记录集位图。其中一个位图是活动的,由运行其存储屏障的应用程序线程填充,而另一个位图则由 GC 用作所有记录的可能指向年轻代中对象的旧代对象字段的只读副本。每次开始年轻代收集时,这两个位图都会进行原子交换。这种方法的一个好处是,应用程序线程无需等待清除位图。GC 处理并清除其中一个位图,而另一个位图则由应用程序线程并发填充。另一个好处是,由于这允许应用程序线程和 GC 线程在不同的位图上工作,因此消除了在两种类型的线程之间设置额外内存屏障的需要。其他使用卡片表标记的代收集器(如 G1)在标记卡片时需要内存屏障,这可能导致存储屏障性能变差。
无额外堆内存的重新定位
其他 HotSpot GC 中的年轻代收集使用清除模型,在该模型中,在单个遍历中找到并重新定位活动对象。在 GC 完全了解哪些对象是活动的之前,必须重新定位年轻代中的所有对象。使用此模型的 GC 只能在所有对象都被重新定位后才能回收内存。因此,这些 GC 需要猜测存活对象所需的内存量,并确保在 GC 开始时该内存量是可用的。如果猜测错误,则需要进行更昂贵的清理操作;例如,将未重新定位的对象就地固定,这会导致碎片,或者停止所有应用程序线程进行完整 GC。
Generational ZGC 使用两个遍历:第一个遍历并标记所有可达对象,第二个遍历重新定位已标记的对象。因为 GC 在重新定位阶段开始之前具有完整的活动信息,所以它可以按区域粒度划分重新定位工作。一旦一个区域中的所有活动对象都被重新定位出去,即该区域已被清空,则该区域就可以作为重新定位或应用程序线程分配的新目标区域被重用。即使没有更多的空闲区域来重新定位对象,ZGC 仍然可以通过将对象压缩到当前已重新定位的区域中来继续操作。这允许 Generational ZGC 在不使用额外堆内存的情况下重新定位和压缩年轻代。
密集堆区
在将对象移出年轻代时,各区域中存活对象的数量和它们占用的内存量会有所不同。例如,最近分配的区域可能包含更多的存活对象。
ZGC 分析年轻代区域的密度,以确定哪些区域值得清空,哪些区域要么太满要么清空成本太高。未选择进行清空的区域会就地老化:它们的对象保持在原位置,这些区域要么作为幸存者区域保留在年轻代中,要么晋升到老年代。幸存者区域中的对象会获得第二次死亡的机会,希望在下一次年轻代收集开始时,足够多的对象已经死亡,从而使这些区域中的更多区域有资格被清空。
这种就地老化密集区域的方法减少了收集年轻代所需的努力。
大对象
ZGC 已经能够很好地处理大对象。通过将虚拟内存与物理内存解耦并超额预留虚拟内存,ZGC 通常可以避开在使用 G1 时有时难以分配大对象的碎片化问题。
在 ZGC 的代际版本中,我们更进一步,允许在年轻代中分配大对象。由于可以在不移动它们的情况下使区域老化,因此没有必要仅仅为了防止昂贵的移动而在老年代中分配大对象。相反,如果它们是短命的,则可以在年轻代中收集它们;如果它们是长命的,则可以廉价地晋升到老年代。
完全垃圾收集
在收集老年代时,会有从年轻代对象到老年代对象的指针。这些指针被视为老年代对象图的根。年轻代中的对象经常发生变化,因此不会跟踪年轻代到老年代的指针。相反,这些指针是通过在老年代标记阶段同时运行年轻代收集来找到的。当这个年轻代收集找到指向老年代的指针时,它会将它们传递给老年代标记过程。
这个额外的年轻代收集仍然会作为正常的年轻代收集来执行,并将存活对象留在幸存者区域中。这产生的一个影响是,年轻代中的存活对象不会受到在收集老年代时进行的引用处理和类卸载的影响。这可以通过一个应用程序来观察,例如,该应用程序释放了对对象图的最后一个引用,调用 System.gc()
,然后期望清除或入队一些弱引用或卸载一些类。为了缓解这个问题,当应用程序代码显式请求 GC 时,在老年代收集开始之前,会首先执行一个额外的年轻代收集,以将所有存活对象晋升到老年代。
替代方案
更简单的屏障和指针着色方案
当前的加载和存储屏障实现理解起来并不简单。一个更简单的版本可能更容易维护,但代价是更昂贵的加载和存储屏障。我们评估了大约十种不同的屏障实现;没有一种能像所选的基于位移的加载屏障那样高效。继续研究和分析这种性能与复杂性的权衡可能仍然值得考虑。
继续使用多映射内存
通过使用利用多映射内存的更简单解决方案,可以跳过无色根方案。如果与非代际 ZGC 相比,指针中需要更多的元数据位,那么最大堆大小将受到限制。另一种方法可以使用混合解决方案,其中一些位使用多映射内存,而其他位则由加载和存储屏障来移除和添加。
测试
ZGC 实现使用不同的 C++ 类型来区分无色和着色指针,这确保了这两种类型之间不能进行隐式转换。着色指针仅限于 GC 代码和屏障。只要运行时系统使用 HotSpot 的访问 API 和屏障来访问对象指针,它就只会看到可推导的无色指针。运行时可见的对象指针类型将始终包含无色指针。我们在不同的对象指针类型中注入了广泛的验证代码,以便快速发现指针损坏或缺少屏障的情况。
- 将使用垃圾收集算法的标准测试集来验证其正确性。
风险与假设
实现复杂性
Generational ZGC 中使用的屏障和着色指针比非代际 ZGC 更复杂。Generational ZGC 还同时运行两个垃圾收集器;这些收集器相当独立,但它们以几种复杂的方式相互交互,从而增加了实现的复杂性。
鉴于额外的复杂性,从长远来看,我们打算通过用 Generational ZGC 完全替换原始的、非代际版本的 ZGC 来最小化维护成本。
Generational ZGC 的性能将不同于非代际 ZGC
我们相信,与前身相比,Generational ZGC 将更适合大多数用例。由于资源使用更低,一些工作负载甚至可能会在使用 Generational ZGC 时获得吞吐量提升。例如,在运行Apache Cassandra基准测试时,Generational ZGC 所需的堆大小是四分之一,但吞吐量却是非代际 ZGC 的四倍,同时仍能保持暂停时间在一毫秒以下。
一些工作负载本质上是非代际的,可能会看到性能略有下降。我们认为,这类工作负载的数量足够少,不足以证明长期维护两个版本的 ZGC 的成本是合理的。
另一个开销来源是功能更强大的 GC 屏障。我们预计,由于不需要频繁收集老年代中的对象,这一开销的大部分将被抵消。
另一个开销来源是同时运行两个垃圾收集器。我们需要确保平衡它们的调用率和 CPU 消耗,以免对应用程序造成不利影响。
与 GC 开发一样,未来的改进和优化将基于基准测试和用户反馈。我们打算在首次发布后继续改进 Generational ZGC。