Java 11 最近已发布,包含一些非常棒的功能。该版本包含一个全新的垃圾收集器 ZGC,它由 Oracle 开发,承诺在数 TB 的堆上具有非常低的暂停时间。在本文中,我们将介绍新 GC 的动机,技术概述以及 ZGC 开启的一些非常令人兴奋的可能性。
那么为什么需要新的 GC 呢?Java 10 已经搭载了四艘经过多年实战测试且几乎可以无限调优。为了正确看待这一点,G1 最新的 Hotspot GC 是在 2006 年推出的。当时最大的 AWS 实例是最初的 m1.small 包装 1 vCPU 和 1.7GB 内存,今天 AWS 很乐意租给你一个 x1e.32x 大型,128 个 vCPU,令人难以置信的 3,904GB 内存的服务器。ZGC 的设计针对这些容量普遍存在的未来:多 TB 容量,暂停时间低(<10ms),对整体应用性能有影响(吞吐量 GC 术语
为了理解 ZGC 在哪里适合现有的收集器,以及它如何能够做到这一点,我们首先需要回顾一些术语。垃圾收集最基本的工作是确定不再使用的内存,并使其可以重用。现代收藏家是分几个阶段进行这一过程的,他们往往被描述为:
并行 – 在 JVM 运行时,有应用程序线程和垃圾收集器线程。并行阶段是由多个 gc 线程执行的阶段,即工作在它们之间分开。它没有说明 gc 线程是否可能与正在运行的应用程序线程重叠。
串行 – 串行的阶段仅在单个 gc 线程上执行。与上面的并行一样,它没有说明工作是否与当前运行的应用程序线程重叠。
Stop The World – Stop The World 阶段,应用程序线程被暂停,以便 gc 执行其工作。当您遇到 GC 暂停时,这是由于 Stop The World 阶段。
并发 – 如果一个阶段是并发的,那么 GC 可以在应用程序线程正在进行其工作的同时执行其工作。并发阶段很复杂,因为它们需要能够处理应用程序线程,从而可能在阶段完成之前使其工作无效。
增量 – 如果一个阶段是增量的,那么它可以运行一段时间并由于某些条件而提前终止,例如时间预算或需要执行的更高优先级的 gc 阶段,同时仍然完成了生产性工作。这与需要完全完成的阶段形成鲜明对比。
固有的权衡取舍
值得指出的是,所有这些属性都需要权衡利弊。例如,并行阶段将利用多个 gc 线程来执行工作,但这样做会导致线程之间协调的开销。同样,并发阶段不会暂停应用程序线程,但可能涉及更多的开销和复杂性,以处理应用程序线程同时使其工作无效。
ZGC
现在我们了解不同 gc 阶段的属性,让我们探讨 ZGC 的工作原理。为了实现其目标,ZGC 使用了 Hotspot Garbage Collectors 的两种新技术:彩色指针和负载障碍。
指针着色
指针着色是一种将信息存储在指针(或 Java 术语,引用)本身中的技术。这是可能的,因为在 64 位平台上(ZGC 仅为 64 位),指针可以处理比系统实际拥有的内存更多的内存,因此可以使用其他一些位来存储状态。ZGC 将自己限制在需要 42 位的 4Tb 堆中,只留下 22 位可能的状态,目前它使用 4 位:finalizable、remap、mark0 和 mark1。我们稍后会解释它们的用途。
指针着色的一个问题是,当您需要取消引用指针时,它可以创建额外的工作,因为您需要屏蔽掉信息位。像 SPARC 这样的平台有内置硬件支持指针屏蔽所以它不是问题,但对于 x86,ZGC 团队使用了一个简洁的多映射技巧。
多重映射
要了解多映射的工作原理,我们需要简要解释虚拟和物理内存之间的区别。物理内存是系统可用的实际内存,通常是安装的 DRAM 芯片的容量。虚拟内存是抽象,意味着应用程序有自己的(通常是隔离的)视图到物理内存。操作系统负责维护虚拟内存和物理内存范围之间的映射,它通过使用页表和处理器的内存管理单元(MMU)和转换后备缓冲区(TLB)来实现这一点,后者转换应用程序请求的地址。
多映射涉及将不同范围的虚拟内存映射到同一物理内存。由于设计只有一个重映射,mark0 和 MARK- 1 可以在任何时间点为 1,这是可能的三个映射做到这一点。ZGC 源代码中有一个很好的图表。
负载障碍
负载障碍是每当应用程序线程从堆加载引用时运行的代码片段(即访问对象上的非原始字段):
void printName(Person person) {
String name = person.name; // would trigger the load barrier
// because we’ve loaded a reference
// from the heap
System.out.println(name); // no load barrier directly
}
在上面的代码中,分配名称的行包括跟随对堆上对象数据的 person 引用,然后将引用加载到它包含的名称。此时触发负载屏障。触发打印到屏幕的第二行不会直接导致加载障碍触发,因为没有来自堆的引用加载 – 名称是局部变量,因此没有从堆加载引用。但是,引用 System 和 out,或者 println 内部可能会触发其他负载障碍。这与其他 GC 使用的写屏障形成对比,例如 G1。加载屏障的工作是检查引用的状态,并在将引用(或者甚至是不同的引用)返回给应用程序之前执行一些工作。在 ZGC 中,它通过测试加载的引用来执行此任务,以查看是否设置了某些位,具体取决于当前阶段。如果引用通过测试,则不执行任何其他工作,如果失败,则在将引用返回给应用程序之前执行某些特定于阶段的任务。
Marking
现在我们了解了这两种技术是什么,让我们看看 ZGC GC 循环。循环的第一部分是标记。标记涉及到以某种方式查找和标记所有堆对象,这些对象可以被正在运行的应用程序访问,换句话说,查找不是垃圾的对象。
ZGC 的标记分为三个阶段。第一个阶段是 Stop The World 阶段,在这个阶段中,GC 的根被标记为存在。GC 根类似于局部变量,应用程序可以使用它访问堆上的其他对象。如果对象不能通过从根开始的对象图遍历来访问,那么应用程序就无法访问它,它被认为是垃圾。根中可访问的对象集称为活动集。GC 根标记步骤非常短,因为根的总数通常相对较小。
一旦该阶段完成,应用程序将继续,ZGC 将开始下一个阶段,该阶段将同时遍历对象图并标记所有可访问对象。在此阶段,load barrier 测试所有装载的引用,它将根据一个掩码测试它们是否已经被标记,如果一个引用还没有被标记,那么它将被添加到一个队列中以进行标记。
在遍历完成之后,会有一个最后的,简短的,Stop The World 阶段,它处理一些边缘情况(我们暂时忽略它),然后标记完成。
Relocation
GC 循环的下一个主要部分是重新定位。重定位包括移动活动对象,以便释放堆的各个部分。为什么要移动对象而不只是填补空白? 一些 GCs 确实这样做了,但它的不幸后果是,分配变得更加昂贵,因为当需要分配时,分配程序需要找到放置对象的空闲空间。相反,如果可以释放大量内存,那么分配就会简单地按照对象所需的内存数量递增 (或“碰撞”) 指针。
ZGC 将堆划分为页面,在此阶段的开始,它会同时选择一组页面,这些页面的活动对象需要重新定位。当选择重定位集时,有一个 Stop(Stop The World),ZGC 将重定位重定位集中作为根 (局部变量等) 引用的任何对象,并将其引用重新映射到新位置。与前面的 Stop the World 步骤一样,这里所涉及的暂停时间仅取决于根的数量和重定位集的大小与活动对象的总大小的比例,而这通常是相当小的。它不像许多收集器那样根据堆的整体大小进行缩放。
在移动任何必要的根之后,下一个阶段是并发重新定位。在此阶段,GC 线程遍历重定位集并重新定位它所包含的页面中的所有对象。应用程序线程也可以重新定位重定位集中的对象,如果它们试图在 GC 重新定位对象之前加载它们,那么这可以通过负载屏障 (从堆中加载引用时触发) 实现,详见以下流程图:
这可确保应用程序看到的所有引用都已更新,并且应用程序不可能对同时重定位的对象进行操作。
GC 线程最终将重定位重定位集中的所有对象,可能仍有引用指向这些对象的旧位置。GC 可以遍历对象图并重新映射所有对其新位置的引用,但这是一个昂贵的步骤。相反,这与下一个标记阶段相结合。在步行期间,如果发现未重新映射引用,则将其重新映射,然后标记为活动。
Recap
试图单独理解复杂垃圾收集器(如 ZGC)的性能特征是很困难的,但从前面的部分可以清楚地看出,我们所涵盖的几乎所有暂停都涉及依赖于 GC 根集合的工作,而不是实时的对象集,堆大小或垃圾。处理标记终止的标记阶段的最后一次暂停是一个例外,但是是增量的,如果超过时间预算直到再次尝试,GC 将恢复为并发标记。
性能
那它是如何表现的?Stefan Karlsson 和 Per Liden 在今年早些时候的 Jfokus 演讲中给出了一些初步数字。ZGC 的 SPECjbb 2015 吞吐量数据与 Parallel GC(优化吞吐量)大致相当,但平均暂停时间为 1ms,最长为 4ms。这与平均暂停时间超过 200 毫秒的 G1 和平行相反。
然而,垃圾收集器是复杂的野兽,基准测试结果可能无法推广到真实世界的性能。我们期待自己测试 ZGC,以了解它的性能如何因工作量而异。