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),对整体应用性能有影响(吞吐量<15%)。它可以实现这一目标的机制也可以在未来扩展,以支持一些令人兴奋的可能性,例如多层堆,即用于热对象的DRAM和用于不常访问的NVMe闪存,

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,以了解它的性能如何因工作量而异。