JVM在Unikernel Nanos上的GC权衡与堆内存释放
在 Akamas 的《为最大性能选择合适的 GC》研究基础上:在 Oracle Cloud ARM64 上使用 Eclipse Temurin 25、Renaissance 和 Nanos。
[LOADING...]
仍然怀疑能否在 unikernel 上运行 JVM?
在 unikernel 上运行完整的 JVM?还支持五种主要的 HotSpot 垃圾回收器、动态堆管理和高级内存 uncommit 能力?
问题已不再只是 Java 能否在 Nanos 上运行,而是 我们日常了解和依赖的 JVM 行为有多少能在这个环境中保留下来。
为了找出答案,我们在 Nanos 上测试了 Eclipse Temurin 25,复现了 Akamas《为最大性能选择合适的 GC》研究中的实验,并验证了 G1、ZGC 和 Shenandoah 的堆内存 uncommit。
结果如何?五种可用的垃圾回收器、吞吐量、内存和暂停时间之间相同的基本权衡,以及在执行期间减少已提交堆内存的能力。
在 Nanos 上运行 JVM 不仅仅是概念验证。它是一个可以应用并验证熟悉 JVM 调优策略的环境。
引言
我们还需要选择和调优 JVM 的垃圾回收器吗,还是近期的 Java 版本已经足够智能,能够自行选择最佳配置?
Akamas 在《为最大性能选择合适的 GC》中探讨了这个问题,使用基于 Apache Spark 的 Renaissance page-rank 基准测试在 Linux 上比较 HotSpot 垃圾回收器。他们的结论很明确:不存在唯一最佳的垃圾回收器。在他们的实验中,从 G1 切换到 Parallel GC 使执行时间减少 22%,内存使用量减少 30%。
我们以该研究为起点,回答关于 Nanos 的两个问题:
- 所选的五种垃圾回收器能否在 Nanos 上使用标准 Temurin 发行版运行? 具体来说,是 Serial、Parallel、G1、ZGC 和 Shenandoah,且不对 JVM 或回收器做修改。
- Nanos 是否支持内存 uncommit? 换句话说,JVM 能否将不再需要的堆内存归还给系统?
Akamas 的研究提供了一个参照点:如果 Nanos 上的回收器表现出与 Akamas 所描述相似的行为和权衡,我们就有了实验证据,表明该基准测试所涉及的功能得到了支持。
结果概览: 五种回收器都能在 Nanos 上运行,并复现了 Akamas 在 Linux 上观察到的主要趋势。与 Akamas 研究一致,Parallel GC 比 G1 更快且使用更少堆内存;在 Nanos 上,它将执行时间减少 20.7%,将峰值堆内存使用量减少 38.2%;在 Nanos 上,它也快于 ZGC 和 Shenandoah。ZGC 实现了约 0.04 毫秒的暂停时间。堆内存 uncommit 在 Nanos 上有效:G1 会释放内存;启用 uncommit 后,ZGC 和 Shenandoah 也会释放内存。
1. 实验环境
Nanos 通过 ops 命令运行,并使用 -n(--nightly)选项,以便下载夜间内核镜像而不是最新发行版。
该 JVM 是面向 Linux aarch64 的标准 HotSpot 发行版:未对回收器或 JVM 本身做任何修改。回收器通过标准标志选择。
信息图 1 —— 实验环境
[LOADING...]
2. 五种垃圾回收器全部可用
我们在 8 GB 内存下比较了 Serial、Parallel、G1、ZGC 和 Shenandoah。每种回收器都使用 3 次独立的 JVM 启动进行测试,每次启动运行 20 次迭代,排除前 5 次作为预热,实验顺序随机化。
第一个发现是定性的:五种回收器都能成功启动、完成基准测试,并表现出预期行为。这一点并不简单。特别是 ZGC 和 Shenandoah,它们需要内核具备相当复杂的内存管理能力:同一物理内存的多个映射、大块虚拟地址空间的预留,以及运行时的内存提交和释放。
“内存”指整个执行过程中的峰值堆内存使用量,而不是常驻内存总量。最大暂停时间报告为三次启动中观测到的最小值与最大值之间的范围。
信息图 2 —— Nanos 上的执行时间与堆内存使用量
[LOADING...]
这些数值由 G1 基线和百分比变化推算,并经过四舍五入。最大暂停时间仍保留在上表中,以避免将亚毫秒级数值与持续数百毫秒的暂停放在同一尺度上。
没有哪个垃圾回收器能同时最小化执行时间、堆内存使用量和暂停时间。 这是 Akamas 得出的总体结论。
Parallel GC:复现 Akamas 的主要发现
与 G1 相比,Akamas 观察到执行时间减少 22%、内存使用量减少 30%。在 Nanos 上,我们测得执行时间减少 20.7%、峰值堆内存使用量减少 38.2%。尽管硬件、架构和 HotSpot 版本不同,变化方向和数量级仍然一致。
Serial GC
Serial 将峰值堆内存使用量降低了 45%,这与 Akamas 的结果一致:在其实验中,Serial 使用的内存约为 G1 的一半。
ZGC:极短暂停,更多堆内存
ZGC 记录到的最大暂停时间约为 0.04 毫秒,峰值堆内存使用量比 G1 高 74.6%;Akamas 观察到的是 +67%。值得注意的是,JDK 25 中的 ZGC 仅支持分代模式(非分代模式已在 JDK 24 中移除),而 Akamas 使用的是 JDK 15 中的非分代 ZGC 实现。即使实现不同,暂停时间与内存之间的权衡再次出现。
Shenandoah:面向延迟的权衡
Shenandoah 将暂停时间保持在约 1 毫秒,执行时间和峰值堆内存使用量接近 G1(+1.8% 和 +5.8%)。在这里,我们也观察到 Akamas 所描述的特征:以额外内存使用为代价换取非常短的暂停时间。
3. G1 启发式策略与堆大小设置
与 Akamas 一样,我们在堆大小实验中测量了 G1:将最大堆从 2 GB 调整到 5 GB,其他设置保持默认。下表中的结果仅属于该实验。
增加 -Xmx 并不会带来单调的改善。Renaissance 在每次迭代前调用 System.gc(),而在 Full GC 之后,G1 会释放未使用的堆内存:每次迭代开始时约有 0.5 GB 的已提交堆内存。这是 uncommit 在 Nanos 上可用的第一个迹象。
独立实验:固定初始堆与动态初始堆
我们还在 -Xmx5g 下进行了另一项独立、专门设计的比较,对比 -Xmx5g(初始堆保持 JVM 默认值)与 -Xms5g -Xmx5g(最小堆和最大堆相等)。这里测试的配置差异在于:当 -Xms 与 -Xmx 相等时,G1 无法将已提交堆内存缩小到 5 GB 以下。该比较中的 -Xmx5g 基线为 8.74 秒,而前一个堆大小实验中的 5 GB 条目为 9.43 秒。这些是来自不同实验系列的测量值,而不是同一次运行中的两个值。现有记录无法确定差异的原因,因此不应将它们视为可互换,也不应合并它们来计算改进幅度。
下表中的比较严格限定在该独立实验内:
在这组专门的测量中,设置 -Xms5g -Xmx5g 会阻止堆内存取消提交,并且与执行时间缩短 5.3%(8.74 → 8.28 秒)和 GC CPU 使用率降低 31.5%(0.54 → 0.37 核)相关。测得的使用堆内存峰值也更高(2.9–3.1 GB 对 1.8–2.3 GB);使用中的堆内存不应与已提交堆内存混淆。结果说明了特定工作负载下的一种权衡:是允许已提交堆内存收缩,还是保持固定的已提交堆内存。它们本身并不能单独确定重新提交操作的确切成本。
4. Uncommit:在不再需要内存时减少已提交堆内存
如果 JVM 不取消提交内存,即使工作负载已经回落,也可能保留大量已提交堆内存。现代垃圾回收器可以释放它:
在所有情况下,uncommit 都在 -Xms 与 -Xmx 之间发挥作用:当 -Xms 等于 -Xmx 时,JVM 不会释放已提交堆内存,日志也证实了这一点。
启动器每 250 毫秒采样一次已提交堆内存。这些值汇总了可用测量结果;专门的 ZGC 和 Shenandoah 测试还包含来自 GC 日志的事件。“减少次数”列统计的是单次启动中已提交堆内存下降超过 64 MB 的次数,而 ZGC 和 Shenandoah 的数字统计的是 GC 日志中的 Uncommitted 事件:这些计数不能直接比较。
信息图 3 —— Uncommit 测试中的已提交堆内存
[LOADING...]
该图展示每项测试中观测到的峰值及随后的最小值;它并不是采样得到的时间序列。
G1。 结果很稳健:每次 Renaissance 调用 System.gc() 后,已提交堆内存都会回到约 0.5 GB,每次启动会出现数十次,且与 -Xmx 无关。Nanos 接收被释放的内存,并将其用于后续提交,没有错误或性能下降。
ZGC。 在一项专门测试中,使用 -Xms1g -Xmx8g 并将 uncommit 延迟缩短到 5 秒,日志记录了 20 个 Uncommitted 事件,每个事件涉及 4 到 468 MB,累计取消提交约 7.7 GB。这是随时间发生的事件总和,而不是同时释放了 7.7 GB。在持续负载下,ZGC 会分块释放内存,并在后续迭代中重新提交,因此已提交堆内存在 6.9 到 7.4 GB 之间波动,而不是向 1 GB 下降。这与工作负载没有进入空闲状态的情况一致。
Shenandoah。 使用 -Xms8g -Xmx8g 时,该回收器会按预期禁用 uncommit。在一次使用 -Xms1g -Xmx8g 的专门启动中,全部 20 次迭代在 359 秒内完成,已提交堆内存从 1.01 GB 上升到峰值 4.64 GB,然后回落到配置的最小值 1.00 GB;最终已提交堆内存为 2.48 GB。日志记录了 144 个 uncommit 事件,每个事件涉及 4 MB 到 3.8 GB,整个执行过程累计取消提交约 41 GB。同一批页面可以被多次提交和释放,因此这并不意味着一次性释放了 41 GB 内存。与在持续负载下分块释放并重新提交内存的 ZGC 不同,Shenandoah 将已提交堆内存降回了最小值。
结果:在已执行的测试中,G1、ZGC 和 Shenandoah 使用标准 JVM 标志在 Nanos 上减少了已提交堆内存。 这验证了所测量配置和工作负载下的 uncommit,但并未将结论扩展到所有可能场景。## 5. Akamas 对 Nanos 的观察
我们并不期望得到完全相同的数值结果:Akamas 使用了不同的硬件架构和 OpenJDK 15,而本次复现使用 ARM64 和 Temurin 25。比较关注效应的方向和数量级,而不是绝对执行时间。 本文的目的仍然是复现 Akamas 的观察结果,并验证 Nanos 上的 uncommit。
信息图 4 —— 五种收集器,一个结论
[LOADING...]
6. 方法与可复现性
每次 JVM 启动都使用全新的 Nanos 实例,并通过 JAVA_TOOL_OPTIONS 传入标志。启动器执行 Renaissance,每 250 ms 采样一次堆和 CPU,并读取 GC 线程的 CPU 计数器。
- 时间: 稳态迭代的平均持续时间。
- CPU: 迭代期间使用的核心数。
- 内存: 峰值堆使用量,取每 250 ms 采集的样本中的最大值;两次采样之间短暂出现的峰值不会被捕获。
- 停顿: 在 GC 安全点花费的时间。
对于主要的收集器比较,报告值是多次独立启动的中位数。原始研究报告了 95% 置信区间;本文未包含其具体数值。专门的 uncommit 测试将单独描述,不应与用于主要比较的每种收集器三次启动相混淆。
结论
Akamas 的研究表明,选择垃圾收集器会显著影响 Linux 上 Java 应用的性能。我们将其作为 Nanos 的测试案例,并获得了两个方面的结果。
基准测试验证了兼容性。 Serial、Parallel、G1、ZGC 和 Shenandoah 都能在 Nanos 上使用标准 Temurin 发行版和普通 JVM 标志运行。Akamas 观察到的权衡再次出现:相对于 G1,Parallel 减少了时间和堆使用量(−20.7% 和 −38.2%,而 Akamas 中为 −22% 和 −30%),ZGC 和 Shenandoah 倾向于短停顿,堆大小设置仍然很重要。
观察到了 uncommit。 G1 会持续释放未使用的堆,在每个周期后回到约 0.5 GB;启用 uncommit 时,ZGC 和 Shenandoah 也会释放内存。在这些测试中,JVM 使用标准 HotSpot 标志在 Nanos 上展示了 uncommit;未直接测量归还给内核的物理 RAM 数量。
没有放之四海而皆准的垃圾收集器。在 Nanos 上,正如在 Linux 上一样,选择权仍属于运行应用的人,可在本研究中验证的五种收集器及选项之间进行选择。
仍然怀疑 JVM 能否在 Nanos 上运行?
五种 HotSpot 垃圾收集器。一个标准的 Eclipse Temurin 25 JVM。与 Akamas 研究中观察到的吞吐量、内存和停顿之间相同的权衡。甚至还包括堆 uncommit,并已通过 G1、ZGC 和 Shenandoah 进行了实验验证。
所有这些都在 Nanos 上完成,无需修改 HotSpot,并使用普通 JVM 标志。
也许现在应该停止询问 JVM 能否在 unikernel 上运行,而开始探索哪些应用可以从中受益。
仍然怀疑 JVM 能否在 Nanos 上运行?JVM 已经准备好接受测试。你呢?
参考文献
- Akamas —— 选择正确的 GC 以实现最高性能
- JEP 346 —— 从 G1 及时归还未使用的已提交内存
- JEP 351 —— ZGC:取消提交未使用的内存
- Nanos Unikernel 和 OPS 文档
- Renaissance 基准测试套件
- Eclipse Temurin
延伸阅读:Nanos 与 OPS 上的 Java
对于希望了解 Nanos 上 Java 应用的操作基础、环境搭建、性能分析和故障排查的读者,有两篇 Foojay 入门文章可供参考: