本文来自一批 JDK 17 服务的容器内存治理。服务名、集群、节点和业务数据均已泛化;示例参数是排查起点,不是所有应用都能直接复制的标准答案。
一段时间里,我们对 Java 服务内存的处理方式很粗糙:容器容易重启,就把 -Xmx 和 Kubernetes memory limit 一起调大。结果是普通 Spring Boot 服务稳定占用 3–4 GiB,重型服务接近 5 GiB,个别实例最终还是被杀掉。
真正排查后,发现这些现象至少包含两类完全不同的问题:
- 一个实例
-Xmx4g,实际堆使用约 3.5 GiB;执行一次堆检查后,存活对象降到约 1.9 GiB,但 RSS 没有立即下降。 - 另一个实例堆只用了约 280 MiB,Java 进程 RSS 却超过 1.2 GiB,同时还有 200 多个线程和常驻的 native profiler。
前者主要是堆与对象分配压力,后者主要是非堆、线程和 native 内存。只看 kubectl top,两者都会被误判为“Java 堆太大”。
第 0 步:先确认比较对象和证据窗口
如果问题是“同一服务的两个 Pod 内存为什么相差数倍”,第一步不是进 JVM,而是确认它们是否真的来自同一份部署模板。
kubectl -n <namespace> get pod <pod-a> \
-o jsonpath='{.metadata.ownerReferences[0].name}{"\n"}'
kubectl -n <namespace> get pod <pod-b> \
-o jsonpath='{.metadata.ownerReferences[0].name}{"\n"}'
kubectl -n <namespace> get rs <replicaset> -o yaml
Pod 名称中相同的 ReplicaSet hash 通常意味着它们共享同一份 PodTemplateSpec,包括:
- 镜像与启动命令。
- JVM 参数和环境变量。
- ConfigMap、Secret 与挂载。
- requests、limits、探针和调度规则。
如果属于同一 ReplicaSet,就不应继续把“两个 Pod 的 JVM 参数不同”作为首要假设,而应转而检查流量、缓存、批处理、连接池、线程数和 native 分配。
还要确认历史证据是否存在。旧 Pod、旧 ReplicaSet 和 previous logs 可能已被滚动发布清理,Prometheus 的留存窗口也可能短于 Pod 离线时间。可以先查询目标时间段:
max by (pod) (
max_over_time(
container_memory_working_set_bytes{
namespace="<namespace>",
pod=~"<application>-<revision>-.+",
container!="",
image!=""
}[24h]
)
)
如果目标时段没有指标和日志,应明确记录:
可以排除模板差异,但现有证据不足以证明旧 Pod 的具体根因。
不要用新 Pod 的一张快照为旧 Pod 编造结论。此时正确做法是补齐下一次复现所需的 NMT、GC 日志和监控,而不是直接宣布“内存泄漏”。
先建立正确的内存模型
flowchart TD
A["Pod memory working set"] --> B["Java Heap"]
A --> C["Metaspace 与 Class Space"]
A --> D["线程栈"]
A --> E["Direct / Mapped Buffer"]
A --> F["JIT Code Cache 与 GC 结构"]
A --> G["JNI、Profiler、glibc arena"]
A --> H["共享库与部分文件页"]
-Xmx 只限制 Java Heap。下面这个等式通常不成立:
容器内存 = Xmx
更接近实际的关系是:
容器内存 ≈ Heap committed + JVM native + 线程栈 + 第三方 native + 工作集中的文件页
因此,容器 limit 与 -Xmx 之间必须留出明确余量。
第 1 步:确认到底是不是 OOM
先看退出原因,不要看到 Restart 就直接调堆:
kubectl -n <namespace> get pod <pod> \
-o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason}{"\n"}{.status.containerStatuses[0].lastState.terminated.exitCode}{"\n"}'
kubectl -n <namespace> describe pod <pod>
kubectl -n <namespace> logs <pod> --previous --tail=300
常见情况:
| 证据 | 更可能的方向 |
|---|---|
OOMKilled、Exit Code 137 | 容器超过 cgroup limit,或节点发生内存压力 |
Java 日志出现 OutOfMemoryError: Java heap space | Java Heap 不足或对象无法释放 |
Metaspace | 类加载、动态代理或 Metaspace 上限问题 |
| Exit Code 143 | 通常是收到 SIGTERM,不等于 OOM |
| 探针连续失败后重启 | 应用阻塞、依赖超时或 GC 停顿被探针放大 |
内核 OOMKill 可能来得太快,JVM 未必有机会生成 Heap Dump。所以 Heap Dump 不存在,不能反推“不是内存问题”。
第 2 步:同时测量 Pod、进程和 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
test -f /sys/fs/cgroup/memory.stat && \
grep -E "^(anon|file|kernel|sock) " /sys/fs/cgroup/memory.stat
'
JVM 侧:
kubectl -n <namespace> exec <pod> -- jcmd 1 VM.version
kubectl -n <namespace> exec <pod> -- jcmd 1 VM.command_line
kubectl -n <namespace> exec <pod> -- jcmd 1 VM.flags
kubectl -n <namespace> exec <pod> -- jcmd 1 GC.heap_info
必须记录采样时间。启动一分钟、业务高峰和空闲一小时的数据不能直接横向比较。
第 3 步:用 NMT 拆分 JVM Native Memory
启动时增加:
-XX:NativeMemoryTracking=summary
注意它必须位于 -jar 之前:
java \
-XX:NativeMemoryTracking=summary \
-jar /app/application.jar
下面这种写法不会生效,因为参数已经变成应用参数:
java -jar /app/application.jar -XX:NativeMemoryTracking=summary
启动后建立基线并比较:
jcmd 1 VM.native_memory summary scale=MB
jcmd 1 VM.native_memory baseline
# 运行一段代表性业务流量后
jcmd 1 VM.native_memory summary.diff scale=MB
重点查看:
Java HeapClassThreadCodeGCCompilerInternal
NMT 默认关闭,summary 和 detail 都有额外开销;Oracle 文档给出的开销参考约为 5%–10%。它也不能覆盖所有第三方 native 分配,因此 NMT 总计小于 RSS 并不奇怪。Native Memory Tracking
第 4 步:取消“Xmx 等于容器上限”的配置
最危险的配置类似这样:
容器 limit: 4Gi
-Xmx4g
Heap 一旦接近 4 GiB,Metaspace、线程栈、Direct Buffer、GC 数据结构和 native 库就没有空间了。最终更可能得到的是 cgroup OOMKill,而不是可分析的 Java Heap OOM。
本轮治理将普通 JDK 17 服务调整为按容器内存计算堆上限:
-Xms768m
-XX:MaxRAMPercentage=65.0
-XX:+UseG1GC
例如容器 limit 为 3 GiB 时,最大堆目标约为 1.95 GiB,剩余约 1.05 GiB 给非堆和 native 内存。65% 只是经过当前服务验证的起点;线程多、Netty Direct Buffer 多或 native 依赖重的服务还要继续降低。
不要同时配置互相竞争的两套上限:
-Xmx2g
-XX:MaxRAMPercentage=65.0
当显式 -Xmx 存在时,百分比就不再是主要控制手段。最终参数应使用 jcmd 1 VM.flags 验证,而不是只看 YAML。
JDK 17 提供容器感知与百分比堆参数,准确含义可以在 JDK 17 java 命令文档 中核对。
第 5 步:保留 G1,但不要用 Full GC 美化监控
JDK 17 的服务继续使用 G1:
-XX:+UseG1GC
一次现场排查中,Heap used 从约 3.5 GiB 降到约 1.9 GiB,但 RSS 没有随之下降。这说明:
- 存活对象确实减少了。
- JVM 或分配器仍可能保留已提交内存页。
- “GC 后 Heap used 下降”与“容器立刻归还同等 RSS”不是同一件事。
同一 ReplicaSet 的两个 Pod,为什么一个 1.8G、一个 1.2G
另一次排查中,两个副本来自同一 ReplicaSet,镜像、JVM 参数、配置和 limit 完全一致:
| 指标 | Pod A | Pod B |
|---|---|---|
| Pod working set | 约 1.86 GiB | 约 1.24 GiB |
| G1 committed Heap | 约 1.5 GiB | 约 768 MiB |
| 当时 Heap used | 约 514 MiB | 约 212 MiB |
| Restart / OOM | 0 | 0 |
GC 日志显示 Pod A 在业务高峰经历了一轮较密集的分配,G1 因此扩大了 committed Heap。后续 mixed GC 逐步把存活对象降到约 442 MiB,继续空闲后 Old 区使用量进一步下降,Heap 也收缩回接近 Xms=768M 的水平;容器内存最终回落到约 1.27 GiB。
现场没有看到:
- OOMKilled。
- Full GC 风暴。
- Humongous Region 持续增长。
- 重启次数增加。
- Old 区只增不减。
因此,这组证据更符合“短时分配抬高 committed Heap,G1 延迟收缩”,而不是内存泄漏。
正确的同时采集方式:
kubectl top pod <pod-a> <pod-b> -n <namespace>
kubectl exec -n <namespace> <pod-a> -- jcmd 1 GC.heap_info
kubectl exec -n <namespace> <pod-b> -- jcmd 1 GC.heap_info
kubectl exec -n <namespace> <pod-a> -- \
jcmd 1 VM.native_memory summary scale=MB
kubectl exec -n <namespace> <pod-b> -- \
jcmd 1 VM.native_memory summary scale=MB
还要对齐同一时刻的 GC 日志。只比较两个 kubectl top 数字,无法知道差异来自 live objects、committed Heap 还是 native memory。
不要为了让曲线下降就定时执行 System.gc(),也不要把很短的 G1PeriodicGCInterval 全局加给所有服务。周期 GC 会消耗 CPU,掩盖真实的对象分配问题;只有明确验证应用存在长时间空闲窗口时才值得单独评估。
如果 Git 中的最新 Deployment 已经删除周期 GC,但运行中 jcmd 1 VM.flags 仍能看到它,说明线上 Pod 还来自旧 spec。kubectl rollout restart 只按当前集群 spec 重建,不会自动读取 Git;必须先执行模板渲染与发布。
Oracle 对 G1 的首要建议仍是尽量使用默认策略,只设置合适的最大堆,再根据 GC 日志调整。Garbage-First Garbage Collector Tuning
第 6 步:给 Metaspace 上限,但不要拍脑袋压得过低
当前模板的起点:
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m
这样能防止异常类加载无限挤占容器内存,但 MaxMetaspaceSize 太小会直接产生:
java.lang.OutOfMemoryError: Metaspace
调整前后应比较:
jcmd 1 VM.native_memory summary scale=MB
jcmd 1 GC.class_histogram
如果类数量持续增长,先查动态代理、脚本引擎、热部署 ClassLoader 和重复生成类,不要只继续提高上限。
第 7 步:关闭常驻 Profiler
诊断工具本身也会占内存。过去模板默认加载 JProfiler native agent,即使没有人连接,agent、采样结构和额外事件仍然常驻进程。
改成默认关闭:
env:
- name: JPROFILER_ENABLED
value: "false"
启动脚本只在排查时追加:
set -- java
if [ "${JPROFILER_ENABLED:-false}" = "true" ]; then
set -- "$@" \
-agentpath:/opt/jprofiler/bin/linux-x64/libjprofilerti.so=port=8849,nowait
fi
分析结束后把开关恢复为 false 并滚动发布。Profiler 应该是一种短时诊断能力,不应成为每个生产 Pod 的默认组成部分。
第 8 步:处理线程栈和消息堆积
一次轻量服务的现场数据是:Heap used 约 280 MiB,RSS 约 1.2 GiB,线程数超过 200。此时继续缩小 Heap 的收益很有限,应先排查线程来源:
jcmd 1 Thread.print > /tmp/threads.txt
grep '^"' /tmp/threads.txt | wc -l
重点盘点:
- Web 容器工作线程。
- 数据库连接池维护线程。
- Kafka、RabbitMQ 与 Nacos 客户端线程。
- XXL-JOB、定时任务和自建线程池。
- ForkJoinPool 与异步执行器。
不建议先全局设置很小的 -Xss。深调用、复杂表达式或大栈帧可能因此触发 StackOverflowError。更稳妥的顺序是:
- 删除重复线程池。
- 根据容器 CPU 限制线程池最大值。
- 收敛消息消费并发和预取。
- 最后再压测
-Xss。
消息消费服务可以从保守配置开始:
env:
- name: SPRING_RABBITMQ_LISTENER_SIMPLE_CONCURRENCY
value: "1"
- name: SPRING_RABBITMQ_LISTENER_SIMPLE_MAX_CONCURRENCY
value: "3"
- name: SPRING_RABBITMQ_LISTENER_SIMPLE_PREFETCH
value: "10"
这不是单纯节省线程。prefetch 太大时,大量尚未确认的消息及其业务对象会同时留在 Heap 中。修改后必须验证消费延迟和积压,不能只看内存下降。
第 9 步:限制 glibc arena
在 Linux glibc 环境中,多线程 native 分配可能产生多个 malloc arena。业务对象释放后,空闲 arena 不一定立即归还给操作系统,表现为 Heap 已下降、RSS 仍偏高。
当前使用的保守起点:
env:
- name: MALLOC_ARENA_MAX
value: "4"
它不是 JVM 参数,也不保证对 Alpine/musl 或其他分配器有效。值太小可能增加多线程分配锁竞争,因此要同时看:
- CPU 使用率。
- 请求 P95/P99。
- native 分配热点。
- RSS 是否真的改善。
第 10 步:让下一次 OOM 留下证据
建议保留:
-XX:+HeapDumpOnOutOfMemoryError
-XX:+ExitOnOutOfMemoryError
-XX:HeapDumpPath=/data/dumps/<app>/<pod>/heap.hprof
-Xlog:gc*,safepoint:file=/data/logs/gc-%t.log:time,uptime,level,tags:filecount=10,filesize=20M
Heap Dump 路径必须:
- 可写。
- 有足够空间。
- 每个 Pod 唯一,避免副本互相覆盖。
- 有清理和下载流程,避免把共享盘写满。
GC 日志回答“什么时候回收、回收前后多少、暂停多久”;Heap Dump 回答“哪些对象占用堆”。两者不能互相替代。
第 11 步:Kubernetes 资源与 JVM 参数一起计算
一个普通服务的示例起点:
resources:
requests:
cpu: 250m
memory: 1536Mi
limits:
memory: 3Gi
配合:
-Xms768m
-XX:MaxRAMPercentage=65.0
计算时要回答三个问题:
- 稳态 P95 内存是多少,request 能否覆盖正常运行?
- 启动和批处理峰值是多少,limit 是否保留安全余量?
- 最大堆之外,Thread、Class、Code、GC、Direct 和 native 共需要多少?
重型服务不能套普通服务的 request/limit。它们应单独建立基线,并考虑滚动发布时新旧 Pod 同时存在的节点容量。
第 12 步:探针不要把短时拥塞变成重启风暴
内存或依赖压力升高时,健康接口也可能变慢。如果 liveness 只允许几十秒,Kubernetes 会在应用最忙时不断重启它,形成:
启动分配高峰 → 探针超时 → 重启 → 再次启动分配高峰
可以从下面的职责拆分开始:
startupProbe:
httpGet:
path: /actuator/health/liveness
port: http
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 30
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: http
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 2
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: http
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 12
- startup 负责给慢启动足够窗口。
- readiness 先快速摘除流量。
- liveness 只处理真正无法恢复的进程状态。
探针放宽不能修复内存泄漏,但能避免一次短时 GC、数据库抖动或依赖超时被放大成集群级重启。
一份可复用的 JDK 17 启动基线
#!/bin/sh
set -eu
DUMP_DIR="/data/dumps/${APP_NAME}/${HOSTNAME}"
mkdir -p "$DUMP_DIR" /data/logs
set -- java \
-XX:+UseContainerSupport \
-Xms768m \
-XX:MaxRAMPercentage=65.0 \
-XX:+UseG1GC \
-XX:MetaspaceSize=256m \
-XX:MaxMetaspaceSize=512m \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:+ExitOnOutOfMemoryError \
-XX:HeapDumpPath="$DUMP_DIR/heap.hprof" \
-Xlog:gc*,safepoint:file=/data/logs/gc-%t.log:time,uptime,level,tags:filecount=10,filesize=20M
if [ "${NMT_ENABLED:-false}" = "true" ]; then
set -- "$@" -XX:NativeMemoryTracking=summary
fi
if [ "${JPROFILER_ENABLED:-false}" = "true" ]; then
set -- "$@" \
-agentpath:/opt/jprofiler/bin/linux-x64/libjprofilerti.so=port=8849,nowait
fi
set -- "$@" -jar /app/application.jar
exec "$@"
上线后必须验证真实命令:
jcmd 1 VM.command_line
jcmd 1 VM.flags
jcmd 1 GC.heap_info
推荐的分阶段上线顺序
不要一次修改十几个参数,否则内存下降后也说不清是哪个参数起了作用。
阶段 A:只增加观测
- 记录 Pod working set、RSS、Heap、线程数。
- 增加轮转 GC 日志。
- 在一个副本上短时启用 NMT。
阶段 B:移除诊断常驻开销
- 默认关闭 JProfiler。
- 对 glibc 镜像试点
MALLOC_ARENA_MAX=4。 - 比较 CPU 与延迟,确认没有明显回退。
阶段 C:重算堆与容器余量
- 从固定
-Xmx4g改为经过计算的百分比或更小的显式-Xmx。 - 保留至少 30%–35% 给非堆与 native。
- 分普通服务和重型服务两套基线。
阶段 D:治理线程与消息在途量
- 收敛线程池。
- 降低消息并发和 prefetch。
- 验证积压、吞吐和业务延迟。
阶段 E:校准资源与探针
- 按稳态和峰值调整 request/limit。
- 确保滚动发布时节点能容纳临时副本。
- readiness 快速摘流量,liveness 避免误杀。
验收表
| 指标 | 基线 | 候选配置 | 验收方向 |
|---|---|---|---|
| 启动后 10 分钟 working set | 记录 | 记录 | 不高于基线 |
| 业务高峰 P95 working set | 记录 | 记录 | 留有 limit 余量 |
| Heap used / committed | 记录 | 记录 | 与实际 live set 匹配 |
| Thread committed | 记录 | 记录 | 无无效增长 |
| Full GC 次数 | 记录 | 记录 | 不因缩堆显著增加 |
| 请求 P95/P99 | 记录 | 记录 | 无明显回退 |
| 消息积压 | 记录 | 记录 | 在业务 SLA 内 |
| OOMKilled / Restart | 记录 | 记录 | 不再异常增加 |
至少跨过一个真实业务高峰再下结论。内存从 4 GiB 降到 1.5 GiB,如果代价是频繁 Full GC 或消息积压,并不是成功优化。
最终结论
这轮 JDK 17 治理真正有效的不是某一个“神奇参数”,而是把以下边界同时收紧:
- Heap 不再吃满容器,给 native 内存留出明确空间。
- G1 保持简单,先解决对象分配而不是定时强制 GC。
- JProfiler 改为按需启用。
- 用 NMT、RSS 和 cgroup 数据区分 Heap 与 Native。
- 收敛线程、消息并发和 prefetch。
- 限制 glibc arena,并用延迟与 CPU 验证副作用。
- 保留 GC 日志、Heap Dump 和退出原因,让下次故障可复盘。
- 用合理探针避免短时拥塞演变成重启风暴。
从“所有服务都给 4G”转向“每类服务都有可解释的内存预算”,才是这次优化最重要的结果。
DISCUSSION
讨论与反馈