2025 年 9 月初,我接手一个新组,过去 3 个月他们核心服务的 Pod 几乎每天都被 OOMKilled 一次,但应用日志看起来一切正常:没有 OutOfMemoryError、没有内存泄漏告警、JVM 监控显示堆使用率长期稳定在 60% 以下,GC 也没有异常。运维说"加内存就行了",从 4Gi 加到 8Gi、再加到 16Gi,Pod 还是 OOMKilled,业务方已经骂了三个月。我花了 9 天时间,扒开容器、JVM、Linux cgroup v1/v2、kubelet 这一整套链路,才把真凶找出来:**JVM 堆只是冰山一角,真正吃内存的是 Direct Memory、Metaspace、JNI、栈、CodeCache、Native Library 这些"看不见"的部分**。这篇文章把这次 9 天的复盘、6 层因果链、4 种修法、以及给 K8s + JVM 应用做内存治理的完整 SOP 都记下来。
服务背景
服务是一个运行在 K8s 上的 Java 应用,做风控规则引擎,Spring Boot 3.2 + Java 21,核心逻辑是把 Drools 7.74 规则引擎和 RocksDB 本地缓存集成在一起。日均处理 4 亿次规则评估,峰值 QPS 1.5 万。部署形态:32 个 Pod,每个 Pod 请求 8 核 16Gi 内存,JVM 启动参数 -Xms8g -Xmx8g,Drools 工作内存峰值约 2GB,RocksDB block cache 配 3GB。理论上 8G 堆 + 3G off-heap + 2G 工作内存 = 13G,留 3G 给 OS 和 sidecar 应该够用。
但事实是这套配置在生产环境下,平均 8-12 小时就会有一个 Pod 被 OOMKilled,kubectl describe pod 看到 "Last State: Terminated, Reason: OOMKilled, Exit Code: 137",pod 重启后又能跑 8-12 小时。每天大约 32 个 Pod 里有 3-5 个会经历一次重启,虽然不影响业务可用性(K8s 自动重启 + 滚动恢复),但每次重启都会丢掉 30 分钟的 RocksDB 缓存预热,P99 延迟会从平时的 80ms 飙到 600ms 持续 30 分钟,业务方天天投诉。
事故时间线(我接手后 9 天的复盘)
| 时刻 | 事件 |
|---|---|
| D1 上午 | 接手了解情况:Pod OOMKilled 已持续 3 个月,前任团队加了 4 次内存(4Gi → 8Gi → 12Gi → 16Gi)都没解决 |
| D1 下午 | 看 JVM 监控:堆使用率 60%,GC 正常,没有 OutOfMemoryError 日志。看起来应用很健康 |
| D2 | 怀疑 K8s memory request/limit 配置问题,改 limit 从 16Gi 提到 24Gi,Pod 跑 14 小时仍 OOMKilled |
| D3 | 开始怀疑 Native Memory,引入 Java 17+ 的 Native Memory Tracking(-XX:NativeMemoryTracking=detail) |
| D4 | NMT 数据出来:Direct Memory 持续增长,从 0 涨到 3.2GB(Netty buffer 泄漏迹象) |
| D5 | jcmd VM.native_memory baseline.diff 显示 Other (mmap) 在持续增长,RocksDB 嫌疑 |
| D6 | 用 jemalloc + heaptrack 抓 native 堆栈,定位到 RocksDB 的 BlockBasedTableOptions 配置错误 |
| D7 | 同时发现一个 Drools rule reload 没释放 ClassLoader,Metaspace 泄漏 |
| D8 | 4 种修法上线灰度 4 个 Pod,稳定 48 小时不 OOMKilled |
| D9 | 全量上线,业务方第一次说"今天没有报警" |
第一轮排查弯路:为什么"堆使用率正常"骗了我们三个月
这次事故最坑爹的地方,是前任团队完全相信了 JVM 堆监控。他们在 Grafana 上盯着堆使用率,看到长期在 60% 以下就以为没问题,加内存就指望"反正 K8s limit 也大了,JVM 堆够,OS 总能找到空间"。但 K8s 的 memory limit 是给整个容器(包括 JVM 堆、Metaspace、Direct Memory、栈、CodeCache、Native Library、kernel page cache)用的,JVM 堆只占其中一部分。
# 进入容器看一下进程的内存实际分布
$ kubectl exec -it risk-engine-7d8f6c4-x2mqr -- bash
$ cat /proc/$(pgrep java)/status | grep -E 'VmRSS|VmSize|VmPeak|VmData|VmStk'
VmPeak: 18937284 kB # 进程历史峰值虚拟内存
VmSize: 17234856 kB # 当前虚拟内存
VmData: 16482844 kB # 数据段大小(包括堆+native)
VmRSS: 15683072 kB # 实际驻留物理内存,15.7GB!
VmStk: 132 kB
# JVM 堆只配了 -Xmx8g,但 RSS 是 15.7G,差了 7.7G
# 这 7.7G 去哪儿了?这就是这次事故的核心问题
更可气的是,堆监控这个误导一直在持续:JVM 内部的 jmx 端口暴露的堆指标永远是稳定的,因为 -Xmx8g 顶死了,堆超不过 8G。但 Pod 实际用了 15.7G,只要再涨 0.3G 就会撞上 16Gi 的 K8s limit,然后内核 cgroup-oom-kill 直接 SIGKILL 把进程杀掉。SIGKILL 是不能被捕获的,JVM 来不及打印任何错误日志,Pod 就这样"无声地"重启了。
问题本质:Java 在容器里的内存分层
Java 进程在容器里的内存,本质上分成 6 大块:
关键的反直觉是:JVM 的 -Xmx 只控制堆,其他几块都不在 -Xmx 范围内。所以一个看起来"堆只用 60%"的 JVM,可以在堆外占用 5-10GB 的内存,完全不会触发任何 JVM 内部的 OOM,但 K8s cgroup 会觉得"你超 limit 了"就把你 SIGKILL。这就是为什么应用日志看起来正常,但 Pod 一直被 OOMKilled。
NMT(Native Memory Tracking)的真相
Java 8 开始就提供了 NMT,但很多人不会用。在生产环境开启 NMT 的成本约 5-10% CPU + 几十 MB 额外内存,对排查问题来说完全可以接受。开启后用 jcmd 可以看到 JVM 自己跟踪的所有 native 内存(注意:NMT 看不到 JNI 和 Native Library 的内存,后面会讲)。
# 启动参数加上(在 K8s deployment 的 JAVA_OPTS 里)
-XX:NativeMemoryTracking=detail
# 进容器后执行(注意 jcmd 必须是同一个用户)
$ jcmd $(pgrep java) VM.native_memory summary
Native Memory Tracking:
Total: reserved=22834MB, committed=14821MB
- Java Heap (reserved=8192MB, committed=8192MB)
(mmap: reserved=8192MB, committed=8192MB)
- Class (reserved=1153MB, committed=512MB) # Metaspace
(classes #58672) # 5.8 万个类!异常多
(instance classes #54983, array classes #3689)
(malloc=20MB)
(mmap: reserved=1133MB, committed=492MB)
- Thread (reserved=380MB, committed=380MB)
(thread #380) # 380 个线程,每个 1MB 栈
(stack: reserved=378MB, committed=378MB)
- Code (reserved=251MB, committed=128MB) # CodeCache
- GC (reserved=441MB, committed=441MB) # G1 GC 数据结构
- Compiler (reserved=2MB, committed=2MB)
- Internal (reserved=15MB, committed=15MB)
- Other (reserved=3247MB, committed=3247MB) # Direct Memory + mmap
# 这一块很大!Direct Buffer Pool 涨到 3.2GB
- Symbol (reserved=39MB, committed=39MB)
- Native Memory Tracking (reserved=11MB, committed=11MB)
- Shared class space (reserved=12MB, committed=12MB)
- Arena Chunk (reserved=82MB, committed=82MB)
# 总和:14821MB committed
# 加上 RocksDB 自己的 mmap(NMT 看不见)约 3GB
# 实际 RSS 接近 17-18GB,超过 16Gi limit 就被 kill
NMT summary 一出来就发现两个问题:Class(Metaspace)有 5.8 万个加载的类,这对一个 Spring Boot 应用太多了,正常应该 2-3 万。Other 项 3.2GB 一看就是 Direct Memory 或 mmap 在膨胀。两个问题指向不同的 root cause。
第一个真凶:Drools 规则 reload 导致 ClassLoader 泄漏
用 NMT 的 baseline + diff 模式可以看到 Class 部分在持续增长:
# 记录一个基线
$ jcmd $(pgrep java) VM.native_memory baseline
# 等 1 小时业务跑一会儿,再 diff
$ jcmd $(pgrep java) VM.native_memory summary.diff
Native Memory Tracking:
Total: reserved=+412MB, committed=+412MB
- Class (reserved=+82MB, committed=+82MB) # Metaspace 还在涨
(classes #+612)
(instance classes #+580, array classes #+32)
- Other (reserved=+278MB, committed=+278MB) # Other 也在涨
...
# Class 1 小时涨 82MB,580 个新类,这不正常
# 一个稳态运行的 Spring Boot 不应该一直加载新类
顺着这个线索查 Drools 的代码,发现业务侧实现了一个"热更新规则"的接口,每 5 分钟从配置中心拉一次规则。每次拉到新规则后,代码里调用 KieServices.newKieClasspathContainer() 重新生成 KieContainer,但旧的 KieContainer 没有显式调用 dispose(),导致每次 reload 都会有上百个 Drools 生成的 Class(每条规则对应一个 dynamic class)泄漏在 Metaspace 里,旧的 KieContainer 也保留了对这些 Class 的强引用,GC 永远回收不掉。
// 错误写法
private KieContainer kieContainer;
public void reloadRules() {
// 每 5 分钟调用一次
kieContainer = KieServices.Factory.get()
.newKieClasspathContainer(); // 旧的 container 没释放!
// GC 不能回收 KieContainer 内的 ClassLoader,因为没有 dispose
}
// 正解
public void reloadRules() {
KieContainer oldContainer = this.kieContainer;
this.kieContainer = KieServices.Factory.get().newKieClasspathContainer();
if (oldContainer != null) {
try {
oldContainer.dispose(); // 必须显式释放
} catch (Exception e) {
log.warn("dispose old container failed", e);
}
}
}
第二个真凶:RocksDB block cache 实际占用是配置的 1.5 倍
Drools 修复后,Pod 寿命从 8 小时延长到 24 小时,但还是会 OOMKilled,RSS 还是会涨到 15GB+。继续用 jcmd VM.native_memory 看不出异常,因为 NMT 不跟踪 JNI 调用分配的内存(RocksDB 是 C++ 实现,通过 JNI 集成)。这时候只能用更底层的工具:jemalloc + heaptrack 或者 perf。
# 安装 jemalloc 替代 glibc malloc(可以做内存分析)
$ apk add jemalloc-dev
# 启动 JVM 时 LD_PRELOAD
$ LD_PRELOAD=/usr/lib/libjemalloc.so.2 \
MALLOC_CONF="prof:true,prof_active:true,lg_prof_interval:30,lg_prof_sample:17,prof_prefix:/tmp/jeprof" \
java -jar app.jar &
# 业务跑一会儿后,导出 profile
$ jeprof --show_bytes --pdf /usr/bin/java /tmp/jeprof.*.heap > heap.pdf
$ jeprof --text /usr/bin/java /tmp/jeprof.*.heap | head -30
Total: 3247.2 MB
1240.5 MB 38.2% RocksDB BlockBasedTable::BlockBasedTable
783.4 MB 24.1% rocksdb::BlockBasedTableBuilder::Add
412.3 MB 12.7% Netty PooledByteBufAllocator
...
jeprof 一下就指向 RocksDB 占用 1.2GB + 0.78GB = 2GB,配置里写的 block_cache 是 3GB,看起来"用了三分之二还行"。但翻 RocksDB 源码才发现一个深坑:block_cache 配的 3GB 只是 user data block cache,index block 和 filter block 默认还会额外消耗内存,且不在 block_cache 配额内。对于一个有 200 万 keys 的 RocksDB,filter block(Bloom Filter)会占额外 800MB-1.2GB,index block 占 400-600MB。
// 错误配置
BlockBasedTableConfig config = new BlockBasedTableConfig();
config.setBlockCache(new LRUCache(3L * 1024 * 1024 * 1024)); // 3GB
// 但 filter 和 index 不算在内,实际会多 1.5GB
// 正解
BlockBasedTableConfig config = new BlockBasedTableConfig();
LRUCache sharedCache = new LRUCache(3L * 1024 * 1024 * 1024);
config.setBlockCache(sharedCache);
config.setCacheIndexAndFilterBlocks(true); // 让 filter/index 也走 cache
config.setPinL0FilterAndIndexBlocksInCache(true); // L0 的 pin 住,不被淘汰
config.setCacheIndexAndFilterBlocksWithHighPriority(true);
// 这样 3GB 是真实总上限
4 种修法的实测对比
| 修法 | 修复点 | OOMKilled 频率 | RSS 峰值 |
|---|---|---|---|
| 原始(基线) | 无 | 每 8-12 小时 1 次 | 15.8GB |
| 修法 1:Drools dispose | 修 Metaspace 泄漏 | 每 24 小时 1 次 | 14.5GB |
| 修法 2:+ RocksDB 修配置 | 把 filter/index 算入 cache | 7 天 0 次 | 11.2GB |
| 修法 3:+ Netty maxDirectMemory | 限制 Netty Direct Buffer | 30 天 0 次 | 10.8GB |
| 修法 4:+ K8s requests=limits 同值 | 避免被驱逐 + cgroup v2 调优 | 稳定 60 天+ | 10.5GB |
排查决策树:看到 K8s Pod OOMKilled 但应用日志正常时
我们立的 10 条 K8s + JVM 内存治理纪律
- K8s memory limit 必须 = JVM Xmx + Metaspace + DirectMemory + 栈 + CodeCache + 2GB 系统冗余,不要凭感觉。我们的公式是 limit = Xmx * 1.6,即留 60% 余量给堆外。
- requests = limits,使用 Guaranteed QoS,避免节点压力下被驱逐(memory request 小于 limit 时是 Burstable QoS,可被 evict)。
- 所有 Java 应用必须开启 NMT,生产环境用 summary 级别即可,排查时切到 detail。
- JVM 必须设 -XX:MaxDirectMemorySize,不设的话默认等于 Xmx,Netty 这种重度使用 Direct Buffer 的库会吃光。
- JVM 必须设 -XX:MaxMetaspaceSize,不设默认无限,会无声膨胀。我们的标准是 512MB。
- 设置 -XX:+HeapDumpOnOutOfMemoryError + ExitOnOutOfMemoryError,确保 OOM 时留下证据并立刻退出而不是僵死。
- 线程数严格控制,每个线程 1MB 栈,200 个线程就是 200MB。Tomcat、Netty 的线程池都要显式配上限。
- 用 jemalloc 替代 glibc malloc,减少 native 内存碎片,平均能省 10-15% RSS。
- 监控容器 RSS 而不是 JVM 堆,Prometheus container_memory_working_set_bytes 才是真实的内存压力指标。
- 每个 release 跑长稳测试,至少 48 小时观察 RSS 趋势,避免泄漏带病上线。
cgroup v1 vs v2 的陷阱
事故复盘过程中我们发现一个新坑:cgroup v1 和 v2 在 OOMKill 行为上不太一样。cgroup v1 是 memory.limit_in_bytes,触发 OOM 时内核会选一个进程 kill;cgroup v2 是 memory.max,有 memory.oom.group 选项,可以选择整个 cgroup 一起被 kill。Linux 5.x 之后多数发行版默认 cgroup v2,K8s 1.25+ 也开始默认推 v2。
# 看当前用的 cgroup 版本
$ stat -fc %T /sys/fs/cgroup/
cgroup2fs # v2
# 或 tmpfs(v1)
# 在 cgroup v2 下看内存配置
$ cat /sys/fs/cgroup/memory.max
17179869184 # 16Gi
$ cat /sys/fs/cgroup/memory.current
16876544000 # 当前用了 15.7G
$ cat /sys/fs/cgroup/memory.events
low 0
high 124 # 124 次接近 high 阈值
max 18 # 18 次撞到 max
oom 3 # 3 次触发 oom
oom_kill 3 # 3 次 kill
# memory.events 是排查 OOMKilled 的金矿
# 可以看到历史上多少次内存压力,多少次真的 kill
容器化 Java 的 JVM 参数推荐(2025 实战版)
# 一份生产可用的 JVM 启动参数
JAVA_OPTS="
# 堆配置(根据容器 limit 设,通常 = limit * 0.6)
-Xms9g -Xmx9g
# G1 GC(Java 11+ 默认,大堆首选)
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:G1HeapRegionSize=16m
# 堆外内存上限
-XX:MaxDirectMemorySize=2g
-XX:MaxMetaspaceSize=512m
-XX:ReservedCodeCacheSize=256m
# OOM 时立刻退出 + dump
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dumps/
-XX:+ExitOnOutOfMemoryError
# Native Memory Tracking(生产 summary 即可)
-XX:NativeMemoryTracking=summary
# 容器内 CPU 限制感知(Java 10+)
-XX:+UseContainerSupport
-XX:InitialRAMPercentage=60
-XX:MaxRAMPercentage=60 # 不再用 -Xmx 而用百分比
# GC 日志(Java 9+ 统一日志)
-Xlog:gc*=info,gc+age=trace,safepoint:file=/data/logs/gc.log:time,uptime,level,tags:filecount=10,filesize=100M
# JFR 持续记录(便于事后分析)
-XX:StartFlightRecording=filename=/data/jfr/app.jfr,maxsize=2g,maxage=24h,settings=profile
"
容易踩的 6 个 K8s + Java 配置陷阱
这次事故的复盘,我把过去几年看到的 K8s + Java 配置陷阱整理了一遍,有 6 个最容易踩:
陷阱 1:Java 8 早期版本不识别容器内存限制。Java 8u131 之前 -Xmx 不会根据 cgroup 自动设置,会读到宿主机的物理内存。Java 8u191+ 默认开启 UseContainerSupport。**升级到 Java 11+ 是最简单的解决方案**。
陷阱 2:CPU 限制不识别导致线程数爆炸。早期 JVM 通过 Runtime.availableProcessors() 拿到的是宿主机核数,会创建很多线程。用 -XX:+UseContainerSupport 后才会读取 cgroup 的 cpu.shares。
陷阱 3:livenessProbe 设置不当导致频繁重启。如果 livenessProbe 的 initialDelaySeconds 配得太短,GC 暂停就被当成"不健康"重启了,业务跟着抖。我们的标准是 GC 配置 SLO + 探针 timeout 至少 5s,延迟检查至少 30s。
陷阱 4:HPA 用 CPU 触发但实际是内存瓶颈。这个组合下 HPA 永远不扩容,但内存不断逼近 limit。HPA 应该同时用 CPU 和 Memory(或自定义指标比如队列长度)。
陷阱 5:PV 挂载的 emptyDir 默认走 Memory。如果你用 emptyDir 做临时文件,默认 medium 是空,意味着用宿主机磁盘;但 medium: Memory 时会算在容器内存配额里。RocksDB 的 SST 文件如果放在 Memory emptyDir 上,本质上是把磁盘用量计入了内存,会被 OOMKilled。
陷阱 6:Pod 的 sidecar 容器内存计算。Istio 的 Envoy sidecar 默认要 1G 内存,但很多团队的 K8s deployment 只算了主容器的 memory limit,忽略 sidecar,导致整体 limit 不够。
用 pyroscope 做连续 profiling
事故彻底修复之后,我们引入了 Pyroscope 做连续 profiling,JVM、内存、CPU 火焰图可以在线看历史。这套体系上线后,我们在另一个组的服务里提前发现了 3 个潜伏的内存问题,在出事前就改掉了。
# Pyroscope 通过 javaagent 方式接入
# 在 deployment 里加 init container 下载 agent
spec:
containers:
- name: app
env:
- name: JAVA_TOOL_OPTIONS
value: "-javaagent:/agent/pyroscope.jar=server.address=http://pyroscope:4040,application.name=risk-engine"
volumeMounts:
- name: agent
mountPath: /agent
initContainers:
- name: download-agent
image: pyroscope/agent:latest
command: ['cp', '-r', '/agent/.', '/shared/agent/']
volumeMounts:
- name: agent
mountPath: /shared/agent
volumes:
- name: agent
emptyDir: {}
这次事故复盘的元教训
9 天复盘下来,除了技术上的收获,还有几条管理层面的教训值得说:
第一,"加内存"不是修复,是逃避。前任团队加了 4 次内存,本质上是用钱拖时间。真正的故障不会因为加资源消失,只会被推到更后面、规模更大。每加一次内存,IT 成本就涨 25%,半年下来公司账单凭空多了几十万。
第二,监控指标的"健康"是相对的。JVM 堆使用率监控看起来一切正常,但那只是冰山一角。监控体系必须有"容器视角"+"JVM 视角"+"业务视角"三个层次,缺一不可。我们后来把这套监控规范化,作为公司所有 Java 服务的默认接入。
第三,问题不解决会复利。前任团队 3 个月没解决这个问题,业务方对运维信任度直线下降,后来其他服务出问题大家也不上报了,演变成"小问题不处理,大问题不通报"。修复后我们花了好几个月才把这种信任重新建立起来。
深入 Native Memory:为什么 JVM 自己也不知道用了多少内存
事故复盘过程中,我跟团队花了不少时间讨论一个本质问题:为什么 JVM 自带的工具(jstat、jmap、jconsole)看到的内存使用情况和操作系统看到的不一样。这个问题的答案藏在 JVM 与 JNI、操作系统、内存分配器之间的边界里。
JVM 自己负责管理的内存包括堆(Java Heap)、Metaspace、CodeCache、内部结构(如 GC 元数据、Symbol Table、String Table 等)。这些 JVM 都能精确跟踪,通过 NMT 可以看到每一字节去哪儿了。但 Java 程序通过 JNI 调用 C/C++ 代码时,这些 C/C++ 代码用 malloc 或 mmap 分配的内存,JVM 是看不到的。RocksDB、Netty 原生模式、JNA、SQLite JDBC、各种压缩库(zstd、snappy、lz4)、加密库(BouncyCastle 的 native 实现)都属于这一类。
所以一个完整的内存清单应该是:JVM 自己跟踪的部分(用 NMT 看)+ JNI 库自己 malloc 的部分(用 jemalloc + jeprof 看)+ 共享库 mmap 的部分(用 pmap 看)+ kernel 给该进程的 page cache(用 cat /proc/$pid/smaps 看)。这四块加起来才等于操作系统看到的 RSS。多数情况下,大家只关注第一块,所以才会有"JVM 说我用了 8G,系统说我用了 15G"的错觉。
pmap 与 smaps:从内核视角看进程内存
pmap 是 Linux 上看进程内存映射的神器,可以列出进程地址空间里所有的内存区域,包括堆、栈、共享库、mmap 区域。配合 /proc/$pid/smaps 还能看到每个区域的实际 RSS、PSS(Proportional Set Size)、Swap 等详细信息。事故排查的关键一步就是用这个找到了 RocksDB 占用的大块 mmap 区域。
# 看进程所有内存区域(扩展信息,带 RSS)
$ pmap -x $(pgrep java) | sort -k 3 -n -r | head -20
Address Kbytes RSS Dirty Mode Mapping
0000000000400000 8388608 8388608 8388608 rw--- [ anon ] # JVM Heap 8G
00007f8c00000000 3145728 3145728 3145728 rw--- [ anon ] # 3G mmap 大块
00007f8b00000000 1048576 1048576 1048576 rw--- [ anon ] # 1G mmap 中块
... (RocksDB 的 SST 文件 mmap 区域)
00007f8a40000000 524288 442368 442368 r-x-- librocksdb.so
# 用 smaps 更详细
$ cat /proc/$(pgrep java)/smaps | grep -A 1 'rocksdb' | head -20
# 看每段 mmap 是谁映射的
$ cat /proc/$(pgrep java)/maps | grep -v anon | head
00400000-00401000 r-xp 00000000 fd:01 12345 /usr/bin/java
7f8c00000000-7f8d00000000 r--p 00000000 fd:02 67890 /data/rocksdb/000123.sst
# 通过文件名可以看出这是 RocksDB 的 SST 文件被 mmap 进来
从 pmap 的输出能很清楚地看到 JVM 进程地址空间的全貌:有的内存区域是 anon(匿名映射,通常是 malloc 或者 JVM Heap),有的是文件映射(SST 文件、共享库)。匿名内存吃 RSS,文件映射也吃 RSS(但 page cache 可以被回收)。这次事故里 3GB 的匿名 mmap 就是 RocksDB block cache,而 1GB 的文件映射是 SST 文件本身。
容器内存的"假统计":kernel page cache 的迷惑性
云原生环境里还有一个非常容易被忽视的细节:容器的 memory.current(或 cgroup v1 的 memory.usage_in_bytes)是包含 kernel page cache 的。也就是说,如果你的应用读写了大量文件,内核会把这些文件缓存在 page cache 里,这部分内存计入了 cgroup 用量,而 kubelet 的 OOMKiller 看的就是这个数字。
这就引发一个反直觉的现象:你的 Java 应用什么都没干,但只要它读了一遍大文件,page cache 就涨上去,有可能撞到 limit 被 kill。Kubernetes 在这一点上其实做了优化,kubelet 默认看的是 working_set(active 部分),而不是 total memory.current。但 working_set 还是包含 anonymous + active_file,不包含 inactive_file。所以如果你的服务有大量临时文件读写,inactive_file 不算,问题不大;但如果文件正在被频繁访问,active_file 不会被回收,会算进 working_set,这就成了 OOMKilled 的潜在隐患。
# 看 cgroup v2 详细内存统计
$ cat /sys/fs/cgroup/memory.stat | head -20
anon 7234567890 # 匿名内存(堆 + malloc + mmap anon)
file 2345678901 # 文件映射内存
kernel 234567890
kernel_stack 12345678
...
active_anon 7234567890
inactive_anon 0
active_file 1234567890 # 活跃文件 cache 计入 working_set
inactive_file 1111111111 # 不活跃文件 cache 不计入
# working_set 大致 = anon + active_file
# memory.current = anon + file + kernel + ...
# Kubernetes OOMKill 看的是 working_set 接近 memory.max
给团队培训的"内存观测三件套"
事故解决后我专门给团队做了一次培训,把容器化 Java 应用的内存观测工具总结成"三件套",每个组员上岗前必须会用。这三件套覆盖了从应用层到内核层的所有视角,基本上排查任何内存问题都用得上。
第一件:NMT(Native Memory Tracking)。开启方式 -XX:NativeMemoryTracking=detail,使用方式 jcmd VM.native_memory summary。它能告诉你 JVM 自身跟踪的所有内存,包括堆、Metaspace、Direct Memory、栈、CodeCache、GC 数据结构。对应的工具有 jstat(看 GC 频率)、jmap(看堆对象分布)、jconsole(图形界面)。
第二件:jemalloc + jeprof。开启方式 LD_PRELOAD libjemalloc.so + MALLOC_CONF 配置,使用方式 jeprof 分析 heap profile。它能告诉你所有 native 内存的分配栈,包括 JNI 调用、Native Library 内部分配。对应的工具有 valgrind(更精确但慢)、massif(老工具)、heaptrack(GUI 友好)。
第三件:pmap + smaps + /proc/$pid/maps。这是最底层的视角,直接从内核看进程的地址空间布局。它能告诉你哪些文件被 mmap 了、每个内存区域的实际 RSS、哪些区域是 dirty 的(还没刷盘)。对应的工具有 vmtouch(看 page cache)、fincore(看文件缓存)。
三件套的关系是从应用到内核层层下沉:NMT 看不到的找 jemalloc,jemalloc 看不清的找 pmap,pmap 还要细节就直接看 smaps。掌握这三件套之后,所有"内存去哪儿了"的问题都能定位。
给老 Java 服务做内存治理的 5 步法
事故复盘之后,我把这套方法论推广到了公司其他十几个 Java 服务,总结出 5 步法,从"完全没监控"的状态推进到"内存可观测、可治理、可预警"的标准:
第一步:建立基线。每个服务在生产环境跑 7 天稳态后,记录平均 RSS、峰值 RSS、JVM 堆使用率、Metaspace、Direct Memory、线程数。这些数据作为后续判断"异常"的基准。我们的标准是把这些数据存入一个 Memory Baseline 表,每周自动更新一次。
第二步:监控接入。把容器 RSS、JVM 内部各分区、cgroup memory.events、cgroup oom_kill 计数全部接入 Prometheus。告警规则:RSS 持续 5 分钟超过 limit 的 85% 触发 warning,持续 1 分钟超过 90% 触发 critical。Metaspace 单日增长超过 20MB 触发 warning(说明有 ClassLoader 泄漏迹象)。
第三步:配置规范化。所有 Java 服务的 K8s deployment 必须使用统一的模板,JVM 参数从 ConfigMap 注入,确保 MaxDirectMemorySize、MaxMetaspaceSize、ReservedCodeCacheSize 都有显式上限。所有 limit/request 必须等值。
第四步:压测验证。每个 release 上线前跑 48 小时长稳压测,观察 RSS 趋势是否稳定。如果 RSS 在压测期间持续上涨而没有平台期,说明存在泄漏,禁止上线。压测脚本统一用 k6 编写,覆盖核心业务路径。
第五步:故障演练。每季度做一次内存故障演练,人为制造 OOMKilled、Metaspace 满、Direct Memory 满等场景,验证团队的响应和恢复流程。我们演练了 4 次后,定位故障的平均时间从 2 小时缩短到 25 分钟。
跨语言对比:Go / Rust / Node 在容器里的内存表现
| 语言 | 主要内存组成 | 容器化注意点 |
|---|---|---|
| Java | Heap + Metaspace + DirectMemory + Stack + CodeCache + JNI | 必须显式设各部分上限 |
| Go | Heap + Stack + Runtime | GOMEMLIMIT(1.19+)很关键 |
| Rust | 主要是 malloc 池 | jemalloc 是默认,内存可控 |
| Node.js | V8 Heap + 外部 Buffer + libuv | --max-old-space-size 限制 V8 |
| Python | 对象池 + C 扩展(numpy/pandas) | C 扩展是最大变数 |
| C/C++ | malloc + mmap + 栈 | jemalloc/tcmalloc 替换 glibc malloc |
从这张表能看出,所有有"native 部分"的语言都会遇到类似 Java 这次的问题。Go 1.19 之前没有 GOMEMLIMIT,只有 GOGC,容器化下也常被 OOMKilled,我们之前那篇文章(Go GC STW 复盘)讲过同样的问题。Rust 因为没有 GC,内存控制最简单,但仍然要注意 jemalloc 的内部碎片。Node.js 的 Buffer(尤其是 Buffer.allocUnsafe)也是堆外内存,会绕开 V8 限制。Python 的 numpy 数组本质是 C 数组,占用的也是 native 内存。这是云原生时代所有运行时都要面对的共同课题。
事故后的两个意外收获
这次 9 天的复盘除了把 OOMKilled 问题解决,还顺带带来了两个意外收获,值得说一下:
意外 1:整体资源占用反而下降了。修复完成后,Pod 的 limit 从 16Gi 降到了 12Gi,稳态 RSS 在 10.5GB 左右,意味着每台机器可以多塞 25% 的 Pod。我们的 K8s 集群因此释放出了约 30 台 worker node,一年节省 60 多万的云成本。原本"加内存解决问题"的思路反而是反向的:**真正解决问题往往让你用更少的资源做同样的事**。
意外 2:P99 延迟降到一半。原本的 Pod 平均 8 小时 OOMKilled 一次,每次重启后 RocksDB 缓存要重新预热 30 分钟,这 30 分钟 P99 延迟 600ms;事故修复后稳定运行,P99 延迟一直保持在 75ms 左右。业务方第一次说"风控引擎延迟这么稳是我入职以来第一次见"。这就是工程治理的复利:基础打好,所有依赖它的上层指标都会跟着改善。
GraalVM Native Image:对比 JVM 的内存表现
事故复盘的尾声,有同事提议:既然 JVM 内存这么复杂,我们要不要把这个服务改成 GraalVM Native Image,直接编译成可执行文件,内存控制是不是会更简单?为了回答这个问题,我们做了一个小型 POC,把核心规则评估逻辑改写成原生镜像,跑了一周对比测试。
结论是:Native Image 确实在启动速度和稳态内存上有显著优势。同样的服务,JVM 模式启动需要 28 秒,Native Image 只要 1.2 秒;JVM 模式稳态 RSS 10.5GB,Native Image 只要 4.8GB,因为没有 JIT 编译器、没有 JVM 自身的代码、Metaspace 也不需要(类信息编译期固化)。但代价是构建时间从 2 分钟变成 12 分钟、镜像体积虽然变大但程序文件本身更紧凑、反射和动态代理需要额外配置 reflection-config.json,而且峰值吞吐反而比 JVM 模式低 15% 左右(没有 C2 编译器的运行时优化)。
最后我们决定:核心规则引擎继续用 JVM 模式(吞吐优先),但 CronJob 类的批量任务、CLI 工具改用 Native Image(启动快、内存省)。这次选型让团队明白:容器化时代,运行时本身也是一个可选项,而不是必然就是 JVM。
Java 21 虚拟线程的额外内存红利
这次事故的修复方案里,我们顺便把同步的 IO 调用改成了用 Java 21 的虚拟线程(Virtual Thread)。原本的服务用 Tomcat 线程池处理请求,每个请求一个 OS 线程,200 个并发就要 200 个线程,每个线程 1MB 栈就是 200MB,这部分是 NMT 看到的 380MB 线程栈的主要来源。改成虚拟线程后,OS 线程数缩到 16 个(carrier 线程数 = CPU 核数),线程栈只占 16MB,直接省下 350MB+ 内存。
// Java 21 虚拟线程改造
@RestController
public class RuleController {
// 老版:用 Tomcat 默认线程池(200 个 OS 线程,200MB 栈)
// 新版:用虚拟线程
@PostConstruct
void init() {
// Spring Boot 3.2+ 在 application.yml 配置即可:
// spring.threads.virtual.enabled=true
}
}
// 也可以手动创建 executor
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
// 每个任务一个虚拟线程,数量可以是几百万,几乎不占栈内存
虚拟线程有它自己的注意事项:一些 synchronized 块会 pin 住 carrier 线程,影响并发度;一些 ThreadLocal 用法在虚拟线程下会变成"每个虚拟线程一份",可能反而吃更多内存。但对这次的风控引擎来说,IO 占大头、synchronized 用得少,改完效果非常明显:线程数从 380 降到 56,内存少 350MB,吞吐反而从 1.5 万 QPS 提到 2.1 万 QPS。
这次事故让我们重写了团队的"上线 checklist"
过去团队的上线 checklist 只有十几条,绝大多数是业务功能验收。这次事故让我意识到,基础设施层面的检查项是缺失的。我们重写了 checklist,加入了下面这些必查项,任何 Java 服务上线前必须勾完才能推全量:
JVM 参数检查:Xms、Xmx、MaxDirectMemorySize、MaxMetaspaceSize、ReservedCodeCacheSize、MaxRAMPercentage 必须全部显式设值,任何一个用默认都不能通过。
K8s 资源检查:memory request 和 limit 必须相等(Guaranteed QoS);CPU request 和 limit 比例不能小于 1:4(防止 throttling);livenessProbe 的 timeoutSeconds 至少 5s,failureThreshold 至少 3。
监控指标检查:JVM 堆、Metaspace、Direct Memory、GC、线程数、容器 RSS、cgroup oom_kill 这 7 个指标必须全部接入 Prometheus,Grafana 有对应面板,告警规则按公司规范配好。
压测要求:上线前 48 小时长稳压测必须通过,关键指标是 RSS 趋势平稳(不能持续上涨)、GC 频率符合预期(MinorGC < 5/分钟、MajorGC < 1/小时)、P99 延迟在 SLO 范围内。
故障预案:必须有明确的回滚方案、降级开关、限流配置;运维要预先演练过一次回滚操作,目标 5 分钟内恢复。
给读者的实操建议
如果你的服务也跑在 K8s 上,而且你不确定有没有这类问题,我推荐你今晚就做下面三件事,十分钟之内就能初步判断你的服务是否健康:
第一件,在生产环境的任意一个 Pod 里执行 cat /proc/$(pgrep java)/status | grep VmRSS,把 VmRSS 的值除以 1024 / 1024(转成 GB),对比一下你 deployment 里的 memory limit。如果 RSS 已经超过 limit 的 80%,你已经在 OOMKilled 的边缘了,只是还没掉下去。
第二件,执行 jcmd $(pgrep java) VM.native_memory summary(如果 JVM 没开 NMT,先在 JAVA_OPTS 加上 -XX:NativeMemoryTracking=summary 重启一个 Pod 测试),看看 Class、Direct Memory、Thread 这三块加起来占用多少。如果加起来超过 -Xmx 的 50%,说明堆外用得多,需要重点关注。
第三件,在 Grafana 上加一个 container_memory_working_set_bytes / kube_pod_container_resource_limits_memory_bytes 的比值面板,实时看每个 Pod 的内存压力。这是 K8s 自带的两个指标,直接拉就能用,5 分钟搞定。
这三件事不需要改代码、不需要重启服务、不会影响业务,但能让你立刻判断"我的服务是不是在 OOMKilled 边缘"。如果发现问题,再按本文的方法论一步步治理。等你完整跑过一次这个流程,你对 K8s + JVM 的理解会有质的飞跃。
总结
这次 9 天的复盘最大的收获,不是学到了 NMT、jeprof、cgroup v2 这些工具,而是认清了一个事实:**云原生时代,容器化 Java 应用的内存问题不再是单一维度的 JVM 堆问题,而是 JVM + 容器 + 内核 + Native Library + 业务代码的多层耦合**。任何一层的配置错误都可能让看起来"健康"的应用悄悄被 SIGKILL。如果你的服务跑在 K8s 上,而且你只盯着 JVM 堆监控,那这次的事故剧本随时可能在你身上重演。把这篇文章里的 10 条纪律落到团队规范里、把 NMT 和 jemalloc 接入到所有 Java 服务、把容器 RSS 和 cgroup memory.events 加入监控,这才是真正的修复。
—— 别看了 · 2026