Java ZGC 深度剖析及其在构建低延迟流系统中的实践心得

01

前言

在 Java 应用程序中,垃圾回收(Garbage Collection,以下简称 GC)是一个不可避免的过程,它负责释放不再使用的内存空间以避免内存泄漏。然而,GC 操作通常会导致短暂的停顿时间(Stop the World,以下简称 STW),这对于对延迟敏感的应用程序来说是一个严重的问题——STW 会导致应用程序暂停响应,从而影响用户体验和系统性能。为了解决这个问题,Java 引入了 Z Garbage Collector(以下简称 ZGC),它是一种低延迟垃圾回收器,旨在减少 GC 引起的停顿时间。ZGC 通过使用并发和分区收集技术,大大减少了 STW 的时间和频率,使得应用程序可以在 GC 期间继续运行,从而提供更加平滑和一致的性能。AutoMQ 基于 ZGC 进行了一系列调优,以获得更低的延迟。在本文中,我们将详细介绍 ZGC 的工作原理,以及如何通过调整和优化 ZGC 的配置来实现更低的延迟,从而提高 Java 应用程序的性能和响应能力。

02

ZGC 特点

在介绍 ZGC 的实现原理之前,我们先来了解一下 ZGC 的特点,以便更好地理解 ZGC 的工作原理:

  • 可扩展性:ZGC 支持各种规模的内存大小,从 8MB 到 16TB,可以满足不同规模和需求的应用程序。

  • 极低延迟:单次 GC 操作 STW 时间低于 1ms(一般不超过 200 μs),平均仅需数十微秒。

  • 可预测性:ZGC 的 STW 时长不会随着堆大小的增加、对象数量的增加或者 GC 操作的频率而增加,因此可以提供可预测的性能。

  • 高吞吐量:ZGC 的吞吐量与 G1GC 相当,可以满足高吞吐量的应用程序需求。

  • 自动调优:ZGC 会自动调整自身的配置参数,以适应不同的应用程序和环境,减少了手动调优的工作量。

03

ZGC 工作原理

下面我们将详细介绍 ZGC 的工作原理,以便更好地理解 ZGC 的优势和特点。

注意:以下介绍均基于 JDK 17 版本的 ZGC,部分内容可能与其他版本有所不同,例如,没有涉及到 JDK 21 中引入的分代(Generational)ZGC。

3.1 核心概念

着色指针与多重映射
ZGC 使用了一种称为“着色指针(Colored Pointers,又称染色指针)”的技术,它将对象指针的高位用于存储额外的信息,这些额外的信息可以用于标记对象的状态,进而帮助 ZGC 实现高效的并发垃圾回收。ZGC 中着色指针的结构如下图所示:

如上图所示,着色指针的高位包含了 20 位的元数据,这 20 位元数据用于存储对象的标记信息。目前,ZGC 中使用了其中的 4 位,剩余的 16 位保留用于未来的扩展。这 4 位的作用如下:

  • Marked0 & Marked1:这两位表示对象是否已被 GC 标记,以及是在哪个周期标记。ZGC 在每个 GC 周期中交替使用这两位,以确定对象是在上个周期亦或当前周期被标记。

  • Remapped:该位表示指针是否已经进行了重映射,即指针不再指向迁移集合(Relocation Set)中的对象。

  • Finalizable:该位表示对象是否仅通过 finalizer 可达。需要注意的是,JDK 18 中的 JEP 421 已经将 finalization 标记为过时,并将在未来的版本中移除。

Java 应用程序本身不会感知到着色指针,当从堆内存中加载对象时,着色指针的读取由读屏障处理。相较于传统的垃圾回收器将对象存活信息记录在对象头中,ZGC 基于着色指针记录了对象状态,在修改状态时仅为寄存器操作,无需访问内存(对象头的 Mark Word),速度更快。由于着色指针在对象地址的高位存储了额外的信息,因此会有多个虚拟地址映射到同一个对象,此即多重映射(Multi-Mapping)。在 ZGC 中,每个对象的物理地址会映射到三个虚拟地址,分别对应着色指针的三种状态,下图展示了多重映射的实际情况:

值得一提的是,某些监控工具(比如 top)没有处理这种多重映射的场景,这会导致其无法正确识别开启了 ZGC 的 Java 进程占用的内存——监控值会显示为实际值的 3 倍,甚至可能会出现使用 100%+ 物理内存的现象。

读屏障 在上一小节中,我们提到了着色指针的读取由读屏障处理。读屏障(Load barriers)是 JIT 编译器(C2)注入到类文件中的代码段,它会在 JVM 解析类文件时添加到所有从堆中检索对象的地方。下面的 Java 代码示例展示了读屏障会被添加的地方:

Object o = obj.fieldA; // 从堆中读取 Object,会触发读屏障

Object p = o; // 没有从堆中加载,不会触发读屏障
o.doSomething(); // 没有从堆中加载,不会触发读屏障
int i = obj.fieldB // 加载的不是对象,不会触发读屏障

具体的插入方式形如:

Object o = obj.fieldA;
// 触发读屏障
if (o & bad_bit_mask) {
    // o 的着色指针的颜色不对,进行修复
    slow_path(register_for(o), address_of(obj.fieldA));
}

实际的汇编实现:

mov     0x20(%rax), %rbx    // Object o = obj.fieldA;
                            // %rax 寄存 obj 地址,0x20 为 fieldA 在其中的偏移量,%rbx 用于寄存 Object o 的地址
test    %rbx, %r12          // if (o & bad_bit_mask)
                            // %r12 寄存染色指针当前 bad color 的掩码
                            // ZGC 不支持压缩对象指针(compressed oops),故可以利用为压缩指针预留的 %r12 寄存器
jnz     slow_path           // %rbx 中的指针为 bad color,修复颜色——按需修改 0x20(%rax) 与 %rbx

ZGC 中,读屏障注入的代码会检查对象指针的颜色,如果颜色是“坏的”,那么读屏障会尝试修复颜色——更新指针,使它指向对象的新位置,或者迁移对象本身。这种处理方式保证了,在一次 GC 期间,对象迁移等重操作仅会在首次加载对象时发生,之后的加载操作则会直接读取对象的新位置,额外开销仅为一次位运算判断。据官方测试,ZGC 读屏障带来的额外性能开销在 4% 左右。

区域化内存管理
类似于 G1GC,ZGC 会动态地将堆划分为独立的内存区域(Region),但是,ZGC 的区域更加灵活,包括小、中、大三种尺寸,活跃区域的数量会根据存活对象的需求而动态增减。将堆划分为区域可以带来多方面的性能优势,包括:

  • 分配和释放固定大小的区域的成本是恒定的。

  • 当区域内的所有对象都不可达时,GC 可以释放整个区域。

  • 相关对象可以被分组到同一个区域中。

值得注意的是,所谓的“小区域”、“中区域”和“大区域”并不是指区域的大小,而是指区域的类别和用途。例如,一个大区域可能比一个中等区域还要小。下面将介绍不同区域尺寸及其用途:

  • 小区域:小区域的大小为 2 MB,用于存储小于 1/8 区域大小(即 256 KB)的对象。小区域的大小是固定的,不会随着堆的大小而变化。

  • 中区域:中区域的大小会根据堆的大小(-XX:MaxHeapSize,-Xmx)而变化。如下表所示,中区域的大小可能为 4

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值