本文来自一次真实优化,服务名、镜像、节点、容量和业务数据均已泛化。文中参数只能作为分析参考,不能直接套用到所有 Java 服务。

一个 Spring Boot 服务升级到 JDK 25 后,刚启动时 Kubernetes 看到约 3.5 GiB,稳定后逐步下降到不足 1 GiB。这个现象不是“JVM 把业务数据清空了”,也不能只用“ZGC 比 G1 更省内存”解释。

关键是要先弄清楚 kubectl top 看到的是什么,以及 JVM 的“已使用堆、已提交堆、进程驻留内存、容器工作集”为什么不相等。

Java 容器内存由什么组成

flowchart TD
  A["Container Memory"] --> B["Java Heap"]
  A --> C["Metaspace / Compressed Class Space"]
  A --> D["Thread Stacks"]
  A --> E["Direct / Mapped Buffers"]
  A --> F["JIT Code Cache"]
  A --> G["GC Native Structures"]
  A --> H["Shared Libraries / Page Cache"]

-Xmx 只限制 Java Heap 上限,不包括其他区域。一个 -Xmx2g 的进程,容器内存超过 2 GiB 完全正常。

第 1 步:确认 Pod 里到底是什么 JDK

不要用基础镜像名称猜供应商:

kubectl -n <namespace> exec <pod> -- java -version

kubectl -n <namespace> exec <pod> -- \
  java -XshowSettings:properties -version 2>&1 \
  | grep -E 'java.vendor|java.version|java.home|java.vm.name'

OpenJDK 是 Java 的实现与生态,Oracle JDK、Eclipse Temurin、Amazon Corretto、Microsoft Build of OpenJDK 等则是不同的发行构建。选择时关注:

  • 安全更新周期。
  • 容器与 CPU 架构支持。
  • 供应商支持和许可证。
  • 是否通过项目的完整回归测试。

换成 Oracle JDK 不会自动解决应用内存问题。

第 2 步:确认所有 JVM 参数来源

kubectl -n <namespace> exec <pod> -- sh -c '
  printf "JAVA_TOOL_OPTIONS=%s\n" "$JAVA_TOOL_OPTIONS"
  printf "JDK_JAVA_OPTIONS=%s\n" "$JDK_JAVA_OPTIONS"
  printf "JAVA_OPTS=%s\n" "$JAVA_OPTS"
'

再查看进程:

kubectl -n <namespace> exec <pod> -- jcmd 1 VM.command_line
kubectl -n <namespace> exec <pod> -- jcmd 1 VM.flags

Picked up JAVA_TOOL_OPTIONS 和 Picked up JDK_JAVA_OPTIONS 说明参数是由环境注入的。若同一个 Deployment 同时定义两者,要确认没有重复或冲突。

全局模板尤其危险:某个只有新 JDK 才支持的参数,一旦注入仍在运行旧 JDK 的服务,就会直接出现:

Unrecognized option
Could not create the Java Virtual Machine

第 3 步:分别查看 Kubernetes 和 JVM

Kubernetes 侧:

kubectl top pod -n <namespace> <pod> --containers
kubectl -n <namespace> get pod <pod> \
  -o jsonpath='{.spec.containers[0].resources}{"\n"}'

容器内:

kubectl -n <namespace> exec <pod> -- sh -c '
  grep -E "VmRSS|VmSize|Threads" /proc/1/status
  test -f /sys/fs/cgroup/memory.current && cat /sys/fs/cgroup/memory.current
'

JVM 侧:

kubectl -n <namespace> exec <pod> -- jcmd 1 GC.heap_info
kubectl -n <namespace> exec <pod> -- jcmd 1 VM.native_memory summary

Native Memory Tracking 需要启动参数:

-XX:NativeMemoryTracking=summary

NMT 有额外开销,适合诊断和基线观测;是否长期启用应经过压测。

第 4 步:理解 SoftMaxHeapSize

示例:

-Xmx2g
-XX:SoftMaxHeapSize=1g

Oracle JDK 25 的 ZGC 文档说明,Soft Max 是 GC 的软目标,不是硬上限。ZGC 会尽量把堆控制在 1 GiB 附近,但在压力下仍可临时增长到 -Xmx2g。The Z Garbage Collector

因此它适合表达:

平时尽量少占内存
高峰允许使用预留上限

不能用它替代容器 memory limit,也不能保证 RSS 一定小于 1 GiB。

第 5 步:理解 ZUncommitDelay

-XX:ZUncommitDelay=120

表示 ZGC 发现堆内存长期空闲后,相关内存页至少要空闲约 120 秒,才有机会归还给操作系统。JDK 25 默认值是 300 秒。

设置为 30 秒可能让监控数据下降得更快,但频繁负载波动时也可能反复 commit/uncommit。推荐先观察业务波峰间隔:

负载形态初始建议
长时间低负载,偶发高峰120–300 秒
持续稳定高负载保持默认,收益有限
每分钟快速波动避免过短,先压测

“归还更快”不总是“吞吐更好”。

第 6 步:一份可验证的 ZGC 起点

-XX:+UseZGC
-Xms512m
-Xmx2g
-XX:SoftMaxHeapSize=1g
-XX:ZUncommitDelay=120
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/java/dumps

容器资源示例:

resources:
  requests:
    cpu: "1"
    memory: 1536Mi
  limits:
    cpu: "2"
    memory: 3Gi

必须给非堆留余量。JVM 堆上限、Direct Memory、线程栈、Metaspace 和 Native 库之和逼近容器 limit 时,进程会被内核 OOMKilled,JVM 未必有机会生成 Heap Dump。

第 7 步:解释为什么先高后低

启动阶段会发生:

  • 加载大量 Class 和 Bean。
  • JIT 编译热点代码。
  • 创建数据库、Nacos、Kafka 等客户端缓冲区。
  • 解析配置、扫描注解、创建线程。
  • 堆会为了启动吞吐临时扩张。

启动完成后:

  • 短命对象被回收。
  • ZGC 根据 Soft Max 调整堆目标。
  • 超过 ZUncommitDelay 的空闲页会逐步归还给操作系统。

因此从 3.5 GiB 降到 900 MiB,可能包含堆收缩,也可能包含文件页和容器工作集的变化。只有同时观察 NMT、Heap 和 cgroup 数据,才能拆解清楚。

第 8 步:为什么本地仍然是 3.5G

逐项比较:

JDK 发行版和准确版本
GC 参数
JAVA_TOOL_OPTIONS / JDK_JAVA_OPTIONS
容器 limit
激活的 Spring Profile
是否连接真实 Nacos / Kafka / 数据库
线程数和连接池
流量及定时任务
观测时间点

本地直接运行时可能没有 cgroup limit,JVM 会看到更大的宿主机内存;IDE 也可能会覆盖 VM Options。先执行:

jcmd <pid> VM.command_line
jcmd <pid> VM.flags
jcmd <pid> GC.heap_info

不要比较“本地启动一分钟”和“线上稳定一小时”的数字。

第 9 步:处理 JDK 25 警告

Unsafe terminally deprecated

通常来自 Netty、gRPC、Nacos Client 等依赖对 sun.misc.Unsafe 的调用。处理顺序:

  1. 用依赖树定位到具体 jar。
  2. 查上游是否已有兼容版本。
  3. 先升级依赖并回归。
  4. 不要自行屏蔽所有警告。

restricted native access

--enable-native-access=ALL-UNNAMED

只有在运行时确认支持该参数、依赖确实需要且通过安全评审时,才添加。不能放进面向混合 JDK 版本的全局 Deployment 模板。

add-opens / add-exports

这类参数是为兼容旧反射或内部 API 而设的过渡措施。每项都应记录“哪个依赖需要、升级到什么版本可删除”。

第 10 步:压测与验收

至少对比两组相同流量:

基线:原 JDK + 原 GC
候选:JDK 25 + ZGC 参数

采集:

  • 启动耗时与 Ready 时间。
  • P50/P95/P99 延迟。
  • 吞吐和错误率。
  • Heap used/committed。
  • RSS、cgroup memory、Direct Memory。
  • GC pause、allocation rate。
  • CPU 和上下文切换。
  • 一次完整批处理或业务高峰。

观察至少要覆盖一个真实负载周期,不能只看启动后的十分钟。

如何判断 900M 是否健康

900 MiB 本身并不天然代表健康。还要确认:

  • 没有因频繁 GC 导致 CPU 增加。
  • P99 没有变差。
  • Heap 在高峰有足够余量。
  • Direct Buffer、线程和 Metaspace 没有持续增长。
  • 没有 OOMKilled 或容器 memory pressure。
  • 批处理和消息消费吞吐正常。

完成定义

  • 运行时 JDK、供应商和 JVM 参数可重复确认。
  • kubectl top、cgroup、Heap、NMT 四层数据能够相互对应。
  • Soft Max 与 Xmx 的关系经过高峰验证。
  • ZUncommitDelay 覆盖真实负载周期,不产生明显抖动。
  • 容器 limit 为非堆预留足够空间。
  • 所有兼容参数都有来源和移除条件。
  • 优化后延迟、吞吐和稳定性不退化。

ZGC 的价值不是让进程“越用越少”,而是在延迟目标允许的范围内,更主动地适应负载并归还长期不用的堆内存。