本文来自一次真实优化,服务名、镜像、节点、容量和业务数据均已泛化。文中参数只能作为分析参考,不能直接套用到所有 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 的调用。处理顺序:
- 用依赖树定位到具体 jar。
- 查上游是否已有兼容版本。
- 先升级依赖并回归。
- 不要自行屏蔽所有警告。
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 的价值不是让进程“越用越少”,而是在延迟目标允许的范围内,更主动地适应负载并归还长期不用的堆内存。
DISCUSSION
讨论与反馈