2024 年我们一个 Java 微服务在 K8s 集群里频繁被 OOMKilled,每天重启 20+ 次,P99 延迟尖刺,业务方反复投诉。日志里只有冷冰冰的 exit code 137,JVM 没留下任何 OOM 痕迹。投了一周排查,发现是 JVM 堆外内存 + Pod limit + cgroup v2 三个问题叠加。修完后服务 30 天零重启,P99 从 800ms 降到 80ms,内存利用率从 60% 提到 85%。本文复盘 K8s + JVM 内存治理的完整链路。
事故现场
服务:订单中心(Spring Boot 2.7 + JDK 17)
部署:K8s 1.28(cgroup v2),20 Pod
资源:Pod limit 2GB,JVM -Xmx 1500m
现象:
- 每天 20+ 次 OOMKilled
- 重启时 P99 飙到 30s
- 业务报错率短时尖刺 5%
排查日志:
$ kubectl describe pod order-xxx
State: Waiting
Reason: CrashLoopBackOff
Last State: Terminated
Reason: OOMKilled
Exit Code: 137
Started: Mon, 10 Mar 2024 14:23:11 +0800
Finished: Mon, 10 Mar 2024 14:51:33 +0800
$ kubectl logs order-xxx --previous
... (业务日志正常,没有 OutOfMemoryError)
... 进程被强杀,没留下 heap dump
$ dmesg | grep -i kill
[123456.789] Memory cgroup out of memory: Killed process 1234 (java)
# JVM 没 OOM,是被 cgroup 干掉的 → 堆外内存爆了
JVM 内存模型(K8s 视角)
Pod limit 2GB
├── JVM Heap(-Xmx) 1500MB
├── JVM Metaspace 200MB
├── Code Cache 50MB
├── Compressed Class Space 50MB
├── Direct Memory(NIO) ?(默认 = Xmx)
├── Thread Stack 1MB × 线程数
├── JNI / Native Memory ?(无限制)
└── 其他(GC、JIT 等) ~200MB
总和:1500 + 200 + 50 + 50 + 1500 + (1MB×300) + 200 = 3800MB
Pod limit:2000MB
→ 必然 OOMKilled
问题 1:JVM 没感知 cgroup limit
# 老版本 JDK 不感知 cgroup(JDK 8u131 才支持)
# 容器内 -Xmx 不设,JVM 会按宿主机内存(256GB)算 Heap
# 结果:宿主机有 N 个容器,每个都觉得自己有 256GB
# 检查
$ docker exec -it order-pod java -XshowSettings:vm 2>&1 | grep -i max
Max. Heap Size (Estimated): 254.50G # 错!应该按 Pod limit
# 修复 1:升级到 JDK 11+(默认 UseContainerSupport)
# JDK 8u191+:-XX:+UseContainerSupport(默认开)
# 修复 2:显式设比例
-XX:InitialRAMPercentage=50.0
-XX:MaxRAMPercentage=70.0
# Pod 2GB → Heap = 1400MB(留 30% 给堆外)
# 修复 3:固定 Xmx(推荐生产用)
-Xms1400m -Xmx1400m
# 不要 Xms 远小于 Xmx,会动态扩缩,慢
问题 2:堆外内存失控
// NIO Direct Memory(Netty 默认用)
// 默认 -XX:MaxDirectMemorySize = Xmx(没显式设)
// → Xmx 1.4G,Direct 1.4G,加起来 2.8G,爆了
// 启动参数:
-XX:MaxDirectMemorySize=512m
-Dio.netty.maxDirectMemory=0 // 禁用 Netty 自管,走 JDK
// 监控堆外
@RestController
public class MemoryEndpoint {
@GetMapping("/admin/memory")
public Map<String, Object> memory() {
Map<String, Object> result = new HashMap<>();
// Heap
MemoryUsage heap = ManagementFactory.getMemoryMXBean().getHeapMemoryUsage();
result.put("heap_used_mb", heap.getUsed() / 1024 / 1024);
result.put("heap_max_mb", heap.getMax() / 1024 / 1024);
// Non-Heap(Metaspace + Code Cache)
MemoryUsage nonHeap = ManagementFactory.getMemoryMXBean().getNonHeapMemoryUsage();
result.put("non_heap_used_mb", nonHeap.getUsed() / 1024 / 1024);
// Direct Memory(反射拿,JDK 没暴露 API)
BufferPoolMXBean direct = ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class)
.stream()
.filter(b -> "direct".equals(b.getName()))
.findFirst()
.orElse(null);
if (direct != null) {
result.put("direct_used_mb", direct.getMemoryUsed() / 1024 / 1024);
result.put("direct_count", direct.getCount());
}
// 线程数 × 栈大小
ThreadMXBean threadMX = ManagementFactory.getThreadMXBean();
result.put("thread_count", threadMX.getThreadCount());
// Process RSS(实际占用)
try {
String stat = new String(Files.readAllBytes(Paths.get("/proc/self/status")));
for (String line : stat.split("\n")) {
if (line.startsWith("VmRSS:")) {
result.put("rss_mb", Long.parseLong(line.split("\\s+")[1]) / 1024);
}
}
} catch (Exception ignored) {}
return result;
}
}
// 输出:
// heap_used_mb: 800, heap_max_mb: 1400
// non_heap_used_mb: 250
// direct_used_mb: 450 ← 这就是问题!
// direct_count: 1234
// rss_mb: 1900 ← 接近 2GB limit
// thread_count: 280
问题 3:NMT(Native Memory Tracking)定位
# 启动 JVM 加参数
-XX:NativeMemoryTracking=detail
-XX:+UnlockDiagnosticVMOptions
-XX:+PrintNMTStatistics
# 运行时查询
$ jcmd 1 VM.native_memory summary
Native Memory Tracking:
Total: reserved=3.2GB, committed=2.8GB
- Java Heap (reserved=1.5GB, committed=1.5GB)
- Class (reserved=300MB, committed=250MB)
- Thread (reserved=280MB, committed=280MB) ← 280 线程 × 1MB
- Code (reserved=120MB, committed=80MB)
- GC (reserved=80MB, committed=80MB)
- Compiler (reserved=20MB, committed=20MB)
- Internal (reserved=50MB, committed=50MB)
- Symbol (reserved=30MB, committed=30MB)
- Native Memory Tracking (reserved=10MB, committed=10MB)
- Arena Chunk (reserved=20MB, committed=20MB)
- Other (reserved=450MB, committed=450MB) ← Direct buffer
# 对比 Heap dump
$ jcmd 1 GC.heap_dump /tmp/heap.hprof
# 用 MAT / VisualVM 分析,找内存泄漏
# 主要看:
# 1. 大对象(byte[] / char[])
# 2. 长生命周期(ThreadLocal、static Map 没清理)
# 3. ClassLoader 泄漏(每次热加载,Metaspace 涨)
问题 4:线程数失控
// 280 线程 × 1MB 栈 = 280MB(堆外)
// 我们的服务为啥这么多线程?
// 1. Tomcat 工作线程
server:
tomcat:
threads:
max: 200 # 默认 200,太多了
min-spare: 20
// 2. 数据库连接池
spring:
datasource:
hikari:
maximum-pool-size: 50 # 不需要这么多
// 3. RestTemplate 没共享(每次 new 一个客户端)
// 4. CompletableFuture 用 ForkJoinPool.commonPool(共享,但容易满)
// 5. Scheduled tasks 配置不当
// 修复:
// 1. Tomcat threads.max = 50(配 HikariCP 50 = 1:1)
// 2. 共享 HTTP 客户端(OkHttp connection pool)
// 3. 自定义 ExecutorService,显式管理生命周期
// 4. 减小 -Xss(默认 1MB → 512KB)
// 验证
$ jstack 1 | grep "java.lang.Thread.State" | wc -l
280 → 100 (砍掉 180 个)
问题 5:Metaspace 持续涨
# Metaspace 一周从 100MB 涨到 280MB
# 原因:Spring Boot DevTools 热加载,ClassLoader 泄漏
# 监控
$ jstat -gc 1 5s
# 关闭热加载(生产必须)
spring:
devtools:
restart:
enabled: false
# 限制 Metaspace
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=256m
# 监控告警
- alert: MetaspaceHigh
expr: jvm_memory_used_bytes{area="nonheap",id="Metaspace"} / 1024 / 1024 > 200
for: 10m
annotations:
summary: "Metaspace > 200MB,可能 ClassLoader 泄漏"
问题 6:cgroup v2 + 内存核算
# K8s 1.25+ 默认 cgroup v2
# 内存核算口径变化:
# - cgroup v1:RSS + Page Cache
# - cgroup v2:anon + file + sock + ...
# 看 Pod 实际内存
$ kubectl exec order-xxx -- cat /sys/fs/cgroup/memory.current
1856372736 # 1.73G
$ kubectl exec order-xxx -- cat /sys/fs/cgroup/memory.max
2147483648 # 2GB
# 详细分类
$ kubectl exec order-xxx -- cat /sys/fs/cgroup/memory.stat
anon 1234567890 # 匿名内存(堆 + 堆外)
file 234567890 # Page Cache
kernel 12345678
sock 1234567
# OOMKilled 触发条件:memory.current >= memory.max
# Page Cache 也算!所以日志写盘多的服务要注意
最终启动参数
#!/bin/bash
# Pod limit 2GB
JAVA_OPTS="
-Xms1400m -Xmx1400m
-Xss512k
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=256m
-XX:MaxDirectMemorySize=256m
-XX:ReservedCodeCacheSize=64m
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:G1HeapRegionSize=16m
-XX:+ParallelRefProcEnabled
-XX:+UnlockExperimentalVMOptions
-XX:+UseStringDeduplication
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/heapdump.hprof
-XX:+ExitOnOutOfMemoryError
-XX:+PrintGCDetails
-Xloggc:/var/log/gc.log
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=5
-XX:GCLogFileSize=10M
-XX:NativeMemoryTracking=summary
-Dio.netty.maxDirectMemory=0
-Dfile.encoding=UTF-8
"
# 内存预算:
# Heap: 1400MB
# Metaspace: 256MB
# Direct: 256MB
# Stack: 50MB(100 线程 × 512KB)
# Code: 64MB
# Other: ~150MB(GC、JIT、Native)
# Total: ~2176MB → Pod 配 2.2GB 比较安全
exec java $JAVA_OPTS -jar /app/app.jar
K8s 配置
apiVersion: apps/v1
kind: Deployment
metadata:
name: order
spec:
replicas: 20
template:
spec:
containers:
- name: app
image: order:v1.2.3
resources:
requests:
memory: "2Gi" # request == limit,QoS Guaranteed
cpu: "1"
limits:
memory: "2Gi"
cpu: "2"
env:
- name: JAVA_TOOL_OPTIONS
value: "-XX:MaxRAMPercentage=70.0" # 兜底
# 优雅终止
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 15"]
# Liveness/Readiness 分开
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
startupProbe: # 启动慢就用这个
httpGet:
path: /actuator/health
port: 8080
failureThreshold: 30
periodSeconds: 5
terminationGracePeriodSeconds: 60
监控告警
# Prometheus + JMX Exporter
- alert: PodMemoryHigh
expr: container_memory_working_set_bytes{pod=~"order-.*"} / container_spec_memory_limit_bytes > 0.85
for: 5m
annotations:
summary: "Pod 内存 > 85% limit,可能 OOMKilled"
- alert: JvmHeapHigh
expr: jvm_memory_used_bytes{area="heap"} / jvm_memory_max_bytes{area="heap"} > 0.85
for: 10m
annotations:
summary: "JVM Heap > 85%,可能内存泄漏"
- alert: JvmDirectMemoryHigh
expr: jvm_buffer_memory_used_bytes{id="direct"} / 1024 / 1024 > 200
for: 5m
annotations:
summary: "Direct Memory > 200MB"
- alert: ThreadCountHigh
expr: jvm_threads_live_threads > 200
for: 10m
annotations:
summary: "线程数 > 200,可能泄漏"
- alert: OOMKilledRate
expr: increase(kube_pod_container_status_terminated_reason{reason="OOMKilled"}[1h]) > 0
annotations:
summary: "Pod 1h 内被 OOMKilled"
# 自动 heap dump 上传
preStop:
exec:
command:
- sh
- -c
- |
if [ -f /var/log/heapdump.hprof ]; then
ossutil cp /var/log/heapdump.hprof oss://heapdumps/$POD_NAME-$(date +%s).hprof
fi
sleep 15
优化效果
指标 优化前 优化后
========================================================
Pod 重启次数(天) 20+ 0
平均 RSS 1.9GB(危险) 1.5GB
内存利用率 60%(浪费) 85%
线程数 280 100
Metaspace 280MB(还在涨) 200MB(稳)
Direct Memory 450MB(失控) 180MB
P50 延迟 100ms 30ms
P99 延迟 800ms(GC尖刺) 80ms
Young GC 频率 1 次/秒 1 次/10秒
Full GC 每天 5 次 每周 0-1 次
业务影响:
- 业务报错率从 0.5% 降到 0.01%
- SLA 从 99.5% 提升到 99.99%
- Pod 资源(20 个)省下来,节约月成本 6000 元
- on-call 不再被半夜叫起来
避坑清单
- JDK 11+ 默认 UseContainerSupport,JDK 8 必须 u191+
- 显式设 -Xmx(不要让 JVM 自己估)
- 显式设 -XX:MaxDirectMemorySize(默认 = Xmx 太大)
- NMT 必开(NativeMemoryTracking=summary 几乎零开销)
- HeapDumpOnOutOfMemoryError + ExitOnOutOfMemoryError
- 线程数控制(Tomcat threads.max + DB pool + Xss 一起调)
- Pod request == limit(QoS Guaranteed,不会被驱逐)
- cgroup v2 注意 Page Cache 也计入
- 分 Liveness / Readiness / Startup 三种探针
- terminationGracePeriodSeconds 给足 preStop 时间
总结
K8s 上的 JVM 内存治理是个系统工程:不是简单调个 -Xmx 就完事。Pod limit 2GB,你能给 Heap 多少?不是 1.9GB,是 1.4GB 左右,剩下 600MB 要留给堆外(Direct、线程栈、Metaspace、Code Cache、Native)。这次最大的认知改变是:OOMKilled 跟 OutOfMemoryError 不是一回事 — 前者是 cgroup 干的,后者是 JVM 自己。如果只看 JVM 日志找不到 OOM,基本就是堆外炸了,必须开 NMT 才能定位。最被低估的工具是 NMT,几乎零开销,定位堆外问题神器。最容易被忽视的是线程栈:300 个线程 × 1MB = 300MB,直接吃掉小 Pod 的可用堆外内存。最后,生产环境强烈建议 request == limit,Guaranteed QoS 让 Pod 不会被驱逐,稳定性远胜于"节省资源"的 BestEffort 配置。
—— 别看了 · 2026