一次线上 Java 服务反复 OOMKilled 的排查:不是堆漏,是 glibc malloc arena 碎片

从 RSS 缓慢爬升被 cgroup 杀掉出发,一步步定位到 glibc 多 arena 的 native 内存碎片,给出 MALLOC_ARENA_MAX 修复与完整排查命令清单

11 分钟阅读
一次线上 Java 服务反复 OOMKilled 的排查:不是堆漏,是 glibc malloc arena 碎片

一次线上 Java 服务反复 OOMKilled 的排查:不是堆漏,是 glibc malloc arena 碎片

目录


一、现象:堆稳如老狗,进程却被反复杀

线上 sell 服务的两个 Pod,运行一段时间后 RSS(常驻内存)会缓慢爬升,最终突破容器 5Gi 上限被 cgroup OOMKilled,表现为”服务偶尔无征兆重启”。

实时对比(容器 limit = 5Gi):

Pod运行时长当前 RSS状态
2d64v刚重启 61 分钟3427 Mi正常爬升中
hksxh4 天 16 小时未重启3983 Mi逼近 5Gi,随时会被 OOMKilled ⚠️

两个关键观察:

  1. 堆是固定的、稳定的-Xmx 写死 2G,GC 日志显示堆用量平稳,老年代不涨——基本可以排除”经典的堆内存泄漏”;
  2. 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>/statusVmRSS~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 的碎片是怎么撑爆容器的

把链条串起来:

  1. glibc 为了多线程并发性能,用了多 arena 设计。 每个线程分配内存时会绑定到一个 arena,arena 之间相互独立、各自维护空闲链表,避免抢同一把锁。默认上限 8 × nproc,本案 = 32 个 arena

  2. sell 服务是个重度多线程 + 重度 native 分配的应用。 它有约 235 个工作线程(XNIO 网络线程 + 多个业务线程池),并且大量走 native 分配的路径:

    • JDBC 驱动(结果集、缓冲区);
    • SkyWalking agent(字节码增强 + 链路数据的 native 缓冲);
    • JSON 序列化(大对象的临时缓冲)。
  3. 235 个线程被打散到多达 32 个 arena 上。 每个 arena 都会保留一部分”分配过又释放、但碎片化、无法及时归还内核”的内存。32 个 arena 各保留一点,乘起来就很可观。

  4. 这部分内存 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"

取值 14 均可,推荐 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 滚动发布后,按以下顺序确认修复生效:

  1. 看 RSS 增长曲线是否收敛:新 Pod 运行同样时长,RSS 应明显低于旧值,且爬升斜率显著变缓甚至趋平;
  2. 复算 RSS − NMT 差值:那 ~830MB 的盲区应大幅缩小(arena 从 32 → 2,碎片上限锐减);
  3. pmap 一次:64MB anon 块的数量应从几十个降到个位数;
  4. 观察一个完整业务周期(含高峰),确认不再触顶 5Gi。

八、复盘与经验总结

这次事故沉淀下来的几条经验,适用于所有跑在容器里的 Java 服务:

  • OutOfMemoryErrorOOMKilled 前者是 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”。把排查方向选对,比记住任何单条命令都重要。

💬 评论