1. 概述
如今,应用程序同时为数千甚至数百万用户提供服务的情况并不少见。此类应用程序需要大量内存,但是,管理所有这些内存可能很容易影响应用程序性能。
为了解决这个问题,Java 11引入了Z垃圾收集器(ZGC)作为实验性垃圾收集器(GC)实现。此外,随着JEP-377的实现,它成为JDK 15中的生产就绪功能。
在本教程中,我们将介绍ZGC如何设法在数TB的堆上保持低暂停时间。
2. 主要概念
要了解ZGC的工作原理,我们需要了解内存管理和垃圾收集器背后的基本概念和术语。
2.1 内存管理
物理内存是我们的硬件提供的RAM。
操作系统(OS)为每个应用程序分配虚拟内存空间。
当然,我们将虚拟内存存放在物理内存中,OS负责维护两者之间的映射关系。这种映射通常涉及硬件加速。
2.2 多重映射
多重映射意味着虚拟内存中存在特定的地址,这些地址指向物理内存中的相同地址。由于应用程序通过虚拟内存访问数据,因此它们对这种机制一无所知(也不需要)。
实际上,我们将虚拟内存的多个范围映射到物理内存中的相同范围:
乍一看,它的用例并不明显,但稍后我们会看到,ZGC需要它来发挥其魔力。此外,它还提供了一些安全性,因为它分隔了应用程序的内存空间。
2.3 重定位
由于我们使用动态内存分配,因此随着时间的推移,普通应用程序的内存会变得碎片化。这是因为当我们释放内存中间的一个对象时,那里还留有一块空闲空间。随着时间的推移,这些间隙不断累积,我们的内存看起来就像一个棋盘,由空闲空间和已用空间的交替区域组成。
当然,我们可以尝试用新对象来填补这些空白。为此,我们应该扫描内存以寻找足够大的可用空间来容纳我们的对象。这样做是一项代价高昂的操作,尤其是当我们每次要分配内存时都必须这样做。此外,内存仍将是碎片化的,因为我们可能无法找到具有所需大小的可用空间。因此,对象之间会存在间隙。当然,这些间隙较小。此外,我们可以尝试最小化这些间隙,但它会使用更多的处理能力。
另一种策略是以更紧凑的格式频繁地将对象从碎片内存区域重新定位到空闲区域。为了更有效,我们将内存空间分成块。我们重新定位一个块中的所有对象,或者不重新定位任何对象。这样,内存分配会更快,因为我们知道内存中有整个空块。
2.4 垃圾收集
当我们创建一个Java应用程序时,我们不必释放我们分配的内存,因为垃圾回收器会为我们做这件事。总之,GC通过引用链监视我们可以从应用程序中访问哪些对象,并释放我们无法访问的对象。
GC需要跟踪堆空间中对象的状态才能完成其工作。例如,一个可能的状态是可达的,这意味着应用程序持有对该对象的引用。此引用可能是可传递的,唯一重要的是应用程序可以通过引用访问这些对象。另一个例子是可终结的:我们无法访问的对象,这些是我们认为垃圾的对象。
为了实现它,垃圾收集器有多个阶段。
2.5 GC阶段属性
GC阶段可以具有不同的属性:
- parallel阶段可以在多个GC线程上运行
- serial阶段在单个线程上运行
- stop-the-world阶段不能与应用程序代码同时运行
- concurrent阶段可以在后台运行,而我们的应用程序会执行它的工作
- incremental阶段可以在完成所有工作之前终止并在以后继续
请注意,上述所有技术都有其优点和缺点。例如,假设我们有一个可以与应用程序同时运行的阶段,此阶段的串行实现需要1%的整体CPU性能并运行1000毫秒。相比之下,并行实现使用30%的CPU并在50毫秒内完成其工作。
在这个例子中,并行解决方案总体上使用了更多的CPU,因为它可能更复杂并且必须同步线程。对于CPU繁重的应用程序(例如批处理作业),这是一个问题,因为我们没有足够的计算能力来完成有用的工作。
当然,这个例子是虚构的。但是,很明显,所有应用程序都有其特点,因此它们对GC的要求也不同。
有关更详细的描述,请访问我们关于Java内存管理的文章。
3. ZGC概念
ZGC打算提供尽可能短的stop-the-world阶段,它以这样一种方式实现它,即这些暂停时间的持续时间不会随着堆大小的增加而增加。这些特性使ZGC非常适合服务器应用程序,在这些应用程序中,大型堆很常见,并且需要快速的应用程序响应时间。
在久经考验的GC技术之上,ZGC引入了新概念,我们将在以下部分中介绍这些概念。
但现在,让我们看一下ZGC工作原理的整体情况。
3.1 蓝图
ZGC有一个称为标记的阶段,在这个阶段,我们可以找到可达对象。GC可以通过多种方式存储对象状态信息,例如,我们可以创建一个Map,其中键是内存地址,值是该地址处对象的状态。这很简单,但需要额外的内存来存储这些信息。此外,维护这样的Map可能具有挑战性。
ZGC使用不同的方法:它将引用状态存储为引用的位,这称为引用着色。但这样一来,我们就面临着新的挑战,设置引用的位来存储对象的元数据意味着多个引用可以指向同一个对象,因为状态位不包含有关对象位置的任何信息。多重映射来拯救!
我们还希望减少内存碎片,ZGC使用重定位来实现这一点。但是对于大型堆,重定位是一个缓慢的过程。由于ZGC不希望有较长的暂停时间,因此它会与应用程序并行执行大部分重定位。但这引入了一个新问题。
假设我们有一个对对象的引用,ZGC重新定位它,并发生上下文切换,应用程序线程运行并尝试通过其旧地址访问该对象。ZGC使用负载屏障来解决这个问题,负载屏障是当线程从堆中加载引用时运行的一段代码-例如,当我们访问对象的非原始字段时。
在ZGC中,负载屏障检查引用的元数据位。根据这些位,ZGC可能会在我们获取引用之前对其执行一些处理。因此,它可能会产生完全不同的引用,我们称之为重新映射。
3.2 标记
ZGC将标记分为三个阶段。
第一阶段是stop-the-world阶段,在这个阶段,我们寻找根引用并标记它们。根引用是到达堆中对象的起点,例如局部变量或静态字段。由于根引用的数量通常很少,所以这个阶段很短。
下一阶段是concurrent,在此阶段,我们从根引用开始遍历对象图,标记我们到达的每个对象。此外,当负载屏障检测到未标记的引用时,它也会对其进行标记。
最后一个阶段也是一个stop-the-world阶段,用于处理一些边缘情况,比如弱引用。
在这一点上,我们知道我们可以到达哪些对象。
ZGC使用marked0和marked1元数据位进行标记。
3.3 引用着色
引用代表一个字节在虚拟内存中的位置,但是,我们不一定必须使用引用的所有位来执行此操作-某些位可以表示引用的属性,这就是我们所说的引用着色。
使用32位,我们可以寻址4GB。由于现在计算机普遍拥有比这更多的内存,我们显然不能使用这32位中的任何一个来着色。因此,ZGC使用64位引用,这意味着ZGC仅适用于64位平台:
ZGC引用使用42位来表示地址本身,因此,ZGC引用可以寻址4TB的内存空间。
最重要的是,我们有4位来存储引用状态:
- finalizable位-对象只能通过终结器访问
- remap位-引用是最新的并指向对象的当前位置(参见重定位)
- marked0和marked1位-用于标记可到达的对象
我们也称这些位为元数据位。在ZGC中,这些元数据位中恰好有一个是1。
3.4 重定位
在ZGC中,重定位包括以下几个阶段:
- concurrent阶段,它查找我们想要重新定位的块,并将它们放入重定位集中
- stop-the-world阶段重新定位重定位集中的所有根引用并更新它们的引用
- concurrent阶段重新定位重定位集中所有剩余的对象,并将旧地址和新地址之间的映射存储在转发表中
- 剩余引用的重写发生在下一个标记阶段,这样,我们就不必遍历对象树两次。或者,负载屏障也可以做到这一点
在JDK 16之前,它通过使用堆保留来执行重定位。但是,从JDK 16开始,ZGC获得了对就地重定位的支持,这有助于避免在完全填满的堆上需要垃圾回收时出现OutOfMemoryError情况。
3.5 重映射和负载屏障
请注意,在重定位阶段,我们没有重写对重定位地址的大部分引用。因此,使用这些引用,我们将无法访问我们想要的对象。更糟糕的是,我们可能访问垃圾。
ZGC使用负载屏障来解决这个问题,负载屏障使用称为重新映射的技术修复指向重定位对象的引用。
当应用程序加载引用时,它会触发负载屏障,然后按照以下步骤返回正确的引用:
- 检查重映射位是否设置为1,如果是,则表示引用是最新的,因此我们可以安全地返回它
- 然后我们检查引用的对象是否在重定位集中,如果不是,那就意味着我们不想重定位它。为避免下次加载此引用时进行此检查,我们将重映射位设置为1并返回更新后的引用
- 现在我们知道我们要访问的对象是重定位的目标,唯一的问题是重定位是否发生?如果对象已被重新定位,我们将跳到下一步。否则,我们现在重新定位它并在转发表中创建一个条目,该条目存储每个重新定位的对象的新地址。在此之后,我们继续下一步
- 现在我们知道该对象已重新定位。通过ZGC,我们在上一步中,或在此对象的早期命中期间的负载屏障。我们将此引用更新为对象的新位置(使用上一步的地址或通过在转发表中查找),设置重映射位,然后返回引用
就是这样,通过上述步骤,我们确保每次尝试访问一个对象时,我们都能获得对它的最新引用。因为每次我们加载引用时,它都会触发加载屏障,因此它会降低应用程序性能,特别是我们第一次访问重定位的对象时。但如果我们想要较短的暂停时间,这是我们必须付出的代价。由于这些步骤相对较快,因此不会显著影响应用程序性能。
4. 如何启用ZGC?
在运行我们的应用程序时,我们可以使用以下命令行选项启用ZGC:
java -XX:+UseZGC <java_application>
但是,在JDK 15之前,ZGC是一个实验性功能,因此我们还需要添加-XX:+UnlockExperimentalVMOptions VM标志以在运行应用程序时使用它:
java -XX:+UnlockExperimentalVMOptions -XX:+UseZGC <java_application>
就是这样!我们可以使用其中一种方法来启用ZGC。但是,通常建议使用最新的JDK LTS版本,以便从最新的修复和功能中受益。
5. 最新功能
在本节中,让我们了解最新版本的JDK中引入的ZGC的一些显著特性。
5.1 NVRAM上的堆
在过去的十年中,NVRAM(非易失性RAM)技术的进步使其速度更快、成本更低。因此,对于许多工作负载来说,将整个Java堆放在NVRAM上是一种经济高效的选择。
随着JEP316的实现,我们可以在JDK 10及更高版本中指定一个备用的内存设备路径来分配Java堆空间:
--XX:AllocateHeapAt=<path>
好消息是ZGC在JDK 15中添加了对NVRAM堆分配的支持。
5.2 亚毫秒级最大暂停时间
ZGC项目的目标之一是最大限度地减少垃圾收集(GC)暂停时间,最初目标为10毫秒。这个目标最终在JDK 16中实现,而现在,ZGC的暂停时间可以提升到亚毫秒级别(小于1ms),并且不会随着堆或根集大小的增加而增加。
ZGC团队实现了一种名为Stack Watermark Barrier的机制来实现这一目标,此机制允许在Java应用程序继续运行时并发扫描线程堆栈。
5.3 压缩类指针和类数据共享
压缩类指针功能通过压缩HotSpot中的对象标头的大小来减少堆使用量,从而允许类指针字段为32位而不是64位。以前,此功能还需要启用压缩Oops,但在JDK 15中,依赖性被打破,允许ZGC独立使用压缩类指针。
此外,类数据共享可减少启动时间和内存占用,现在即使在禁用压缩Oops功能时也可与ZGC配合使用。
5.4 动态GC线程数
JDK的-XX:+UseDynamicNumberOfGCThreads选项使垃圾收集器能够根据工作负载和系统条件动态调整GC线程数,借助ZGC在JDK 17中对此功能的支持,它可以优化线程使用率,以有效地收集垃圾,而不会消耗过多的CPU,从而确保Java线程有更多的CPU时间可用。
此外,-XX:ConcGCThreads选项设置与-XX:+UseDynamicNumberOfGCThreads结合使用时ZGC使用的最大线程数。
5.5 快速JVM终止
在某些情况下,由于与垃圾收集器的协调,终止使用ZGC的Java进程可能需要一段时间。不过,在JDK 17中,ZGC得到了改进,通过中止正在进行的垃圾回收周期,可以按需快速达到安全状态。因此,终止运行ZGC的JVM现在几乎是瞬时的。
6. 总结
在本文中,我们知道ZGC的目标是支持大堆大小和低应用程序暂停时间。为了实现这一目标,它使用了多种技术,包括彩色64位引用、负载屏障、重定位和重新映射。