JDK 26 上如果显式开了 Parallel GC,连续分配大块临时数组时,堆可能几乎不扩。同一套 Spring Boot 4.1.2 探测:循环 2000 次、每次 new 16M 字节且立刻丢掉引用,JDK 26.0.2.1 的 committed 从始至终停在 23M,对应 2000 次 Full GC;换到 JDK 27.0.1.0.3,Full GC 是 0,committed 扩到 1370M。

这是 JDK-8377561。根因在 Parallel 的堆扩容启发式,不是业务把对象握死了。Inside Java 对 JDK 27 STW 收集器的整理里也点了这件事。

谁会撞上

明确写了 -XX:+UseParallelGC 的吞吐型服务。批次解压、报表缓冲、一次性读大文件进内存再丢掉,都容易走出「反复大块分配、短期不存活」的路径。

默认 G1 的进程基本碰不到这条。同版本 JDK 26 上改成 G1,同一循环 committed 会直接冲到 4G 量级,Full GC 次数是 0。

还有一类是升级窗口卡在 26:21 上偶发几次 Full GC 还能把堆拉起来,26 会陷进「每次分配失败都 Full GC、committed 几乎不动」。27 起这条启发式修好了。

现场什么样

GC 日志里刷屏 Pause Full (Allocation Failure),前后 used 掉回个位数兆,括号里的堆容量却长期停在二十兆上下:

[0.145s][info][gc] GC(4) Pause Full (Allocation Failure) 18M->2M(23M) 2.487ms
[0.149s][info][gc] GC(5) Pause Full (Allocation Failure) 18M->2M(23M) 2.099ms
...
[5.868s][info][gc] GC(2001) Pause Full (Allocation Failure) 18M->2M(23M) 2.046ms
[5.871s][info][gc] GC(2002) Pause Full (Allocation Failure) 18M->2M(23M) 2.028ms

进程未必立刻 OOM。16M 的数组在 Full GC 后能塞进那 23M 的 committed,所以循环能跑完,只是每次分配都先打一轮 Full GC。CPU 被 STW 吃掉,吞吐掉下来,旁路会以为在疯狂回收泄漏对象。

怎么确认

参数钉死:-Xms8m -Xmx4g -XX:+UseParallelGC,开 -Xlog:gc*=info。探测放在 Spring 容器启动之前,避免启动阶段先把堆撑开、绕过病态区间。

对照原样日志(Spring Boot 4.1.2 jar):

===== JDK26-Parallel =====
label=JDK26-Parallel
java.version=26.0.2.1
spring.boot.version=4.1.2
phase=pre-spring
T0_before used=2M committed=12M max=3641M
T_mid_i500 used=18M committed=23M max=3641M
T_mid_i1000 used=18M committed=23M max=3641M
T_mid_i2000 used=18M committed=23M max=3641M
T1_afterLoop used=18M committed=23M max=3641M
elapsed_ms=5739
gc completed Full (Allocation Failure)=2000
gc Pause Young count=14

===== JDK27-Parallel =====
label=JDK27-Parallel
java.version=27.0.1.0.3
spring.boot.version=4.1.2
phase=pre-spring
T0_before used=2M committed=12M max=3641M
T_mid_i500 used=341M committed=995M max=3641M
T_mid_i1000 used=533M committed=995M max=3641M
T_mid_i2000 used=829M committed=1370M max=3641M
T1_afterLoop used=829M committed=1370M max=3641M
elapsed_ms=3513
gc completed Full (Allocation Failure)=0
gc Pause Young count=92

===== JDK21-Parallel =====
label=JDK21-Parallel
java.version=21.0.12.1
T0_before used=2M committed=9M max=3641M
T_mid_i500 used=2562M committed=2584M max=3641M
T1_afterLoop used=3261M committed=4095M max=4095M
elapsed_ms=7738
gc completed Full (Allocation Failure)=2

===== JDK26-G1-control =====
label=JDK26-G1-control
java.version=26.0.2.1
T0_before used=1M committed=10M max=4096M
T_mid_i500 used=830M committed=4096M max=4096M
T1_afterLoop used=578M committed=4014M max=4096M
elapsed_ms=4543
gc completed Full (Allocation Failure)=0

读法很直接。26 + Parallel:committed 钉在 23M,2000 次循环对应 2000 次完成态 Full GC。27 + Parallel:只走 Young,committed 扩到 1370M。21 + Parallel:会有少数 Full GC,但堆最终能拉到接近 -Xmx。26 + G1:同负载正常扩堆,说明锅在 Parallel 扩容启发式,不在这段 Java 代码。

连带断点

  • 探测必须赶在 Spring 大量类加载、bean 创建之前。容器先启动再分配,堆往往已经被抬过病态区间,26 上反而看不到风暴,容易误判「修了」。
  • 官方描述频率是 Often,不是 Always。小堆起步、-Xms 很低、又连续抛远大于 Eden 的数组,最容易踩中。
  • 回归来自更早的 Parallel 扩容启发式调整(JDK-8338977)。修在 JDK 27(JDK-8377561),不是业务参数能稳妥补上的那种。
  • 和昨天写的 G1 MinHeapFreeRatio 默认变化不是一回事。那条管 Full GC 之后缩不缩堆;这条管 Parallel 在分配失败时扩不扩堆。

升级怎么选

还在 JDK 26、且生产显式 Parallel:优先升到 27.0.1.0.3 或同系列已含该修复的构建。升完用同一套「启动前大块循环分配 + gc 日志」做回归,盯 Full GC 次数和 committed 是否跟着走。

暂时不能动大版本:同一台机器改 G1(或拿掉强制 Parallel,让 27 默认 G1 接手)可以绕开。上面 JDK26-G1-control 已经说明,同版本换收集器,行为立刻正常。

不要把 -Xms 盲目拉齐到 -Xmx 当长期方案。它能减少「从小堆爬升」的路径,却把常驻占用锁死,也掩盖别的扩容问题。

监控上,Parallel 进程要同时看 Full GC 频率和 MemoryMXBean committed。只有 used 在抖、committed 长期不动,再叠加 Allocation Failure 的 Full GC,才像这条启发式问题。

— 感谢阅读 —
广告

一起交流

分享你的思考,让讨论更进一步。