升到 JDK 27 之后,G1 在 Full GC 结束时默认不再按空闲比例缩堆。同一套 Spring Boot 探测:清掉约 350M 临时对象并 System.gc() 五次,JDK 21.0.12.1 默认把 committed 从 512M 收回 64M,JDK 27.0.1.0.3 默认仍停在 512M。

根因写在默认值上。JDK 21:MinHeapFreeRatio=40、MaxHeapFreeRatio=70。JDK 27:改成 0 / 100,这条 Full GC 后的比例启发式等于关掉。

谁会撞上

批量任务、报表、导入导出这类「先冲高再空闲」的服务。以前靠 Full GC / 显式 System.gc() 把堆还回去,容器 RSS 跟着掉。升到 27 后 used 掉了,committed 还顶着,旁路监控会以为内存泄漏。

还有一类是运维脚本直接读 Runtime.totalMemory() 或容器 working set,拿它当「应用还占多少」。这条指标在 27 默认下不会再跟着 Full GC 回落。

只影响没显式设这两个比例、又走 G1 的进程。命令行已经写死 -XX:MinHeapFreeRatio / MaxHeapFreeRatio 的,行为跟以前一致。

怎么确认

先看默认值,别猜:

===== PrintFlagsFinal Min/MaxHeapFreeRatio =====
--- JDK 21.0.12.1 ---
    uintx MaxHeapFreeRatio                         = 70                                     {manageable} {default}
    uintx MinHeapFreeRatio                         = 40                                     {manageable} {default}
     bool UseG1GC                                  = true                                      {product} {ergonomic}
--- JDK 27.0.1.0.3 ---
    uintx MaxHeapFreeRatio                         = 100                                    {manageable} {default}
    uintx MinHeapFreeRatio                         = 0                                      {manageable} {default}
     bool UseG1GC                                  = true                                      {product} {ergonomic}

再跑同一套探测:-Xms64m -Xmx512m -XX:+UseG1GC -XX:-ExplicitGCInvokesConcurrent,分配约 350M(256KiB 小块,避开巨区),清空引用,System.gc() 五次,读 MemoryMXBean 的 committed。

===== JDK21-G1-default =====
label=JDK21-G1-default
java.version=21.0.12.1
spring.boot.version=4.1.2
T1_afterAlloc used=472M committed=512M max=512M
T2_afterClearGc used=7M committed=64M max=512M

===== JDK21-G1-force-0-100 =====
label=JDK21-G1-force-0-100
java.version=21.0.12.1
T1_afterAlloc used=471M committed=502M max=512M
T2_afterClearGc used=7M committed=502M max=512M

===== JDK27-G1-default =====
label=JDK27-G1-default
java.version=27.0.1.0.3
spring.boot.version=4.1.2
T1_afterAlloc used=482M committed=512M max=512M
T2_afterClearGc used=6M committed=512M max=512M

===== JDK27-G1-restore-40-70 =====
label=JDK27-G1-restore-40-70
java.version=27.0.1.0.3
T1_afterAlloc used=476M committed=512M max=512M
T2_afterClearGc used=6M committed=64M max=512M

对照很直白。21 默认会缩;21 人为改成 0/100 就不缩。27 默认不缩;27 显式写回 40/70,又缩回 64M。开关就是这两个比例,不是 Spring Boot 版本差异。

现场怎么读

used 从四百多兆掉到个位数,说明对象确实被回收了。committed 才是「JVM 还握着多少堆容量」。27 默认下 Full GC 后这条线不动,是新默认,不是回收失败。

探测里关掉了 ExplicitGCInvokesConcurrent,让 System.gc() 走 STW Full GC,专门打到「Full GC 后按比例伸缩」这条路径。线上如果开着并发显式 GC,缩堆时机不同,但默认比例本身已经变了,升级前后对比时仍要按 0/100 来预期。

连带断点

  • 容器 memory limit 较紧、又指望波峰过后自动把堆还出去的服务,升级后 RSS 高位会停更久。该扩 limit,还是改回旧比例,要单独压一轮。
  • G1 还有按 GC CPU 占比等启发式调堆的路径。关掉比例启发式,是为了少和那些启发式互相打架。官方说明见 Inside Java 对 JDK 27 STW 收集器的整理,以及 JDK-8238686。
  • Parallel GC 不吃这组「G1 Full GC 后比例」的同一套默认故事。本次对照只钉 G1。
  • JEP 523 让 G1 在所有环境默认开启,和这次比例默认调整常一起出现。小容器升级时两件事会叠在一起。

升级怎么选

多数服务:接受新默认。堆高峰过后 committed 留在高位,换来更少的无意义缩扩。监控改看 used / 分配速率 / GC 日志,别再把 Full GC 后的 totalMemory() 当「已经还回去了」。

必须保留旧缩堆手感:启动参数加回 -XX:MinHeapFreeRatio=40 -XX:MaxHeapFreeRatio=70。上面第四组已经证明,27.0.1.0.3 上这样设,T2 的 committed 会回到 64M。

建议在预发用同一套「冲高 → 清空 → Full GC → 读 committed」做回归,把 21 默认、27 默认、27 恢复 40/70 三组数留档。数字对得上,再决定要不要改参数。

— 感谢阅读 —
广告

一起交流

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