简介

如今,应用程序同时服务成千上万甚至数百万个用户的情况并不少见。这样的应用程序需要大量的内存。但是,管理所有这些内存可能很容易影响应用程序性能。为了解决此问题,Java 11引入了Z垃圾收集器(ZGC)作为实验性垃圾收集器(GC)的实现。在本教程中,我们将看到ZGC如何在多个TB的堆上设法保持低暂停时间。

ZGC概念

ZGC计划在尽可能短的时间内stop-the-world。它以这样的方式实现它:这些暂停时间的持续时间不会随着堆大小的增加而增加。这些特性使ZGC非常适合服务器应用程序,在这些应用程序中,大堆很常见,因此要求快速的应用程序响应时间。

在久经考验的GC技术之上,ZGC引入了新概念,我们将在以下各节中进行介绍。

现在,让我们看一下ZGC工作原理的整体情况。

全局

ZGC有一个称为标记的阶段,我们在其中找到可到达的对象。GC可以通过多种方式存储对象状态信息。例如,我们可以创建一个Map,其中的键是内存地址,值是该地址处对象的状态。这很简单,但是需要额外的内存来存储此信息。而且,维护这样的Map可能具有挑战性。

ZGC使用另一种方法:将引用状态存储为引用的位(bits)。 这称为引用着色。但是这样,我们面临着新的挑战。将引用的位设置为存储有关对象的元数据意味着,由于状态位不保存有关对象位置的任何信息,因此多个引用可以指向同一对象。

我们还希望减少内存碎片。ZGC使用重定位来实现此目的。但是,对于大堆来说,重定位是一个缓慢的过程。由于ZGC不需要很长的暂停时间,因此它与应用程序并行地执行大多数重定位。 但这引入了新的问题。

假设我们有一个对象的引用。ZGC重新定位它,然后发生上下文切换,应用程序线程在其中运行并尝试通过其旧地址访问该对象。ZGC使用加载屏障(load barriers)来解决此问题。加载屏障是一段代码,当线程从堆中加载引用时(例如,当我们访问对象的非原始字段时),该代码就会运行。

在ZGC中,加载屏障会检查引用的元数据位。根据这些位,ZGC可能会在获取引用之前对引用执行一些处理。 因此,它可能会产生完全不同的引用。我们称此为重映射。

标记

ZGC将标记分为三个阶段。

第一阶段是stop-the-world阶段。在此阶段,我们寻找根引用(root)并对其进行标记。根引用是到达堆中对象(例如局部变量或静态字段)的起点。由于根引用的数量通常很少,因此此阶段很短。

下一阶段是并发(concurrent)。在此阶段,我们从根引用开始遍历对象图(object graph)。我们标记我们到达的每个对象。 同样,当加载屏障检测到未标记的引用时,也会对其进行标记。

最后一个阶段也是stop-the-world阶段,以处理一些边缘情况,例如弱引用。

至此,我们知道可以到达哪些对象。

引用着色

引用表示虚拟内存中字节的位置。但是,我们不必一定要使用引用的所有位来执行操作 – 有些位可以表示引用的属性。 这就是我们所说的引用着色。

使用32位,我们可以寻址4 GB。由于当今计算机的内存要比这更多,所以我们显然不能使用这32位中的任何一个进行着色。因此,ZGC使用64位引用。这意味着ZGC仅在64位平台上可用:

zgc-pointer

ZGC引用使用42位来表示地址本身。所以,ZGC引用可以寻址4TB的内存空间。

最重要的是,我们有4位存储引用状态:

  • 可终结位 – 仅可通过终结器(finalizer)访问该对象
  • 重新映射位 – 引用是最新的,指向对象的当前位置(请参见重定位)
  • marked0marked1位(bits) – 用于标记可达对象

重定位

在ZGC中,重定位包括以下阶段:

  1. 一个并发阶段,它查找块,我们要重定位并将它们放入重定位集(set)中。
  2. stop-the-world阶段将所有根引用重定位到重定位集中并更新其引用。
  3. 并发阶段重定位重定位集中的所有其余对象,并将旧地址和新地址之间的映射存储在转发表(forwarding table)中。
  4. 其余引用的重写将在下一个标记阶段进行。这样,我们不必遍历对象树两次。另外,加载屏障也可以做到。

重映射和加载屏障

请注意,在重定位阶段,我们没有将大多数引用重写为重定位后的地址。因此,使用这些引用我们将无法访问想要的对象。更糟糕的是,我们可以访问到垃圾(非期望的内容)。

ZGC使用加载屏障来解决此问题。加载屏障使用一种称为重新映射的技术来修复指向重定位对象的引用。

当应用程序加载引用时,它将触发加载屏障,然后屏障将按照以下步骤返回正确的引用:

  1. 检查重映射位是否设置为1。如果是,则表示引用是最新的,因此我们可以安全地返回它。
  2. 然后,我们检查所引用的对象是否在重定位集中。如果不是,那意味着我们不想重新安置它。为了避免下一次加载此引用时进行此检查,我们将重映射位设置为1并返回更新的引用。
  3. 现在我们知道我们要访问的对象是重定位的目标。唯一的问题是重定位是否发生?如果对象已重定位,则跳到下一步。否则,我们现在将其重新放置,并在转发表中创建一个条目(entry),该条目存储每个重新放置的对象的新地址。此后,我们继续下一步。
  4. 现在我们知道对象已重定位。要么是ZGC,要么是上一步中的操作,要么是此对象的较早命中时的加载屏障。我们将此引用更新为对象的新位置(使用上一步中的地址或通过在转发表中查找该地址),设置重映射位,然后返回引用。

就是这样,通过上面的步骤,我们确保了每次尝试访问对象时,我们都会获得对其最新的引用。由于每次我们加载引用时,都会触发加载屏障。因此,它降低了应用程序的性能。特别是第一次访问重定位的对象时。但这是我们要缩短暂停时间必须付出的代价。而且由于这些步骤相对较快,因此不会显著影响应用程序的性能。

如何启用ZGC

在运行应用程序时,我们可以使用以下命令行选项启用ZGC:

-XX:+UnlockExperimentalVMOptions -XX:+UseZGC

请注意,由于ZGC是实验性GC,因此需要一段时间才能获得正式支持。

总结

在本文中,我们看到ZGC打算以较短的应用程序暂停时间来支持大堆。为了实现此目标,它使用了多种技术,包括着色64位引用,加载屏障,重定位和重新映射。

An Introduction to ZGC: A Scalable and Experimental Low-Latency JVM Garbage Collector