背景
我们的一个后端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 & |
分析思路
jmap分析结果如下,堆和新生代没有异常,排除法判断是堆外内存导致。
1 | jmap -heap $pid |
持续观察一段时间后,通过top发现RES没有继续升高整体稳定在4g左右,猜测不是内存泄露而是某些程序通过堆外内存生成了一些buffer。所以想看下内存都在干嘛。
排查问题步骤:观察内存
先用pmap查看内存情况,发现有很多奇怪的64MB的空间。结果和命令如下:
1 | pmap -x $pid |
问题预估:进过查看资料以及和同事沟通,怀疑是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 | gdb attach <pid> |
注意:生产环境慎用,gdb会阻塞进程。
这些64MB的很多内存是空的,总共加起来差不多有2.5g,算上我们分配的堆内存空间差不多正好4个G,大概原因找到了:由于频繁的调用系统malloc:os申请内存没有回收。查阅文章,可以试着用tcmalloc来解决遂开始安装tcmalloc,并且用gperf分析具体的调用
排查问题步骤:安装tcmalloct和gperf
1.安装环境
1 | yum -y install gcc gcc-c++ |
2.安装libunwind
1 | cd /usr/local/src |
3.安装gperftools
1 | wget https://github.com/gperftools/gperftools/releases/download/gperftools-2.5/gperftools-2.5.tar.gz |
4.使配置生效
1 | vim /etc/ld.so.conf.d/usr_local_lib.conf |
5.加入环境变量
1 | export LD_PRELOAD=/usr/local/google-perftools/lib/libtcmalloc.so |
问题解决
通过top查看进程
之前的进程 top结果 RES 3.3g
之后的进程 top结果 RES 1.8g
我们这时候再用pmap去看进程,64MB的内存那块消失
用pperf查看内存
上面的环境变量生效后运行java就会生成heap的快照地址在见上面HEAPPROFILE的设置
用下面的命令处理heap的快照
1 | cd /usr/local/gperftools/tmp/ |
pdf的文件如下
总结
- 原因:glibc的ptmalloc的arana机制会在并发情况下额外申请很多64MB内存空间,这部分空间不会回收的会重复利用。因为服务的线程数一定所以一般来讲不会出现泄漏,而是内存空洞问题。系统资源不足进程就会被kill
- 解决方案:
- ptmalloc取代tcmalloc。
- 减少堆大小。
附录:压测记录
方案
对服务进行压测,压倒系统出现瓶颈无法在继续给出压力为止,这时候观测系统的参数。*本次压测主要模拟在系统不通负载的运行中内存的使用情况,cpu的占用以及系统瓶颈仅作为参考
结果
下图是在aapi测试时候的监控图:
- 在100QPS下aapi系统出现瓶颈,已经无法继续增加压力,这时候,可以看出100QPS的场景下cpu的波动很大,但是内存增长在合理范围内。
- 系统空转后java进程内存逐渐落回1.6G
docker内部
top图
pidstat -w -p <$pid> 1,上下文切换始终正常
pidstat -u -p <$pid> 1 cpu的使用率
压力主要在user%和system%,代表程序和内核态的调度。