Java 堆外内存排查
概述
Java 内存排查,一直是不容易的事情,堆内内存还好,只要有 dump 文件,放到 MAT 分析下,基本就可以了,但是堆外内存,一直是个老大难问题。我们经常会遇到以下几个问题:
- 为什么我配置了 Xmx2G,系统告警说内存用到了 4G?
- 为什么配置了-XX:MaxDirectMemorySize 却没有效果?
- 为什么 jdk 提供的工具,无法检查出堆外内存?
- Xmx 和 Xms 设置相同,理论上,物理内存是不是就没有变化了?但为什么总是在变化呢?
以上几个问题,只是冰山一角,君不见网上有数不清的堆外内存排查案例。本文尝试总结堆外内存的来龙去脉。
分析
首先 Java 内存的分类:
- 堆内(eden,Survivor0, Survivor1, old,压缩指针(15M 左右))
- 堆外
- JVM 自身运行需要的堆外内存
- 线程栈(1Mb,200个线程,通常就是 200Mb 左右)
- GC 自身占用内存(50Mb 左右)
- 元数据(metaspace)100M 左右
- 类信息,方法信息
- 字节码,Code (代码存储区,80 Mb 左右)
- 运行时常量池
- 类常量池
- 符号表(sumbol,几兆左右)
- 开发者可以使用的堆外内存(直接内存)
- ByteBuffer.aallocateDirect() 内存分配,受 XX:MaxDirectMemorySize 限制,这个内存是会被 JVM GC 自动回收的。
- 反射 Unsafe.allocateMemory 调用分配的内存,XX:MaxDirectMemorySize 无效。
- JNI 使用 C 分配的内存,XX:MaxDirectMemorySize 无效。
排查思路
前提条件:如果出现了 RES 大于 Xmx,首先要排除元数据区是否没有配置 Max 参数。这个可以使用 Jmap (jmap -histo[:live] pid)查看。
如果元数据区没有问题。 通常就是堆外内存导致,Unsafe 或者 JNI
jmap -histo:live 30232
Jmap 使用:
option:
- no option: 查看进程的内存映像信息,类似 Solaris pmap 命令。
- heap: 显示Java堆详细信息
- histo:live : 显示堆中对象的统计信息, 会触发 GC !
- clstats:打印类加载器信息
- finalizerinfo: 显示在F-Queue队列等待Finalizer线程执行finalizer方法的对象
- dump:
:生成堆转储快照 - F: 当-dump没有响应时,使用-dump或者-histo参数. 在这个模式下,live子参数无效.
- help:打印帮助信息
- J
:指定传递给运行jmap的JVM的参数
如何判断是 unsafe 还是 JNI ? 使用 NMT(Native Memory Tracking),注意:NMT 会带来 5%~10% 的性能损耗。
NMT 是 oracle 追踪堆外内存的一个插件。
这里使用一段代码例子:
1 | public static void main(String[] args) throws InterruptedException, IOException { |
每秒执行一次申请堆外内存操作。
使用步骤:
- 重启 Java 应用, 加入 -XX:NativeMemoryTracking=detail 参数。最好再加上 -XX:+AlwaysPreTouch 参数,可排除内存曲线干扰。
- 启动参数 -XX:NativeMemoryTracking=detail -Xmx1G -Xms1G -XX:+AlwaysPreTouch -XX:MaxDirectMemorySize=10G
- 如果要关闭:jcmd
VM.native_memory shutdown
- 等待内存变化,发现变化后,使用 jcmd 8490 VM.native_memory summary scale=MB 查看内存。结果如下图:
如上图, 列出了相关的内存信息,堆内存 1024,class 680 个,线程数 22个 等信息。
主要关注 total committed(committed表示应用正在使用的内存大小) 结果。可以看到这边显示的 1.8G,而实际上,我们只申请了 1G。
注意:下面显示 Internal 使用了 2709M,这就是 unsafe 申请的内存
。
如果看见 top 中的 RES 和 这个 commited 差不多,基本上,就是使用了 unsafe 内存。
unsafe 问题怎么办?
注意:-XX:MaxDirectMemorySize=size 默认和 -Xmx 相等
NIO 和 Netty 都取 -XX:MaxDirectMemorySize 值限制大小。NIO 和 Netty 有计数器字段,计算已申请堆外内存大小,监控堆外内存使用情况,超过最大值限制,抛OOM。
- NIO 中 是:OutOfMemoryError: Direct buffer memory。
- Netty 中是:OutOfDirectMemoryError: failed to allocate capacity byte(s) of direct memory (used: usedMemory , max: DIRECT_MEMORY_LIMIT )。
JDK 工具: jvisualvm
上图左边展示了 Unsafe 堆外内存的监控曲线,右边展示了 MappedByteBuffer 的内存增长曲线。
相关代码如下:
1 | public static void main(String[] args) throws InterruptedException, IOException { |
这段代码,既分配了堆外内存,又申请了 mmp(底层调用的是个 naive 方法);
如果是 unsafe 内存,分为 2 种情况:
- 如果是 ByteBuffer.aallocateDirect() 分配的,可以使用 XX:MaxDirectMemorySize 控制。如何确定? java.nio.Bits#totalCapacity 属性监控。
- 还可能是 netty ,通常不会,可能性小,监控项:io.netty.util.internal.PlatformDependent#DIRECT_MEMORY_COUNTER
- 如果是 Unsafe 分配的,比较麻烦,需要 debug 这个 unsafe 类,找出是哪里用的,然后再进行查看,为什么泄漏。
- 另外检查是否有System.gc
如果 RES 和 这个 commited 差很多,那通常就是 JNI 分配内存的问题。
题外话:ByteBuffer 的回收。Direct ByteBuffer分配出去的直接内存其实也是由GC负责回收的,而不像Unsafe是完全自行管理的,Hotspot在GC时会扫描Direct ByteBuffer对象是否有引用,如没有则同时也会回收其占用的堆外内存。
DirectByteBuffer 类有一个内部的静态类 Deallocator,这个类实现了 Runnable 接口并在 run() 方法内释放了内存,源码如下
JNI 问题怎么办?
常见 java zip 工具类。
google 工具:
- root 权限安装 gperftools,export 环境变量, 重启 Java 应用。然后静等输出 多个 heap 二进制文件。
- 有了 heap 文件后,使用 pprof 将最后一个 heap 二进制文件转成 pdf 调用图,即可查看。
通常会有这样的图:
实战
- 使用 NMT 排除 unsafe 内存泄漏。
unsafe 内存泄漏实战代码。
JVM 参数:-XX:NativeMemoryTracking=detail -Xmx1G -Xms1G -XX:+AlwaysPreTouch -XX:MaxDirectMemorySize=1M
1 | import java.io.IOException; |
- 使用 google pprof 排查 JNI 内存泄漏。
工具安装:
- yum install gperftools
1 | export LD_PRELOAD="/usr/lib64/libtcmalloc.so" |
JNI 内存泄漏实战代码:
JVM 参数:-XX:NativeMemoryTracking=detail -Xmx20M -Xms20M -XX:+AlwaysPreTouch -XX:MaxDirectMemorySize=1M
1 | import java.io.*; |
其他工具
- GDB dump查看内存空间内容
- pmap 查看进程内存地址空间
- pmap -x 30232 | sort -k 3 -n -r
- 7f5a
- strace常用来跟踪进程执行时的系统调用和所接收的信号
- strace -o output.txt -T -tt -e trace=all -p 28979
- strace -f -e”brk,mmap,munmap” -o output.txt -p 30232