ZGC:Java垃圾回收的变革者

172 阅读23分钟

一、引言:ZGC 为何引发关注

在 Java 虚拟机(JVM)的世界里,垃圾回收器(Garbage Collector)一直是影响应用性能的关键因素。随着 Java 应用的日益复杂和数据量的不断增长,对垃圾回收器的性能要求也越来越高。ZGC(Z Garbage Collector)作为一款在 Java 11 中引入的可伸缩低延迟垃圾收集器,自诞生以来就备受关注。它以其卓越的低延迟和高并发性能,为 Java 开发者们带来了全新的垃圾回收体验,在云计算、大数据处理等对内存管理要求极高的场景中,发挥着越来越重要的作用,成为 Java 垃圾回收领域的一颗璀璨新星 。

二、ZGC 解决的痛点

传统 GC 的困境

在深入探讨 ZGC 之前,我们先来回顾一下传统垃圾回收器所面临的困境。以 CMS(Concurrent Mark Sweep)收集器为例,它是一款以获取最短回收停顿时间为目标的老年代垃圾回收器 ,在并发标记和并发清理阶段,它能与应用程序线程并发执行,以此来减少停顿时间。但它也存在诸多问题,比如会产生内存碎片,因为它采用的是 “标记 - 清除” 算法,清理垃圾对象后容易留下不连续的内存空间。这就导致在分配大对象时,可能因无法找到连续空间而触发 Full GC,进而增加停顿时间。而且 CMS 在并发阶段会与应用程序线程争夺 CPU 资源,对应用性能产生影响,特别是在多核处理器上,其并发阶段的 CPU 使用率可能相当高。此外,由于并发标记阶段与应用程序线程并行运行,新产生的 “浮动垃圾” 可能来不及被标记,导致在并发清除阶段结束后仍有未清理的对象,为了处理这些垃圾,CMS 不得不定期进行 “并发模式失败”(Concurrent Mode Failure,CMF)的 Full GC,这会导致长时间的停顿。

再看 G1(Garbage First)垃圾回收器,虽然它在很多方面有了改进,比如将堆划分为多个大小相等的区域(region),能独立地回收这些区域,通过设置期望的最大停顿时间,能更好地适应不同应用的需求 。但在大内存、高并发场景下,G1 也存在一些问题。当堆内存非常大时,G1 的转移阶段(将存活对象复制到其他区域)可能会导致较长的停顿时间,因为这个过程涉及对象的复制和引用更新。在高并发场景下,G1 的回收效率可能无法满足需求,例如在一些需要处理海量数据的实时计算场景中,G1 的停顿时间可能会影响数据处理的实时性。

ZGC 的突破

ZGC 的出现,成功解决了传统 GC 的诸多痛点。在停顿时间方面,ZGC 通过独特的设计,将停顿时间控制在极低的水平,目标是确保 GC 停顿时间不超过 10 毫秒,无论堆的大小是多少。这是因为 ZGC 采用了并发标记、并发转移等技术,使得垃圾回收过程中大部分工作都可以与应用程序线程并发执行,大大减少了因垃圾回收导致的应用停顿。

在处理内存碎片问题上,ZGC 采用了 “标记 - 整理” 算法,在每次回收后都会进行内存压缩,确保内存空间的连续性,减少了内存碎片的产生。这使得 ZGC 在处理大对象分配时更加高效,不会因为内存碎片而频繁触发 Full GC。

ZGC 还具备强大的可扩展性,能够支持从几百兆到 16TB 的堆大小,并且堆大小对其停顿时间的影响微乎其微。这使得 ZGC 在面对大规模数据处理和高并发场景时,能够游刃有余地发挥其性能优势,为应用提供稳定、高效的内存管理服务。

三、ZGC 的实现原理

核心技术

ZGC 之所以能在垃圾回收性能上取得巨大突破,离不开其背后的一系列核心技术,其中染色指针、读屏障和内存多重映射技术发挥了关键作用 。

染色指针(Colored Pointers) :在 64 位系统中,指针通常占用 8 个字节(64 位),但实际上大部分应用程序使用的内存远小于指针所能表示的范围,这就导致指针的高位有很多未使用的位。ZGC 利用了这一特性,将指针的高 4 位作为颜色标志位,与低 44 位表示的实际内存地址共同构成染色指针 。这 4 位颜色标志位可以存储丰富的对象状态信息,比如 Marked0 和 Marked1 用于标记对象是否可达,在垃圾回收的标记阶段,通过这两个标志位来标识对象的存活状态;重映射位(Remap)表示在垃圾收集转移过程后,对象的引用关系是否已经更新;终结位表示该对象是否只能通过终结器(Finalizer)进行访问 。通过染色指针,ZGC 可以直接从指针中获取对象的状态信息,无需额外的数据结构来存储这些信息,大大减少了元数据的开销,并且能够快速判断对象是否被标记、是否被移动等,为并发垃圾回收提供了有力支持。

读屏障(Load Barrier) :读屏障是一种在程序读取对象引用时执行的特殊代码片段。在 ZGC 中,读屏障主要有以下几个作用:当指针指向已经被转移的对象时,读屏障会修正该指针,使其指向新的位置,确保应用程序能够访问到正确的对象;在标记阶段,如果读取到的指针未被标记,读屏障会标记该指针,协助完成对象的标记工作;在转移阶段,如果指针指向需要转移的区域,读屏障会将该指针指向的对象进行转移,并修正指针 。读屏障的存在,使得 ZGC 在垃圾回收线程与应用程序线程并发运行的情况下,每次指针载入都能保证访问到正确的对象,避免了因对象移动或状态变化导致的访问错误,确保了垃圾回收过程中对象引用的一致性和正确性 。

内存多重映射(Memory Multi-Mapping) :ZGC 采用内存多重映射技术,将多个不同的虚拟内存地址映射到同一个物理内存地址上。这是因为染色指针技术改变了指针的结构,操作系统和处理器可能无法直接识别和处理这些带有颜色标志位的指针 。通过内存多重映射,ZGC 将染色指针中的标志位看作是地址的分段符,把不同的地址段都映射到同一个物理空间,使得经过多重映射转换后的染色指针能够正常寻址,从而解决了染色指针与操作系统和处理器的兼容性问题,保证了 ZGC 在内存管理上的高效性和正确性 。

这三种核心技术相互配合,染色指针为读屏障提供了快速判断指针状态的依据,读屏障利用染色指针的状态信息来修正指针,确保对象访问的正确性,而内存多重映射则解决了染色指针在系统层面的寻址问题,它们共同支撑起了 ZGC 高效的并发垃圾回收机制。

标记 - 压缩策略

ZGC 采用的标记 - 压缩策略是其实现高效垃圾回收的关键算法。

标记阶段:ZGC 的标记阶段采用了并发标记的方式,尽可能减少对应用程序的影响。在标记开始时,会有一个短暂的暂停(Pause Mark Start),这个暂停主要是为了标记 GC Roots,即那些可以作为对象可达性分析起点的对象,比如线程栈中的局部变量、静态变量等 。由于 GC Roots 的数量相对较少,所以这个暂停时间非常短。之后进入并发标记阶段(Concurrent Mark),垃圾回收线程会与应用程序线程同时运行,从 GC Roots 开始,沿着对象的引用链遍历整个堆内存,标记出所有存活的对象 。在这个过程中,读屏障会发挥作用,如果读取到未被标记的对象指针,读屏障会将其标记。标记阶段会交替使用 Marked0 和 Marked1 这两个标志位来标记对象,这样可以避免在并发标记过程中因对象状态的改变而导致的重复标记或遗漏标记问题 。标记阶段结束时,会有另一个短暂的暂停(Pause Mark End),用于处理一些边界情况,确保标记的完整性。

压缩阶段:在标记阶段完成后,ZGC 会确定需要进行内存压缩的区域。ZGC 采用并发转移(Concurrent Relocate)的方式来实现内存压缩,这也是其减少停顿时间的关键所在 。在并发转移之前,会有一个暂停(Pause Relocate Start),用于通知所有涉及对象转移的线程。然后进入并发转移阶段,ZGC 会将存活对象从碎片化的区域复制到新的、连续的区域,从而消除内存碎片 。在转移过程中,每个被转移的对象都会在转发表(Forwarding Table)中记录其新旧地址的映射关系。当应用程序线程访问旧地址的对象时,读屏障会通过转发表将指针修正为新地址,保证应用程序能够正确访问到转移后的对象 。ZGC 在转移对象时,会优先选择碎片化程度较高的区域进行转移,以最大程度地提高内存利用率。在转移完成后,原来的区域就可以被释放和重用,实现了内存的高效管理 。通过这种标记 - 压缩策略,ZGC 在保证低停顿时间的同时,有效地解决了内存碎片问题,为应用程序提供了更稳定、高效的内存环境 。

四、ZGC 的执行步骤

初始标记

初始标记(Initial Mark)是 ZGC 垃圾回收过程的起始阶段 ,其主要任务是标记出所有的根对象(GC Roots),这些根对象包括线程栈上的引用、静态变量以及一些特殊的对象 。这个阶段需要暂停应用线程(STW),因为在标记根对象时,必须确保根对象的引用关系不会发生变化,否则可能会导致标记不准确 。不过,由于 GC Roots 的数量相对较少,并且只是简单地标记这些根对象,所以初始标记阶段的停顿时间非常短,一般情况下,其处理时间与 GC Roots 的数量成正比,而与堆的大小或者活跃对象的大小无关 。例如,在一个拥有少量线程和静态变量的 Java 应用中,初始标记阶段可能只需要几毫秒甚至更短的时间就能完成 。这个阶段就像是在一片茂密的森林中找到了所有的大树(根对象),为后续的垃圾回收工作奠定了基础 。

并发标记

在初始标记完成后,ZGC 进入并发标记(Concurrent Mark)阶段 。在这个阶段,垃圾回收线程会与应用程序线程同时运行,从初始标记阶段标记的根对象出发,通过深度优先遍历的方式,沿着对象的引用链遍历整个堆内存,标记出所有存活的对象 。在并发标记过程中,读屏障发挥着关键作用。由于应用程序线程在不断运行,可能会产生新的对象引用或者修改现有对象的引用关系,这就可能导致在标记过程中出现对象引用的变化。读屏障会在应用程序线程读取对象引用时被触发,如果读取到的指针未被标记,读屏障会将其标记,确保所有存活对象都能被正确标记 。例如,当应用程序线程读取一个对象的成员变量引用时,读屏障会检查该引用是否已被标记,如果未标记,则将其标记为存活对象 。在并发标记阶段,ZGC 会交替使用 Marked0 和 Marked1 这两个标志位来标记对象,这样可以避免在并发标记过程中因对象状态的改变而导致的重复标记或遗漏标记问题 。整个并发标记阶段的耗时相对较长,因为它需要遍历整个堆内存,但由于是与应用程序线程并发执行,所以不会对应用程序的正常运行造成明显的停顿 。

再标记

尽管并发标记阶段已经尽可能全面地标记了存活对象,但由于应用程序线程在并发标记期间一直在运行,可能会产生新的对象,这些新对象在并发标记阶段并未被标记 。因此,ZGC 需要进行一次再标记(Remark)阶段,以标记并更新在并发标记期间产生的新对象 。这个阶段同样需要暂停应用线程(STW),但 ZGC 通过一系列优化措施,尽量缩短了再标记阶段的停顿时间 。ZGC 会使用 SATB(Snapshot-At-The-Beginning)算法来处理在并发标记阶段对象引用关系的变化 。SATB 算法在并发标记开始时,会对对象引用关系进行一次快照,记录下当时的对象引用状态 。在再标记阶段,通过对比快照和当前的对象引用关系,就可以快速找出在并发标记期间产生的新对象,并对其进行标记 。ZGC 还会对一些热点代码路径进行优化,减少在再标记阶段对这些路径的扫描次数,从而进一步缩短停顿时间 。一般来说,再标记阶段的 STW 时间很短,最多 1ms,超过 1ms 则再次进入并发标记阶段 。例如,在一个高并发的 Web 应用中,虽然在并发标记期间会产生大量新的对象,但通过 SATB 算法和优化措施,再标记阶段的停顿时间可以控制在非常短的时间内,几乎不会对用户请求的响应时间产生影响 。

并发转移准备

并发转移准备(Concurrent Prepare for Relocate)阶段是 ZGC 为后续的并发转移做准备的重要阶段 。在这个阶段,ZGC 会处理与并发标记阶段重叠的一些操作,例如更新引用和处理根对象 。ZGC 会根据特定的查询条件统计得出本次收集过程要清理哪些 Region,将这些 Region 组成重分配集(Relocation Set) 。ZGC 每次回收都会扫描所有的 Region,用范围更大的扫描成本换取省去 G1 中记忆集的维护成本 。重分配集的确定非常关键,它决定了哪些 Region 中的存活对象会被复制到其他的 Region 。在这个阶段,ZGC 还会对一些对象的引用进行更新,确保在后续的并发转移阶段,对象的引用能够正确指向新的位置 。JDK 12 的 ZGC 中开始支持的类卸载以及弱引用的处理,也是在这个阶段完成的 。例如,在一个大型的分布式系统中,可能存在大量的类和对象,通过并发转移准备阶段的处理,可以确保在并发转移阶段,这些类和对象能够被正确地处理,提高系统的稳定性和性能 。

初始转移与并发转移

初始转移(Initial Relocate)阶段是并发转移的前期步骤,它需要暂停应用线程(STW) 。在这个阶段,ZGC 会将部分存活对象从旧的内存区域转移到新的内存区域 。具体来说,ZGC 会从根对象出发,找到那些位于重分配集中的存活对象,并将它们复制到新的区域 。初始转移阶段主要处理根对象直接引用的存活对象,其处理时间和 GC Roots 的数量成正比,一般情况耗时非常短 。例如,在一个拥有一定数量根对象的 Java 应用中,初始转移阶段可能只需要几毫秒就能完成对根对象直接引用的存活对象的转移 。

并发转移(Concurrent Relocate)阶段是 ZGC 执行过程中的核心阶段,在这个阶段,ZGC 会在后台并发地处理剩余存活对象的转移 。ZGC 会将重分配集中的存活对象复制到新的 Region 上,并为重分配集中的每个 Region 维护一个转发表(Forwarding Table),记录从旧对象到新对象的转向关系 。当应用程序线程访问旧地址的对象时,读屏障会通过转发表将指针修正为新地址,保证应用程序能够正确访问到转移后的对象 。这就是 ZGC 的指针 “自愈”(Self-Healing)能力,它使得只有第一次访问旧对象时会因为需要查询转发表而变慢,后续访问则可以直接通过修正后的指针快速访问新对象 。一旦重分配集中某个 Region 的存活对象都复制完毕后,这个 Region 就可以立即释放用于新对象的分配,但是转发表还得留着不能释放掉,因为可能还有访问在使用这个转发表 。在一个高并发的电商应用中,大量的对象在并发转移阶段被转移到新的内存区域,通过转发表和读屏障的配合,应用程序线程能够在对象转移过程中继续正常访问对象,保证了电商交易的实时性和流畅性 。

五、ZGC 的调优方案

堆大小设置

堆大小的设置是 ZGC 调优的关键环节,直接影响着应用的性能和内存使用效率 。在设置堆大小参数(-Xmx 和 - Xms)时,我们需要充分考虑应用的内存需求和对象分配速率 。

假设我们有一个电商应用,在促销活动期间,订单数据量会大幅增加,对象分配速率急剧上升。如果堆大小设置过小,当对象分配速率超过堆内存的可用空间时,ZGC 就需要更频繁地运行来回收内存,这会导致频繁的垃圾回收,增加停顿时间,影响用户体验,比如可能会出现页面加载缓慢、订单提交延迟等问题 。相反,如果堆大小设置过大,虽然可以减少垃圾回收的频率,但会造成内存资源的浪费,这对于一些对内存资源有限的服务器环境来说是不可取的 。

为了确定合适的堆大小,我们可以通过监控工具,如 Java Mission Control(JMC),来分析应用的内存使用情况。JMC 可以实时监控堆内存的使用量、对象分配速率等指标 。通过一段时间的监控,我们可以得到应用在不同负载下的内存使用峰值和平均对象分配速率 。例如,经过监控发现,在正常业务量下,应用的活跃对象占用内存约为 2GB,而在促销活动等高并发场景下,活跃对象占用内存可能会达到 4GB,并且对象分配速率在高并发时会达到每秒 500MB 。那么,为了保证在高并发场景下堆中有足够的内存分配新对象,同时又避免内存浪费,我们可以将最大堆大小(-Xmx)设置为 8GB,初始堆大小(-Xms)设置为 4GB 。这样,在正常业务量下,堆内存有足够的空间供对象分配,而在高并发场景下,堆内存也能满足对象分配的需求,同时又不会浪费过多内存 。

并发 GC 线程数调整

并发 GC 线程数的调整对于平衡 GC 线程和应用线程对 CPU 资源的竞争至关重要 。在 ZGC 中,我们可以通过 - XX:ConcGCThreads 参数来调整并发 GC 线程数 。

以一个在线游戏服务器为例,该服务器需要处理大量玩家的实时请求,对并发性能要求极高 。如果并发 GC 线程数设置过多,比如设置为 CPU 核数的 50%,在垃圾回收的并发标记和并发转移阶段,GC 线程会占用大量的 CPU 资源,导致应用线程能够使用的 CPU 资源减少,这可能会使游戏服务器处理玩家请求的速度变慢,出现玩家操作延迟、卡顿等问题 。相反,如果并发 GC 线程数设置过少,比如只设置为 CPU 核数的 5%,那么垃圾回收的速度会变慢,对象的分配速率可能会超过垃圾收集的速率,最终导致应用线程停下来等待 GC 线程完成垃圾收集并释放内存,同样会影响游戏的流畅性 。

一般来说,ZGC 具有内置启发式功能,可以根据应用的特征自动选择最佳线程数,默认是核数的 12.5% 。但对于一些对延迟非常敏感的应用,我们需要根据实际情况进行调整 。在上述在线游戏服务器的例子中,经过测试,我们发现将并发 GC 线程数设置为 CPU 核数的 10% 时,既能保证垃圾回收的效率,又不会对应用线程的 CPU 资源造成过多抢占,游戏服务器的性能表现最佳,玩家的游戏体验也得到了保障 。从 JDK 17 开始,ZGC 引入了并发 GC 线程数的动态缩放,它可以根据工作负载自动调整线程数量,这在一定程度上减少了手动调整该参数的必要性,但对于一些特殊的应用场景,手动调整仍然是优化性能的重要手段 。

其他调优参数

除了堆大小和并发 GC 线程数,还有一些其他调优参数也能对 ZGC 性能产生重要影响 。

启用大页面(-XX:+UseLargePages) :大页面(也称为巨页)是大于标准页面大小的内存页面,在 Linux/x86 系统上,大页面的大小通常为 2MB 。启用大页面可以减少内存管理的开销,提高内存访问效率 。当应用程序频繁访问内存时,使用大页面可以减少页面切换的次数,从而提高系统的整体性能 。在一个大数据处理应用中,需要频繁读取和写入大量的数据,启用大页面后,内存访问效率得到了显著提升,数据处理的速度明显加快 。启用大页面需要在操作系统级别进行一些配置,如将内存分配给大页面池和设置 Hugetlbfs 文件系统等 。

启用透明大页面(-XX:+UseTransparentHugePages) :透明大页面(THP)是 Linux 内核中的一项功能,它能自动将标准内存页聚合为更大、更高效的巨页 。启用透明大页可以让应用程序从大页中受益,而不需要手动管理大页内存 。在一个高并发的 Web 应用中,启用透明大页后,系统的内存管理效率得到了提高,Web 服务器能够更快地响应用户请求 。需要注意的是,THP 在某些情况下可能会引入延迟峰值,不太适合对延迟敏感的应用程序,在启用 THP 之前,建议先评估其对特定工作负载和性能要求的影响 。

启用 NUMA 支持(-XX:+UseNUMA) :NUMA(Non - Uniform Memory Access)是一种多处理器计算架构,在这种架构中,计算机系统的内存被分为多个区域或 “节点”,每个处理器都与一个或多个内存区域直接关联,每个处理器对自己本地内存节点的访问速度比对远程内存节点的访问速度更快 。ZGC 支持 NUMA,默认情况下会启用该支持,它会尽力将 Java 堆分配定向到 NUMA 本地内存 。在一个多插槽 x86 服务器上运行的分布式数据库应用中,启用 NUMA 支持后,数据库的读写性能得到了明显提升,因为数据的存储和访问更靠近处理器的本地内存,减少了内存访问的延迟 。如果 JVM 检测到它只能使用单个 NUMA 节点上的内存,NUMA 支持将自动被禁用 。在大多数情况下,不需要显式配置 NUMA 支持,但如果想覆盖 JVM 的决定,可以使用 - XX:+UseNUMA 或 - XX:-UseNUMA 选项 。

六、总结

ZGC 作为 Java 垃圾回收领域的重大创新,以其卓越的性能和独特的设计,成功解决了传统垃圾回收器在停顿时间、内存碎片和可扩展性等方面的痛点。通过染色指针、读屏障和内存多重映射等核心技术,ZGC 实现了高效的并发垃圾回收,其标记 - 压缩策略确保了内存的有效管理和碎片的减少。在执行步骤上,从初始标记到并发转移的各个阶段,ZGC 都尽可能地减少了对应用程序的影响,将停顿时间控制在极低水平。在调优方面,合理设置堆大小、并发 GC 线程数等参数,以及正确使用其他调优参数,能够进一步提升 ZGC 的性能,使其更好地适应不同应用场景的需求 。