Administrator
发布于 2022-04-30 / 7556 阅读
50

线上应用内存持续增长但不 OOM 的排查

RSS 涨到 5.8 GB,但堆才用了 1.2 GB,也没 OOM

4 月底值班,监控告警:订单服务一个实例的容器内存(RSS)从 2 GB 缓慢涨到 5.8 GB,持续了四天还在涨,但没有抛 OutOfMemoryError。K8s 的内存 limit 是 6 GB,再涨就要被 OOMKilled 重启,重启又会丢在途请求。

最反直觉的地方是:jstat -gc 看堆最多 1.2 GB,离 4 GB 的堆上限远着呢。内存去哪了?这篇是完整排查路径。

第一步:分清"堆内"和"堆外"

Java 进程占的内存 = 堆 + 堆外。堆外又分几块,先建立概念:

区域受 -Xmx 控制?典型来源
Java 堆对象实例
元空间 Metaspace否(有独立上限)类元数据
直接内存 Direct Buffer否(受 MaxDirectMemorySize)NIO、Netty
线程栈每线程默认 1 MB
glibc 分配器碎片原生内存 malloc

既然堆才 1.2 GB,增长的内存肯定在堆外。先排除元空间和直接内存:

# 开 NMT(Native Memory Tracking)看堆外分布
-XX:NativeMemoryTracking=detail
$ jcmd <pid> VM.native_memory summary

Total: reserved=6123MB, committed=5810MB
- Java Heap: 4096MB
- Metaspace: 182MB
- Thread: 96MB
- Code: 142MB
- GC: 88MB
- Internal (Direct Buffer): 116MB       # 直接内存
- Unknown: 2090MB                        # ← 这块最大且说不清

直接内存才 116 MB,Unknown 有 2 GB,这才是增长的元凶。NMT 把它归到 Unknown,说明不是 JVM 自己管理的,是原生 malloc 分配的。

第二步:看是不是元空间泄漏

虽然 NMT 显示 Metaspace 只有 182 MB,但我还是确认了下,因为动态生成类(CGLIB、反射代理)很常见。用 jstat -gcmetacapacity 看趋势:

jstat -gcmetacapacity <pid>
MCMN  MCMX     MC     CCSMN  CCSMX    CCSC    YGC  FGC  FGCT
0   1.05G   182M     0     0.25G   28M   320   4   18.2

MC(已用元空间)稳定在 182 MB 没涨,FGC 才 4 次,排除元空间泄漏。如果这里在涨,多半是 CGLIB 每次调用 create() 都生成新代理类,那个要在代码里把代理缓存起来。

第三步:直接内存泄漏长什么样

我们用了 Netty 做内部 RPC。直接内存泄漏的特征是 Internal 那项持续增长,且伴随 java.lang.OutOfMemoryError: Direct buffer memory。但本例 Internal 才 116 MB,而且没抛 OOM,所以不是直接内存。

顺手确认了上限:-XX:MaxDirectMemorySize 没设,默认等于堆上限 4 GB,远没到。排除。

第四步:真正的元凶是 glibc 的 malloc 碎片

Unknown 那 2 GB 是原生内存。我们用的是 JDK 11 + 默认 glibc(ptmalloc2)。glibc 的分配器有个老问题:频繁分配/释放小块原生内存时,会产生大量碎片,而且 free 之后内存不一定归还给 OS

我们的服务启用了 -XX:+UseG1GC,G1 会用 mmap 向 OS 要内存、用 madvise(MADV_DONTNEED) 还回去,这本该让 RSS 回落。但真正在涨的是 JNI 和压缩库(我们用了 Snappy 做压缩)调的 malloc,这部分归 glibc 管,G1 管不到。

验证方法:用 pmap 看进程的匿名内存映射:

$ pmap -x <pid> | sort -k3 -n | tail -5
00007f...  rw-p  65536K   65400K   65400K  [anon]    # 大量 64MB 的 arena
00007f...  rw-p  65536K   65000K   65000K  [anon]
...

出现了几十个 64 MB 的 [anon] 块,这正是 glibc 的 arena(内存分配区)。glibc 默认最多开 8 × CPU核数 个 arena,我们 8 核允许 64 个 arena,每个 64 MB,理论上能囤 4 GB。碎片就这样囤住了。

根因定位到代码:一个用 JNI 调 Snappy 的压缩工具,每次压缩都 malloc 一块 buffer、用完 free,但对象频繁创建销毁,glibc 没把空闲块合并归还 OS。

解决方案:三选一,我们用了两个

  • 限制 arena 数量:设环境变量 MALLOC_ARENA_MAX=2,把 arena 上限压到 2,碎片囤积大幅减少。这是改动最小的,重启后 RSS 稳定在 2.4 GB,不再涨。
  • 换分配器:在容器里用 jemalloctcmalloc 替换 glibc 的 malloc,碎片回收更积极。我们给关键服务挂了 LDR_PRELOAD=/usr/lib/libjemalloc.so,RSS 比只用 MALLOC_ARENA_MAX 再降 15%。
  • 代码层缓存 buffer:把压缩用的 native buffer 用对象池复用,减少 malloc/free 次数。这是治本,但要改 JNI 封装,排期靠后。

改造前后对比(连续跑 4 天):

方案4 天后 RSS是否还涨
原状(glibc 默认)5.8 GB涨(逼近 6 GB limit)
MALLOC_ARENA_MAX=22.4 GB稳定
jemalloc2.0 GB稳定

排查路径总结成一张图

遇到"内存涨但不 OOM",按这个顺序走,别上来就 dump 堆:

RSS 高但堆低?
  ├─ 开 NMT(jcmd VM.native_memory)
  ├─ Metaspace 涨? → 类加载泄漏(CGLIB/反射代理缓存)
  ├─ Internal(Direct) 涨? → 直接内存泄漏(Netty ByteBuf 未 release)
  ├─ Thread 涨? → 线程没回收(线程池无界)
  └─ Unknown 大? → 原生 malloc 碎片
        ├─ MALLOC_ARENA_MAX 限制 arena
        └─ jemalloc / tcmalloc 替换

关键点:堆 dump 对堆外内存基本没用。我一开始下意识想去 dump,浪费了半天——堆才 1.2 GB,dump 出来干干净净,什么问题都看不出。堆外问题得用 NMT + pmap + 分配器参数去查。

下篇预告

这篇先把《线上应用内存持续增长但不 OOM 的排查》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。

参考