WEBKT

容器内 Java 进程 RSS 持续暴涨?用 pmap 和 smaps 诊断 Native 内存泄露的硬核指南

2 0 0 0

在容器化时代,不少开发者都遇到过这样一个诡异的问题:Java 进程的 JVM 堆内存(Heap)设置了上限(如 -Xmx4g),容器的 OOM Killer 却依然在某个深夜悄然降临,杀死了这个容器。

通过监控会发现,容器的实际物理内存占用(RSS,Resident Set Size)一直在无休止地往上涨。遇到这种情况,仅仅看 Java 堆 dump 是无济于事的,因为泄露发生在 Native Memory(本地内存)

本文将带你深入 Linux 底层,一步步通过 pmap/proc/$PID/smaps 诊断出 Java 进程 Native 内存泄露的源头。


一、 为什么 JVM 限制了堆,RSS 还会持续暴涨?

Java 进程消耗的物理内存(RSS)远不止 -Xmx 指定的堆内存,它还包括:

  1. JVM 自身运行内存:Metaspace(元空间)、Thread Stacks(线程栈,每个线程默认 1MB 左右)、GC 传输数据结构、Code Cache 等。
  2. Direct Byte Buffers(直接内存):Netty 等框架常用的堆外内存。
  3. JNI / Native Code:Java 代码中调用了 C/C++ 动态链接库(如 libzip.so、加密库、数据库驱动等)分配的内存。
  4. 内存分配器碎片(Malloc Arenas):glibc 默认的内存分配策略在高并发下会导致极大的内存碎片。

当排除掉堆和 Metaspace 之后,我们需要把视线移向操作系统底层的虚拟内存分配。


二、 第一步:排查前的准备工作

在容器内进行底层排查,通常需要高权限和特定的工具包。

1. 进入容器并获取 root 权限

如果是 Kubernetes 环境:

kubectl exec -it <pod-name> -c <container-name> -- /bin/bash

2. 安装必要工具

容器镜像通常是精简版的,可能缺少 pmapgdb

  • Alpine 镜像
    apk add --no-cache procps gdb strace
    
  • Debian/Ubuntu 镜像
    apt-get update && apt-get install -y procps gdb
    
  • CentOS/RedHat 镜像
    yum install -y procps-ng gdb
    

三、 第二步:使用 pmap 快速定位大内存块

pmap(Process Map)可以报告进程的内存映射关系。我们首先要找出是哪些内存段(Address Range)占用了大量的空间。

1. 查找 Java 进程 PID

jps -l
# 或者
ps -ef | grep java

假设获取到 Java 进程的 PID 为 1

2. 导出并排序 pmap 结果

执行以下命令,按内存块大小(RSS)降序排列,输出前 30 个最大的内存段:

pmap -x 1 | sort -k 3 -n -r | head -n 30

(注:-x 参数表示显示扩展格式)

输出示例如下:

Address           Kbytes     RSS   Dirty Mode  Mapping
00007f3c4c000000   65536   61420   61420 rw---   [ anon ]
00007f3c50000000   65536   58912   58912 rw---   [ anon ]
00007f3c54000000   65536   54200   54200 rw---   [ anon ]
00007f3c78000000   65536   48120   48120 rw---   [ anon ]
...

3. 分析 pmap 结果的特征

在上面的输出中,有一些非常典型的特征:

  • 大量 65536 KB(刚好 64MB)的内存块
    如果发现有几十个甚至上百个大小为 64MB 左右、Mapping 为 [ anon ](匿名内存)且 RSS 已经几乎填满的内存段,这几乎可以 100% 确定是 glibc 的 Arena 导致的内存碎片问题
  • 单个极大的 [ anon ]
    如果有一个几百 MB 甚至几个 GB 的匿名内存块,那可能是 DirectByteBuffer、或者是某些 JNI 库直接调用 malloc 分配的未释放空间。

四、 第三步:深入 smaps 剖析内存块细节

pmap 只能让我们看到宏观的地址区间,要看具体内存段的分配细节,需要查看 /proc/$PID/smaps

smaps 文件记录了极其详尽的虚拟内存条目。我们可以针对上一步 pmap 捞出来的可疑内存地址范围进行定向查询。

1. 定位可疑地址在 smaps 中的条目

假设我们怀疑 00007f3c4c000000 这个 64MB 的块。在终端中执行:

grep -A 20 "7f3c4c000000" /proc/1/smaps

会输出类似下面的详细信息:

7f3c4c000000-7f3c50000000 rw-p 00000000 00:00 0 
Size:              65536 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:               61420 kB
Pss:               61420 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:     61420 kB
Referenced:        58200 kB
Anonymous:         61420 kB
LazyFree:              0 kB
AnonHugePages:         0 kB
ShmemPmdMapped:        0 kB
FilePmdMapped:        0 kB
Shared_Hugetlb:        0 kB
Private_Hugetlb:       0 kB
Locked:                0 kB
THPEligible:           0
VmFlags: rd wr mr mw me ac 

2. 关键指标解读

  • Private_Dirty
    这个指标极为关键。它代表了未被共享且已被修改的私有内存(基本上等同于该进程实际消耗的物理内存)。如果这个值很高,说明该虚拟内存段已经实打实地吃掉了物理内存。
  • Anonymous
    显示为匿名内存,说明它没有映射到任何具体的文件上(如 .so 库文件或 jar 包)。这是通过 mallocmmap(MAP_ANONYMOUS) 分配出来的,通常对应堆外内存或 JNI 内存分配。

五、 第四步:用 gdb 实体倾倒(Dump)内存内容,揪出罪魁祸首

知道了哪个地址段在泄露物理内存还不够,我们必须看一眼里面存的到底是什么数据。

通过 gdb 可以直接抓取指定内存段的数据并输出为文件,然后利用 strings 命令查看里面的明文信息。

1. 使用 gdb dump 内存

以刚才定位到的 7f3c4c000000 地址段为例,起始地址是 00007f3c4c000000,结束地址是 00007f3c50000000
(注意:十六进制地址可以直接写在 gdb 命令中)

gdb --batch --pid 1 -ex "dump memory /tmp/leak_dump.bin 0x7f3c4c000000 0x7f3c50000000"

这个命令会把这 64MB 的物理内存内容完整地保存到容器内的 /tmp/leak_dump.bin 文件中。

2. 分析 Dump 文件的内容

使用 strings 命令提取文件中的可读字符:

strings /tmp/leak_dump.bin | head -n 100

通过观察输出的文本,通常能发现强烈的业务特征:

  • 场景 A:全是 HTTP 请求/响应报文、JSON 字符串
    说明这是网络 I/O 缓冲区。可能是 Netty、Tomcat 或其他连接池没有妥善释放 DirectByteBuffer 导致的泄露。
  • 场景 B:大量的 Zip/Jar 文件目录结构、文件名
    说明是 Java 的 ZipFileInflater/Deflater 泄露。在 Java 8 中,如果使用了 ZipInputStream 却没主动调用 close(),其底层的 C 语言 native 内存(inflateInit2_ 分配的)就不会被回收,极易造成此类泄露。
  • 场景 C:大面积的数据库查询结果、SQL 语句
    说明是数据库驱动或连接池的堆外缓存泄露。
  • 场景 D:看不懂的乱码/二进制
    可能是某些加密算法库、图像处理库(如 ImageMagick 的 JNI 绑定)在分配内存。

六、 常见泄露源头与解决方案

根据上面排查出的线索,可以对照以下几种常见病因进行治理:

1. glibc 的 Malloc Arena 机制(最常见)

  • 病症pmap 看到几十个 64MB 左右的匿名内存块,且 smapsPrivate_Dirty 很高。
  • 病因:glibc 为了提高多线程下内存分配的并发性能,会为每个线程或 CPU 创建内存分配池(Arena)。默认上限是 CPU核心数 * 8。高并发的 Java 应用会频繁创建/销毁线程,导致创建了极多的 Arena,且由于碎片化,这些 Arena 占用的内存极难释放回操作系统。
  • 解法:在容器的启动脚本或环境变量中,限制 Arena 的数量。通常设置为 24 就足够了:
    export MALLOC_ARENA_MAX=4
    
    设置该变量后重启容器,你会发现那些诡异的 64MB 内存块几乎消失了,RSS 大幅回落。

2. Inflater / Deflater 导致的本地内存泄露

  • 病症:Dump 出来的内容里有大量的 zip 压缩包文件名。
  • 病因:Java 的 ZipInputStream / GZIPInputStream 底层调用了 zlib 库。如果不手动 close(),其占用的 native 内存只能等待 GC 时通过虚引用(PhantomReference)的清洁工线程(Cleaner)去回收。如果 GC 发生不频繁,或者对象晋升到了老年代,就会导致 native 内存长期无法释放。
  • 解法
    1. 确保在 try-with-resources 块中完整闭合所有流。
    2. 如果使用了第三方依赖(如某些旧版本的 PDF 导出库、Excel 解析库),升级它们至最新版本。

3. Netty 堆外内存泄露

  • 病症:Dump 出来的内容包含大量的业务数据交互,如 HTTP Header、Redis 命令等。
  • 解法
    1. 开启 Netty 的泄露检测检测级别:
      -Dio.netty.leakDetection.level=PARANOID
      
      在日志中如果看到 LEAK: ByteBuf.release() was not called before it's garbage-collected 的报警,即可精确定位到未释放 ByteBuf 的代码位置。
    2. 限制 JVM 最大可分配的直接内存:
      -XX:MaxDirectMemorySize=512m
      

4. 开启 JVM 自身的 Native Memory Tracking (NMT)

在寻找具体代码位置时,还可以配合 JVM 提供的 NMT 工具。
启动 Java 进程时加入参数:

-XX:NativeMemoryTracking=detail

然后可以在容器内使用以下命令查看当前 JVM 内部各项 native 内存的开销:

jcmd <pid> VM.native_memory detail

NMT 会直观地告诉你,究竟是 Compiler(编译器)、GCSymbol(符号表)还是 Internal(内部/Direct Memory)消耗了最大比例的 native 内存。


总结

排查容器内 Java 进程 RSS 泄露是一项考验 Linux 底层功底的工作。我们可以梳理出如下的标准排查路径:

出现 RSS 持续上涨 
   │
   ├──> 1. 开启 NMT 排除 JVM 内部(Metaspace / Thread Stacks 等)
   │
   └──> 2. 使用 pmap -x 排序分析内存块大小与特征
           │
           ├──> 呈现大量 64MB 块 ──> 考虑限制 MALLOC_ARENA_MAX
           │
           └──> 出现大面积未知 anon 块
                   │
                   └──> 3. 查阅 smaps 确认 Private_Dirty 占比
                           │
                           └──> 4. 使用 gdb dump 该物理内存段
                                   │
                                   └──> 5. strings 分析内容,还原业务场景并根治

通过这套组合拳,容器内的各类 Native 内存“幽灵”泄露都将无处遁形。

小兵排雷 JVMLinux内存泄露

评论点评