Ohhnews

分类导航

$ cd ..
DZone Java原文

Java 27 将 G1 设为所有环境的默认垃圾收集器:升级前如何测试小型容器

#java 27#g1 gc#jep 523#容器内存#垃圾回收

当 JDK 27 于 2026 年 9 月 15 日发布时,大部分注意力都集中在紧凑对象头和抗量子 TLS 上。JEP 523 获得的报道较少,部分原因是它的描述听起来像是例行维护:G1 成为所有环境下的默认垃圾收集器。

对大多数应用来说,这什么也没改变。但对某一类特定的容器而言,它会悄无声息地更换收集器——日志里没有警告,启动时也不会失败。本文介绍该回退机制此前做了什么、哪些工作负载会失去它、如何识别受影响的服务,以及如何运行一次能产出可用数据而非噪声的对比测试。

Serial GC 回退机制曾经的作用

自 Java 9 起,G1 一直是名义上的默认收集器。较少人知的是,HotSpot 会在启动时检查机器配置,并在受限环境中覆盖这一选择。当以下任一条件成立时,JVM 会选择 Serial GC:

条件阈值
可用处理器数1
物理内存小于 1,792 MB

当时的推理是合理的。Serial GC 是单线程的,原生内存占用非常小。在单核机器上,并发收集器会把时间花在与应用线程争抢唯一可用的核心上,而 G1 维护的簿记结构相对于小堆来说开销很高。

此后 G1 已有大幅改进。它的原生内存占用在多个版本中不断降低,JDK 26 交付的 JEP 522 通过引入第二张卡表,消除了 G1 写屏障中的同步开销。JEP 523 为这项工作画上句号。回退机制被移除,现在无论 CPU 数量或可用内存多少,所有环境都会选择 G1。Serial GC 仍被完全支持,也仍可显式指定。

已经指定收集器的应用——无论是 G1、ZGC、Parallel 还是 Shenandoah——不受此变更影响。

哪些工作负载会受到影响

该变更只触及那些从未设置显式收集器参数、且运行在受限环境中的服务。在实践中,这对应着一组可预期的部署形态:

  • 边车容器。日志采集器、指标代理和服务网格组件通常只分到几分之一个 CPU 和几百兆内存,很少被调优。
  • 批处理与队列 worker。短生命周期任务被调度到当时可用的任何容量上。
  • 低流量的内部服务。管理 API、配置服务和内部工具,被刻意分配小规格 Pod。
  • CI runner 与开发容器。这些环境很少设置 JVM 参数,因为工作负载并非生产环境。
  • CPU 限制为小数的 Pod。这种情况很容易被忽略。配置为 cpu: 500m 的容器可能只向 JVM 报告一个可用处理器,在早期版本中就会被归入 Serial GC 的范畴。

这些正是最不可能出现在性能测试计划中的工作负载,也恰恰因此,这一变更值得有针对性地验证。

第一步:识别当前的收集器

在改动任何配置之前,先确定哪些服务目前确实运行在 Serial GC 上。GarbageCollectorMXBean 接口直接暴露了这一点:

收集器名称可以说明当前激活的是哪种实现:

收集器报告的 MXBean 名称
Serial GCCopy, MarkSweepCompact
G1G1 Young Generation, G1 Concurrent GC
ParallelPS Scavenge, PS MarkSweep
ZGCZGC Cycles, ZGC Pauses

对于 Spring Boot 应用,将其注册为启动监听器,就能把信息送入现有的日志聚合系统,无需任何额外基础设施:

在你的全部环境中部署这段代码,然后在日志中筛选收集器名称为 Copy 的条目。这一结果集界定了你的测试范围。

第二步:理解原生内存差异

关于垃圾收集器选择的讨论通常围绕暂停时间展开。但就这一特定变更而言,原生内存消耗才是更关键的指标。

G1 维护着 Serial GC 不需要的簿记结构,包括记忆集(remembered sets)、卡表以及按区域划分的元数据。这部分内存分配在 Java 堆之外,因此会计入容器内存限制,却不会出现在堆指标中。

JDK 27 发布后公布的测量数据表明,G1 的原生内存开销约为 43 MB,而 Serial GC 约为 1.7 MB。Ankur Mundra 在真实 cgroup 限制下于 Spring Boot 4.1 上测量了两种收集器,并报告了这些数字。

绝对差值并不算大。比例上的影响完全取决于容器大小:

容器内存限制G1 开销大致占比
1024 MB4%
512 MB8%
256 MB16%

在规格紧绷的容器中,这部分开销在应用分配第一个对象之前就已被消耗掉。如果内存限制设置时几乎没有余量,那么仅仅这次变更就可能在升级后引发 OOM kill,而其他一切都没变。

如果你想在自有环境中确认这个数字,原生内存跟踪(Native Memory Tracking)可以按类别给出明细:

然后针对运行中的进程执行:

输出的 GC 部分报告了归因于收集器的已提交原生内存。

第三步:运行受控对比

有意义的对比需要你实际部署的最小容器规格、有代表性的负载,以及足够长、能进入稳态的运行时间。30 秒的运行测的是启动行为,而不是收集行为。

为两种配置分别采集 GC 日志:

有三项测量指标很重要,其中第三项最常被忽略:

  • 总暂停时间。将整轮运行中的暂停时长累加,而不是比较单次收集。Serial GC 的暂停次数更少、每次更长;G1 次数更多、每次更短。只有总量才具可比性。
  • 尾部延迟。G1 会运行并发阶段。在单核容器上,这些阶段会与请求处理争抢唯一可用的处理器。这种影响在平均值中看不见,在 p99 上却清晰可见。
  • 负载下的常驻集大小(RSS)。原生内存差异正是在这里变得具有运维意义。应在整轮运行过程中持续采样 RSS,而不是在启动时读取一次:

如果在 G1 下 RSS 接近容器限制、而在 Serial 下并非如此,你就在问题找上你之前发现了它。

第四步:当 G1 表现更差时固定收集器

如果测量显示 G1 在受限工作负载上表现更差,恰当的应对不是推迟 JDK 升级,而是显式声明收集器:

这是受支持的配置。Serial GC 既未被废弃,也没有计划移除,JEP 523 明确保留了它。

在 Kubernetes 部署中,应在会被审查到的地方表达这一选择:

即使在 G1 表现良好的场景,也有理由显式设置该参数。显式选择收集器具有自我说明性。未来的工程师阅读清单文件时,能看到这里做过决策并经过了测试,而不必去推断 JVM 在运行时选择了什么。

结论

JEP 523 是一项合理的变更。G1 确实弥补了当初支持该回退机制的差距,而从 JVM 启动逻辑中移除一个隐藏的条件判断,也让所有人的心智模型更简单。

运维层面的考量则更具体:一批规模小、很少被测试的容器会在升级时更换收集器,而没有任何信号提示这件事发生了。识别这些容器只需要一行日志,测试它们只需要跑一轮负载。两者都跳过的代价,是一次延迟回归或一次 OOM kill,并且看起来与团队所做的任何改动毫无关系。

Java 27 不是长期支持版本,许多团队会继续留在 Java 25。这并不削弱这项工作的相关性。下一个 LTS 会带来同样的默认值,而在短期支持版本上评估它,风险远低于在迁移过程中才发现问题。

延伸阅读

DZone 贡献者所表达的观点仅代表其个人。