jvm堆外内存上升排查

背景

应用内存在由32G缩小至16G后,经常出现docker OOM。设置的java堆大小为11G,而java进程Rss不断上升最终导致OOM,因此怀疑是堆外内存上升导致的

排查

pmap

先用pmap观察内存映射情况,发现有很多64M的内存映射。google了一下,发现是glibc默认内存分配器ptmalloc的分配区(arena)。

在 Doug Lea 实现的内存分配器中只有一个主分配区(main arena),每次分配内存都必须对主分配区加锁,分配完成后释放锁,在 SMP 多线程环境下,对主分配区的锁的争用很激烈,严重影响了 malloc 的分配效率。于是 Wolfram Gloger 在 Doug Lea 的基础上改进使得Glibc 的 malloc 可以支持多线程,增加了非主分配区(non main arena)支持,主分配区与非主分配区用环形链表进行管理。每一个分配区利用互斥锁(mutex)使线程对于该分配区的访问互斥

在64位系统下,最多可以存在 8 * CPU核心数 个分配区。目前我们的docker 配置为8C 16G,也就是说这些分配区最多可以有64个,占用内存64 * 64M = 4096M。会不会是数量太多了导致的?

可以通过设置环境变量MALLOC_ARENA_MAX来控制分配区数量。设置MALLOC_ARENA_MAX=1时表示仅保留一个主分配区

尝试设置MALLOC_ARENA_MAX=1并且重启java进程,确实64M的分配区就不见了,但是集中到了一个大的主分配区中,并且大小还是在不断上升。问题没有解决

NMT

NMT是JVM自带的本地内存追踪工具,能够分析JVM自身内部分配的一些内存,但是无法追踪非JVM分配的内存,例如JNI等native code。

通过在jvm参数里添加 -XX:NativeMemoryTracking=detail 启动,一段时间后执行命令jcmd {pid} VM.native_memory summary 查看结果

可以看到主要包括几个方面

  • Java Heap:java 堆
  • Thread: 线程栈
  • GC:垃圾回收器内部的内存开销
  • 等等

主要观察Total Committed 的内存,观察一段时间后确实上升了,但是仅占整个java进程Rss上升的一部分(< 30%),看来jvm内部占用内存不是主要原因

gzip

看来可能有jni调用在分配内存,网上搜了下,看到很多案例与GZIP相关,主要是GZIPInputStream未close导致的。其主要原因是在java.util.zip.Deflater/Inflater 这两个类的构造函数中会在堆外分配一段缓冲区用于GZIP解压缩。Deflater/Inflater这两个类是实现了finalize方法的,在其中会释放掉堆外分配的缓冲区。如果是这两个类的实例在finlizer队列中积压导致迟迟无法执行finalize方法,确实有可能导致堆外内存无法释放导致上涨

尝试将机器拉出停止接受流量,强制Full GC 并放置一段时间,通过heap dump观察Deflater/Inflater这两个类的实例已经在finalizer队列中降到个位数,也没有其他泄漏的地方。然而java进程的Rss并没有下降,看来也不是这个原因

ptmalloc

又google了一番,发现一些案例与glic的默认内存管理器 ptmalloc 有关,其原理具体可以看华庭大师的《 Glibc 内存管理 - Ptmalloc2 源代码分析》,简而言之,就是ptmalloc实现了一套自己的内存管理的策略,程序在调用malloc向操作系统申请一块内存后,调用free释放内存的时候,并不一定会将内存还给操作系统,而是自己保留下来预备给下次malloc使用。 并且 ptmalloc 还有内存碎片的问题,导致一些内存碎片即无法还给操作系统,也无法被分配利用。

glic中提供了 malloc_trim函数 [https://man7.org/linux/man-pages/man3/malloc_trim.3.html] 可以归还所有保留的内存(包括内存碎片)给操作系统

尝试用gdb 调用 malloc_trim (较大概率导致java进程crash,试了几次才成功)

gdb –batch –pid={pid} -ex “call malloc_trim()”

完成后再看,java进程的Rss下降了大约1.2G,看来很有可能 ptmalloc 保留了比较多的内存未归还给操作系统,导致java 进程 Rss 不断上涨

看了一下,ptmalloc 主要有两个替代方案,facebook的jemalloc 和 google 的 tcmalloc,后续找机会试用一下看看效果

tcmalloc

安装与使用

安装make:sudo yum -y install gcc make

安装gcc:sudo yum -y install gcc gcc-c++

tcmalloc集成于gperftools中,先下载gperftools (gperftools-2.8.1.tar.gz) https://github.com/gperftools/gperftools/releases

解压 tar -xvf gperftools-2.8.1.tar.gz

进入解压后的目录并编译

cd gperftools-2.8.1 && ./configure && make && sudo make install

/usr/local/lib 为默认的安装目录

编译完成后即可,在JVM启动时设置LD_PRELOAD环境变量,加载tcmalloc

方式为在tomcat启动脚本中添加如下一行

export LD_PRELOAD=/usr/local/lib/libtcmalloc.so

线上运行一段时间,java进程Rss稳定,并且相比ptmalloc少了1G