一次堆外内存泄露的排查

背景

我们的一个后端java服务,采用的jetty+spring,堆内存1个g,但是一运行很快内存会占到4个g,启动参数以及top见下图:

1
nohup java -Djava.awt.headless=true -Djava.net.preferIPv4Stack=true -DappName=uapi -server -Xms1g -Xmx1g -XX:SurvivorRatio=8 -Xss256k -XX:ReservedCodeCacheSize=64m -XX:+CMSParallelRemarkEnabled -XX:+UseCMSCompactAtFullCollection -XX:+CMSScavengeBeforeRemark -XX:+UseFastAccessorMethods -XX:+DisableExplicitGC -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=80 -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC -XX:+PrintTenuringDistribution -Xloggc:./gc.logs -jar xxx.jar > nohup.txt 2>&1 &

avator

分析思路

jmap分析结果如下,堆和新生代没有异常,排除法判断是堆外内存导致。

1
jmap -heap $pid

avator

持续观察一段时间后,通过top发现RES没有继续升高整体稳定在4g左右,猜测不是内存泄露而是某些程序通过堆外内存生成了一些buffer。所以想看下内存都在干嘛。

排查问题步骤:观察内存

先用pmap查看内存情况,发现有很多奇怪的64MB的空间。结果和命令如下:

1
pmap -x $pid

avator

问题预估:进过查看资料以及和同事沟通,怀疑是glibc组件的ptmalloc的arena机制导致的:

  • 当一个线程调用malloc申请内存时候,会先查看自己是否有一个分配去,如果有加锁,加锁失败,去环形链表找一个未加锁的分配区,如果没找到,malloc会开辟一个新的分配区加入环形链表并加锁,用它来分配内存。
  • 这种机制在多线程竞争锁激烈的场景下会带来一个问题:非主分配区开辟越来越多,因为它一旦开辟了就不会释放,一个分配区就是64MB。这样也会导致进程占用的内存越来越多(可能实际使用的并不多)。如果系统配置的ulimit进程最大虚拟内存值不是unlimited,那么当进程占用的内存达到ulimit值,就会core掉。这个情况也可以在pmap -p pid中看到里面有大量的64MB大小的anon内存块。这个问题可以通过设置MALLOC_ARENA_MAX环境变量来限制Arena的最大数量规避。
  • 经过后面的gperf排查发现我们的代码中有GZipInputStream在创建对象时候底层的Java_java_util_zip_Inflater_init方法确实是调用了mallloc。

    所以,这其实不是内存泄露而是内存空洞的现象,在我们的系统资源捉襟见肘时候就比较致命。

排查问题步骤:dump内存

查看/proc/$pid/smaps 查看进程内存的使用情况发现进程大量申请了64MB的空间。【怀疑是glibc在2.10引入的arena引起的,需要后续深入研究】

通过gdb dump出内存来查看;【具体命令见下方】

1
2
3
4
5
6
gdb attach <pid>
dump memory outfile.txt startAddreess endAddress
# 其中startAddreess endAddress在上面的smaps中都有

#查看内存
strings outfile.txt

注意:生产环境慎用,gdb会阻塞进程。

这些64MB的很多内存是空的,总共加起来差不多有2.5g,算上我们分配的堆内存空间差不多正好4个G,大概原因找到了:由于频繁的调用系统malloc:os申请内存没有回收。查阅文章,可以试着用tcmalloc来解决遂开始安装tcmalloc,并且用gperf分析具体的调用

排查问题步骤:安装tcmalloct和gperf

1.安装环境

1
yum -y install gcc gcc-c++

2.安装libunwind

1
2
3
4
5
6
cd /usr/local/src
wget http://download.savannah.gnu.org/releases/libunwind/libunwind-0.99.tar.gz
tar -xzvf libunwind-0.99.tar.gz
cd libunwind-0.99
./configure  --prefix=/usr/local/google-perftools/libunwind
make && make install

3.安装gperftools

1
2
3
4
5
wget https://github.com/gperftools/gperftools/releases/download/gperftools-2.5/gperftools-2.5.tar.gz
tar -xzvf gperftools-2.5.tar.gz
cd gperftools-2.5
./configure --prefix=/usr/local/google-perftools/
make && make install

4.使配置生效

1
2
3
4
5
6
7
vim /etc/ld.so.conf.d/usr_local_lib.conf

#新增以下内容
/usr/local/google-perftools/libunwind/lib
# 按esc再:wq! #保存退出

/sbin/ldconfig  #执行此命令,使libunwind生效。 需要sudo权限

5.加入环境变量

1
2
3
export LD_PRELOAD=/usr/local/google-perftools/lib/libtcmalloc.so
export HEAPPROFILE=/usr/local/gperftools/tmp/gzip
##注意 mkdir -p /usr/local/gperftools/tmp

问题解决

通过top查看进程

之前的进程 top结果 RES 3.3g
avator

之后的进程 top结果 RES 1.8g
avator

我们这时候再用pmap去看进程,64MB的内存那块消失

avator

用pperf查看内存

上面的环境变量生效后运行java就会生成heap的快照地址在见上面HEAPPROFILE的设置

用下面的命令处理heap的快照

1
2
3
4
5
6
7
cd /usr/local/gperftools/tmp/

#查看heap信息
/usr/local/gperftools-2.5/bin/pprof --text $JAVA_HOME/bin/java /usr/local/gperftools/tmp/gzip_618.0057.heap

#导出pdf
/usr/local/gperftools-2.5/bin/pprof --pdf $JAVA_HOME/bin/java /usr/local/gperftools/tmp/gzip_618.0057.heap >gzip_618.0057.pdf

pdf的文件如下

avator

总结

  • 原因:glibc的ptmalloc的arana机制会在并发情况下额外申请很多64MB内存空间,这部分空间不会回收的会重复利用。因为服务的线程数一定所以一般来讲不会出现泄漏,而是内存空洞问题。系统资源不足进程就会被kill
  • 解决方案:
    • ptmalloc取代tcmalloc。
    • 减少堆大小。

附录:压测记录

方案

对服务进行压测,压倒系统出现瓶颈无法在继续给出压力为止,这时候观测系统的参数。*本次压测主要模拟在系统不通负载的运行中内存的使用情况,cpu的占用以及系统瓶颈仅作为参考

结果

下图是在aapi测试时候的监控图:

avator

  • 在100QPS下aapi系统出现瓶颈,已经无法继续增加压力,这时候,可以看出100QPS的场景下cpu的波动很大,但是内存增长在合理范围内。
  • 系统空转后java进程内存逐渐落回1.6G

docker内部

top图

avator

pidstat -w -p <$pid> 1,上下文切换始终正常

avator

pidstat -u -p <$pid> 1 cpu的使用率

avator

压力主要在user%和system%,代表程序和内核态的调度。