本文来自一批 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 spaceJava 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 Heap
  • Class
  • Thread
  • Code
  • GC
  • Compiler
  • Internal

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 没有随之下降。这说明:

  1. 存活对象确实减少了。
  2. JVM 或分配器仍可能保留已提交内存页。
  3. “GC 后 Heap used 下降”与“容器立刻归还同等 RSS”不是同一件事。

同一 ReplicaSet 的两个 Pod,为什么一个 1.8G、一个 1.2G

另一次排查中,两个副本来自同一 ReplicaSet,镜像、JVM 参数、配置和 limit 完全一致:

指标Pod APod 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 / OOM00

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。更稳妥的顺序是:

  1. 删除重复线程池。
  2. 根据容器 CPU 限制线程池最大值。
  3. 收敛消息消费并发和预取。
  4. 最后再压测 -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

计算时要回答三个问题:

  1. 稳态 P95 内存是多少,request 能否覆盖正常运行?
  2. 启动和批处理峰值是多少,limit 是否保留安全余量?
  3. 最大堆之外,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 治理真正有效的不是某一个“神奇参数”,而是把以下边界同时收紧:

  1. Heap 不再吃满容器,给 native 内存留出明确空间。
  2. G1 保持简单,先解决对象分配而不是定时强制 GC。
  3. JProfiler 改为按需启用。
  4. 用 NMT、RSS 和 cgroup 数据区分 Heap 与 Native。
  5. 收敛线程、消息并发和 prefetch。
  6. 限制 glibc arena,并用延迟与 CPU 验证副作用。
  7. 保留 GC 日志、Heap Dump 和退出原因,让下次故障可复盘。
  8. 用合理探针避免短时拥塞演变成重启风暴。

从“所有服务都给 4G”转向“每类服务都有可解释的内存预算”,才是这次优化最重要的结果。