一次线上 Java 服务反复 OOMKilled 的排查:不是堆漏,是 glibc malloc arena 碎片
目录
- 一、现象:堆稳如老狗,进程却被反复杀
- 二、第一直觉为什么会骗人
- 三、排查思路:自上而下层层剥离
- 四、根因分析:glibc 多 arena 的碎片是怎么撑爆容器的
- 五、一张图看懂内存账
- 六、修复方案:P0 / P1 / P2
- 七、验证:改完之后该看什么
- 八、复盘与经验总结
- 九、附录:排查命令清单
一、现象:堆稳如老狗,进程却被反复杀
线上 sell 服务的两个 Pod,运行一段时间后 RSS(常驻内存)会缓慢爬升,最终突破容器 5Gi 上限被 cgroup OOMKilled,表现为”服务偶尔无征兆重启”。
实时对比(容器 limit = 5Gi):
| Pod | 运行时长 | 当前 RSS | 状态 |
|---|---|---|---|
2d64v | 刚重启 61 分钟 | 3427 Mi | 正常爬升中 |
hksxh | 4 天 16 小时未重启 | 3983 Mi | 逼近 5Gi,随时会被 OOMKilled ⚠️ |
两个关键观察:
- 堆是固定的、稳定的。
-Xmx写死 2G,GC 日志显示堆用量平稳,老年代不涨——基本可以排除”经典的堆内存泄漏”; - RSS 却随运行时间单调爬升,且爬升速度和”运行时长”强相关——运行越久涨得越多,重启后归零再慢慢涨。
这是一个非常典型的信号:涨的不是堆,是堆之外的 native 内存。
二、第一直觉为什么会骗人
看到 Java 进程 OOM,绝大多数人的第一反应是”内存泄漏,dump 堆查大对象”。但这次如果顺着这个直觉走,会浪费大量时间——因为:
- 堆
-Xmx2g封顶,JVM 永远不会让堆超过 2G,堆漏最多把堆打满触发OutOfMemoryError(Java 异常,有堆栈),而不是让整个进程 RSS 涨到 5Gi 被内核杀掉(OOMKilled,无 Java 异常、无堆栈,Pod 直接消失重建)。 OutOfMemoryError(JVM 层)和OOMKilled(cgroup/内核层)是两件完全不同的事。前者是 JVM 喊”我堆不够了”,后者是内核喊”你整个进程占的物理内存超了 limit,我把你毙了”。
分清这两者,是这次排查能走对方向的前提。RSS 被内核杀,意味着要查的是进程总物理内存,而不只是堆。
三、排查思路:自上而下层层剥离
排查 native 内存问题的核心方法论:把进程 RSS 这块大蛋糕,一层层切开,看哪一层在涨、哪一层 JVM 自己也说不清。
进程 RSS(内核视角,被 cgroup 计量)
├── JVM 托管内存(NMT 能追踪)
│ ├── Heap 堆
│ ├── Metaspace 元空间
│ ├── Code Cache JIT 代码缓存
│ ├── Thread Stacks 线程栈
│ └── GC / Internal GC 结构等
└── JVM 之外的 native(NMT 追踪不到 ← 嫌疑人)
├── glibc malloc 分配器自身的碎片/保留 ← 本案凶手
├── DirectByteBuffer / Netty 堆外
├── JNI / agent(SkyWalking 等)直接调 malloc
└── 内存映射文件等
3.1 第一步:排除堆内存泄漏
先把最常见的嫌疑排除掉:
- 看 GC 日志 /
jstat -gc:Full GC 后老年代能回落、不持续抬高 → 堆没漏; - 必要时
jmap -dump拉一份堆快照,用 MAT 看支配树,确认没有异常大对象堆积。
结论:堆稳定在 2G 以内,不是堆漏。 排查方向转向 native。
3.2 第二步:用 NMT 圈定 JVM 托管内存
打开 NMT(Native Memory Tracking),让 JVM 自己报账:
# 启动参数加上(summary 级别开销很小,可在预发/排查期常开)
-XX:NativeMemoryTracking=summary
# 运行时查看
jcmd <pid> VM.native_memory summary
NMT 会列出 JVM 自己管理的各类 native 内存:堆、Metaspace、Code Cache、线程栈、GC 结构、Internal 等。本案统计下来:
JVM 托管内存(堆 + Metaspace + Code + 线程栈 + GC)总计约 3.07 GB,完全稳定,不是凶手——堆固定 2G 永远不会涨。
到这里,“JVM 自己知道的内存”全部稳定。但进程 RSS 却接近 4G 还在涨。那多出来的、还在涨的那块,JVM 自己都不知道。
3.3 第三步:RSS 与 NMT 的差值——盲区在哪
把两个数字摆在一起:
| 指标 | 数值 | 来源 |
|---|---|---|
进程 RSS(/proc/<pid>/status 的 VmRSS) | ~3.9 GB 且在涨 | 内核 |
| NMT committed(JVM 托管总和) | ~3.07 GB 稳定 | JVM |
| 差值 | ~830 MB 且随时间累积 | NMT 盲区 |
这 ~830MB 的差值就是关键:它是”进程实际占用”减去”JVM 承认占用”的部分。NMT 追踪不到它,说明它不是 JVM 主动分配的,而是更底层的东西——分配器(glibc malloc)自身的开销、或绕过 JVM 记账的 native 分配。
排查 native 内存,盯住 RSS − NMT 这个差值,比盯任何单一指标都管用。
3.4 第四步:pmap 抓现行,64MB 块就是铁证
用 pmap 看进程的内存映射,按占用排序:
pmap -x <pid> | sort -k3 -n -r | head -40
如果看到大量大小接近 64MB(65536 KB)的匿名(anon)映射块,几十个甚至上百个——这就是 glibc malloc arena 的典型签名。
# 数一下有多少个 ~64MB 的 anon 块
pmap -x <pid> | awk '$2 ~ /^6[0-9]{4}$/ {c++} END{print c}'
glibc 的每个 arena 在增长时会以 64MB 为单位向内核 mmap 堆区,碎片化后这些 64MB 块整块无法归还内核,于是 RSS 里就堆出一排排的 64MB 匿名块。看到这个画面,基本可以锁定 glibc 多 arena 碎片。
3.5 第五步:确认 glibc 版本、核数与 arena 配置
最后坐实三个环境事实:
# glibc 版本
getconf GNU_LIBC_VERSION # → glibc 2.28
ldd --version | head -1
# 容器看到的 CPU 核数(决定默认 arena 上限)
nproc # → 4
# 当前是否设了 MALLOC_ARENA_MAX
cat /proc/<pid>/environ | tr '\0' '\n' | grep -i malloc # → 空,未设置
三条事实凑齐:glibc 2.28 + 4 核 + 未设 MALLOC_ARENA_MAX。
glibc 64 位下,默认 arena 数上限 = 8 × nproc。这里 8 × 4 = 32 个 arena。凶手画像完整了。
四、根因分析:glibc 多 arena 的碎片是怎么撑爆容器的
把链条串起来:
-
glibc 为了多线程并发性能,用了多 arena 设计。 每个线程分配内存时会绑定到一个 arena,arena 之间相互独立、各自维护空闲链表,避免抢同一把锁。默认上限
8 × nproc,本案 = 32 个 arena。 -
sell服务是个重度多线程 + 重度 native 分配的应用。 它有约 235 个工作线程(XNIO 网络线程 + 多个业务线程池),并且大量走 native 分配的路径:- JDBC 驱动(结果集、缓冲区);
- SkyWalking agent(字节码增强 + 链路数据的 native 缓冲);
- JSON 序列化(大对象的临时缓冲)。
-
235 个线程被打散到多达 32 个 arena 上。 每个 arena 都会保留一部分”分配过又释放、但碎片化、无法及时归还内核”的内存。32 个 arena 各保留一点,乘起来就很可观。
-
这部分内存 NMT 追踪不到(就是第 3.3 节那 ~830MB 差值),随着业务持续运行约 21 小时不断累积,叠加在稳定的 3GB JVM 之上,RSS 从 3G 缓慢爬到 5Gi → 被 cgroup OOMKilled。
一句话总结:不是堆漏,是 glibc 多 arena 的 native 内存碎片随时间膨胀,撑爆了容器的内存上限。
为什么”碎片”会归还不了内核?glibc malloc 只有当一个 arena 顶部连续空闲达到 trim 阈值时才会 madvise/munmap 还给内核。多线程乱序分配/释放下,每个 arena 顶部常被零星存活对象”压住”,导致大片空闲夹在中间、整不动、还不掉——arena 越多,这种”卡住的碎片”越多。
五、一张图看懂内存账
容器 limit = 5 Gi ←──────── cgroup 在这里挥刀
RSS │
5Gi ┤ ╭── OOMKilled 💥
│ ╭────╯
4Gi ┤ ╭─────────╯ ← native 碎片累积区
│ ╭─────────╯ (~830MB 且持续涨,NMT 盲区)
3Gi ┤═══════════════════════════════════════════ ← JVM 托管 3.07GB(稳定)
│ Heap 2G + Metaspace + Code + 235×线程栈 + GC
2Gi ┤
│
0 └────────────────────────────────────────────▶ 运行时长
0h 8h 16h 21h
JVM 那 3GB 是一条水平线(稳定),真正往上拱、最终顶穿 5Gi 的,是上面那层斜向上的 native 碎片。
六、修复方案:P0 / P1 / P2
P0(立即,否则逼近 5Gi 的 Pod 很快会在业务高峰崩)
1. 给 sell 的 Deployment 加环境变量 MALLOC_ARENA_MAX=2。
这是单点最有效的修复——把 native 碎片从最多 32 个 arena 压到 2 个,RSS 增长会立刻收敛。
env:
- name: MALLOC_ARENA_MAX
value: "2"
取值 1
4 均可,推荐 2:1 会牺牲多线程分配并发度,24 在”省内存”和”分配并发”之间平衡得最好。
2.(更彻底的替代方案)换用 jemalloc / tcmalloc。
# 镜像内置库后,启动前 LD_PRELOAD
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so
jemalloc/tcmalloc 的碎片行为远优于 glibc,是长期重度 native 分配服务的更优解,代价是镜像要内置库、需回归测试。
3. 主动滚动重启逼近上限的 Pod(如已到 3983Mi/5Gi 的那个),避免它在不可控时刻 OOM 影响线上。这是生产操作,建议由运维择机执行。
P1(本次迭代)
4. 临时把容器 limit 提到 6~8Gi 兜底。 但单独提 limit 治标不治本(碎片照样涨,只是把崩盘时间推后),必须和 P0 的 MALLOC_ARENA_MAX 一起做。
5. 加 -XX:MaxDirectMemorySize=256m 显式封顶 direct memory, 防止未来 Netty/NIO 的 direct buffer 失控成为新的 native 增长源。
P2(后续)
6. 加内存告警:RSS 达 80%(4Gi)即告警, 提前发现爬升,别等 OOMKilled 才知道。
7. 若设了 MALLOC_ARENA_MAX 后 RSS 仍缓慢爬升, 再排查应用层 direct buffer 泄漏——重点看 GroupResourcesServiceImpl 这条高频路径有没有未释放的 ByteBuf / 大对象缓存。
七、验证:改完之后该看什么
加上 MALLOC_ARENA_MAX=2 滚动发布后,按以下顺序确认修复生效:
- 看 RSS 增长曲线是否收敛:新 Pod 运行同样时长,RSS 应明显低于旧值,且爬升斜率显著变缓甚至趋平;
- 复算 RSS − NMT 差值:那 ~830MB 的盲区应大幅缩小(arena 从 32 → 2,碎片上限锐减);
- 再
pmap一次:64MB anon 块的数量应从几十个降到个位数; - 观察一个完整业务周期(含高峰),确认不再触顶 5Gi。
八、复盘与经验总结
这次事故沉淀下来的几条经验,适用于所有跑在容器里的 Java 服务:
OutOfMemoryError≠OOMKilled。 前者是 JVM 堆/元空间不够(有 Java 堆栈),后者是进程总 RSS 超 cgroup limit 被内核杀(无堆栈、Pod 直接重建)。看到无堆栈的反复重启,第一时间往 native 方向想。- RSS = JVM 托管 + JVM 之外的 native。 堆封顶不代表进程内存封顶。容器内存 limit 必须给 JVM 之外的 native 留足余量(经验上
-Xmx别超过 limit 的 50~60%)。 - NMT 有盲区。 它只追踪 JVM 自己分配的 native,追踪不到 glibc 分配器自身的碎片。
RSS − NMT committed这个差值才是 native 问题的指北针。 - glibc 默认多 arena 对”高线程数 + 重 native 分配”的服务极不友好。 容器化 Java 服务建议默认就设
MALLOC_ARENA_MAX(或直接上 jemalloc),把它作为基础镜像/通用模板的一部分,而不是等出事再加。 - agent 不是免费的。 SkyWalking 这类字节码增强 agent 会带来额外 native 分配,排查 native 内存时别忘了把它算进嫌疑名单。
九、附录:排查命令清单
把这次用到的命令整理成一份可复用的 native 内存排查 checklist:
# === 0. 先分清是 OOMKilled 还是 OutOfMemoryError ===
kubectl describe pod <pod> | grep -A3 "Last State" # 看 Reason: OOMKilled
kubectl logs <pod> --previous | grep -i "OutOfMemoryError" # 有无 Java 异常
# === 1. 排除堆漏 ===
jstat -gc <pid> 1000 10 # 看 GC 后老年代是否回落
jmap -dump:live,format=b,file=heap.hprof <pid> # 必要时拉堆快照
# === 2. NMT 圈定 JVM 托管内存(需启动加 -XX:NativeMemoryTracking=summary)===
jcmd <pid> VM.native_memory summary
jcmd <pid> VM.native_memory summary.diff # 与基线对比看哪块在涨
# === 3. RSS 与差值 ===
cat /proc/<pid>/status | grep -E "VmRSS|VmHWM"
# RSS − NMT committed = native 盲区,重点盯这个差值的增长
# === 4. pmap 抓 glibc arena 的 64MB 块 ===
pmap -x <pid> | sort -k3 -n -r | head -40
pmap -x <pid> | awk '$2 ~ /^6[0-9]{4}$/ {c++} END{print "64MB anon 块:", c}'
# === 5. 坐实环境事实 ===
getconf GNU_LIBC_VERSION # glibc 版本
nproc # 核数 → 默认 arena = 8 × nproc
cat /proc/<pid>/environ | tr '\0' '\n' | grep -i malloc # 是否已设 MALLOC_ARENA_MAX
# === 6. 验证修复 ===
# 设 MALLOC_ARENA_MAX=2 重启后,重复第 3、4 步,看差值与 64MB 块数量是否锐减
这类问题的难点不在”修”(一个环境变量就压住了),而在”敢不敢相信堆是无辜的、并顺着 RSS − NMT 的差值一路追到 glibc”。把排查方向选对,比记住任何单条命令都重要。