ZGC 架构深入
介绍
在上一篇文章中,我们对 ZGC 进行了高级概述,并介绍了如何配置它。本文将深入探讨指导 ZGC 的关键实现细节和架构决策。
并发和 GC 周期
ZGC 的主要优势之一是其极低的暂停时间,低于 1 毫秒。这是通过 ZGC 成为几乎完全并发的垃圾收集器来实现的。以下是 GC 在每个 GC 周期中经历的一些高级过程的图表,以及该过程是否并发执行 (✅)
串行 | 并行 | G1 | ZGC | |
---|---|---|---|---|
标记 | ❌ | ❌ | ✅* | ✅ |
重新定位/压缩 | ❌ | ❌ | ❌ | ✅ |
引用处理 | ❌ | ❌ | ❌** | ✅ |
重新定位集选择 | ❌ | ❌ | ❌ | ✅ |
JNI WeakRef 清理 | ❌ | ❌ | ❌ | ✅ |
JNI 全局引用扫描 | ❌ | ❌ | ❌ | ✅ |
类卸载 | ❌ | ❌ | ❌ | ✅ |
线程栈扫描 | ❌ | ❌ | ❌ | ✅ |
注意: 图表基于 JDK 19
* 仅旧一代
** 部分并发
ZGC 能够几乎并发地处理所有 GC 过程,实质上将暂停阶段变成了短暂的同步点,这些同步点不会随着活动集的大小而增加,并且无论规模如何都能提供一致的性能。
ZGC GC 周期
GC 周期由三个暂停和三个并发阶段组成,每个阶段都有不同的职责。以下是显示 ZGC GC 周期简化视图的图表
暂停标记开始
同步点,用于发出标记阶段开始的信号。
在此阶段以及所有暂停阶段,只会执行一些次要操作,例如设置布尔标志以及“良好”的当前全局颜色;请参阅 彩色指针。
并发标记
在此并发阶段,ZGC 将遍历整个对象图并标记所有对象。
暂停标记结束
同步点,用于发出标记结束的信号。
并发准备重新定位
在此并发阶段,ZGC 将删除对象
暂停重新定位开始
同步点向线程发出信号,表明对象将在堆中移动。
并发重新定位
在此并发阶段,ZGC 将移动对象并在堆中压缩区域以释放空间。有关此阶段的更多信息,请查看有关压缩的部分。
彩色指针
GC 工作的核心部分是在堆中移动对象,同时避免应用程序使用指向已移动对象的过时引用。实现此目的的一种简单方法是在该工作期间暂停应用程序,但为了实现 ZGC 的低暂停时间目标,它必须几乎所有工作都并发执行。您可以看到潜在的问题:即使 ZGC 在应用程序运行时执行其工作,它也必须确保应用程序始终获得正确的引用。它通过两个关键的架构决策来实现这一点,即彩色指针和加载屏障。让我们看一下彩色指针。
ZGC 使用一个 64 位指针,其中 22 位保留用于有关指针的元数据。22 个元数据位为指针提供“颜色”,可以提供有关指针当前状态的信息。彩色指针类似于其他 GC 实现中使用的标记和版本指针。从 JDK 19 开始,ZGC 中的彩色指针看起来像下图:
目前,正在使用 4 位,而其他 18 位保留供将来使用。每一位的用途如下
- 可终结: 此位指示对象是否仅通过终结器可达。请注意,终结在 JDK 18 中被指定为已弃用,将在 JEP 421 中删除。
- 重新映射: 此位指示指针是否已知不指向重新定位集。
- Marked0 & Marked1: 这些位指示对象是否已知被 GC 标记。ZGC 在这两个位之间交替使用哪个是每个 GC 周期的“良好”位。
每一位都有一个“良好”和“不良”颜色;但是,什么是“良好”或“不良”颜色将取决于访问对象时的上下文。应用程序本身不会意识到彩色指针;当从堆内存中加载对象时,加载屏障 会处理彩色指针的读取。
堆多映射
由于 ZGC 可以在应用程序运行时移动堆内存中对象的物理位置,因此需要提供多条路径来访问对象所在的当前物理位置。在 ZGC 中,这是通过堆多映射来实现的。使用多映射,对象的物理位置被映射到虚拟内存中的三个视图,对应于指针的每个潜在“颜色”。这允许加载屏障在对象自上次同步点以来已移动的情况下找到该对象。
此设计决策的一个后果是,系统可能会报告 ZGC 的内存使用量高于其实际使用量。这是由于对象在虚拟内存中的三倍寻址造成的;但是,实际内存使用量仅来自对象实际所在的位置。当系统报告的内存使用量高于系统上安装的物理内存时,这一点最容易理解。下图展示了多映射在实践中的样子:
加载屏障
在上一节中,我们介绍了彩色指针是如何成为 ZGC 允许并发处理的主要架构决策之一的;本节介绍了另一个关键的架构决策,即加载屏障。
加载屏障是 C2 编译器(JIT 的一部分)注入到类文件中的代码段,当 JVM 解析类文件时,这些代码段会被注入。加载屏障被添加到从堆中检索对象的类文件中。以下 Java 代码示例显示了加载屏障将被添加的位置
Object o = obj.fieldA();
<load barrier added here by C2>
Object p = o; //No barrier, not a load from the heap
o.doSomething(); //No barrier, not a load from the heap
int i = obj.fieldB(); //No barrier, not and object reference
加载屏障添加的行为将检查从堆中加载时对象的指针的“颜色”。加载屏障针对“良好”颜色情况进行了优化,这是常见情况,以允许更快地通过。假设加载屏障遇到“不良”颜色。在这种情况下,它将尝试修复颜色,这可能意味着更新指针以将对象的新位置放在堆上,甚至在返回对系统的引用之前重新定位对象本身。这种修复确保随后从堆中加载对象将走快速路径。
区域
ZGC 不会将堆视为一个单一的桶来存放对象,而是将堆动态地划分为单独的内存区域,如下图所示(简化)
这遵循与 G1 GC 相似的模式,G1 GC 也使用内存区域。但是,ZGC 区域(在内部定义为 ZPages)更加动态,具有小、中和大尺寸;活动区域的数量可以根据活动集的需要增加和减少。
将堆划分为区域可以为 GC 性能带来一些好处,包括:分配和释放一组大小的区域的成本将是恒定的,当区域内的所有对象不再可达时,GC 可以释放整个区域,相关对象可以分组到一个区域中。
区域大小
根据上图,ZGC 有三种不同的区域大小:小、中和大。它还突出表明,矛盾的是,一个大区域可能比一个中等区域小。以下是不同区域大小及其用途的介绍。
小区域
小区域的大小为 2 MB。小于小区域大小的 1/8(12.5%)的对象,即小于或等于 256 KB 的对象,将存储在小区域中。
中等区域
中等区域的大小会根据最大堆大小 (-Xmx
) 的设置而有所不同。1 GB 或更大,中等区域的大小设置为 32 MB;低于 128 MB,中等区域被禁用。与小区域一样,大小为中等区域大小的 1/8(12.5%)或更小的对象将存储在那里。以下是中等区域大小范围的图表
最大堆大小 | 中等区域大小 |
---|---|
>= 1024 MB | 32 MB |
>= 512 MB | 16 MB |
>= 256 MB | 8 MB |
>= 128 MB | 4 MB |
< 128 MB | 关闭 |
大区域
大区域保留用于巨型对象,并以 2 MB 的增量紧密地适应对象的大小。因此,一个 13 MB 的对象将存储在一个 14 MB 的大区域中。任何太大而无法放入中等区域的对象都将放置在其自己的大区域中。
压缩和重新定位
区域的设计利用了这样一个事实,即大多数在同一时间创建的对象将在同一时间离开作用域。但是,正如大多数限定词所暗示的那样,情况并非总是如此。通过内部 GC 启发式方法,GC 最终可能会将主要由不可达对象填充的区域中的对象复制到一个新区域,以允许释放旧区域并释放内存。这称为压缩和重新定位。ZGC 从 JDK 16 开始,通过两种重新定位方法来实现压缩,即就地和非就地。
当有空闲区域可用时,会执行非就地重新定位,这是 ZGC 首选的重新定位方法。以下是非就地重新定位的样子
如果没有空闲区域可用,ZGC 将使用就地重新定位。在这种情况下,ZGC 将对象移动到一个人口稀少的区域。以下是就地重新定位的示例
使用就地重新定位,ZGC 必须首先压缩指定用于重新定位对象的区域内的对象。这可能会对性能产生负面影响,因为只有一个线程可以执行此工作。增加堆大小可以帮助 ZGC 避免使用就地重新定位。
其他阅读材料
以下是一些值得查看的链接,以了解更多关于 ZGC 的信息。
ZGC 团队维基:https://wiki.openjdk.org/display/zgc/Main
Per Liden 的(ZGC 的原始开发者)博客:https://malloc.se/
深入了解 ZGC:OpenJDK 中的现代垃圾收集器:https://dl.acm.org/doi/full/10.1145/3538532
更多学习
最后更新: 2022年3月6日