一段痛苦的回忆(线上堆外内存泄露排查)

3,247 阅读5分钟

背景

为什么说是一段痛苦的回忆?国庆期间,本想着好好休整一下身体。结果连着几晚上的线上报警搞得人精神恍惚。每个晚上都是不同的报警。这篇文章主要对java服务堆外内存泄露问题的排查进行介绍。希望对大家有所帮助。

排查过程

jsp发现服务进程消失

国庆凌晨,nginx层收到报警,整个web服务不可用。qa发现app整体瘫痪,我上服务器通过jps看了一下,发现服务已经没了。此时发现问题很严重,也不能快速定位解决,就隔离一台问题服务,其他的迅速重启后,服务恢复。

查看日志

通过查看服务的日志,发现业务没有任何异常报错。gc也是非常正常。于是就排查操作系统日志。 883A5729-0551-4956-A1B1-7DFE98A88B25.png 发现java进程被os kill掉了,原因是oom。看到这里其实已经心里有数,linux预申请内存的时候如果发现内存不足,则会kill掉最大内存的进程(Java进程)。因为我们jvm堆设置的6g,容器内存为8g,留了2g的容错空间肯定是够的,所以定位肯定是哪块内存泄露了。

查看容器内存

07090789-A5D3-445F-A7CC-43F52FF9424A.png

通过监控看到容器内存29号一直增涨,并没有释放的痕迹。(29版本上线,所以跌了),所以可以定位是java服务存在内存泄露。如果是这样,那肯定是一个必现的问题,当时进程已经被kill了,没有现场,所以第二天才能继续排查。然后就继续睡觉了。

排查进程资源

第二天登录到一台正在运行的服务,通过top命令查看了进程情况。发现进程的RES占了7.3g。我们的堆只有6g,正常情况应该不会超过6.5个g。

C2356B73-4E25-442D-8354-71B6979F3A1D.png

排查内存占用

虽然RES升高肯定是堆外存在泄露,但还是看了看堆里面是否存在异常对象,通过jmap dump之后没有发现异常。排查java 线程栈:通过top -Hp pid命令查看线程数189个。并没有过多的线程消耗内存资源。所以排除。

FF9A8EEB-3185-4F4C-B685-72470ADAC5D8.png 排查堆外内存和Mataspace。

通过jmap触发fullgc(因为当时线上量小,低峰期,所以并没有隔离,不建议线上直接使用,最好隔离后再用),从gc日志看Mataspace正常,并且full gc执行完成之后,RES并没有释放,结合堆情况排除了unsafe.allocateMemory和DirectByteBuffer内存泄露的case(DirectByteBuffer有cleaner机制,fullgc很一般会被释放)。

B06A91C2-BD5D-46AC-99D8-A9821CDC02C6.png

是否存在未正常关闭资源

上面都没有发现问题,剩下只有两个思路了。

1.没有正常关闭某些资源(流、tcp连接是否异常)

2.native代码内存没有释放。

通过代码查看到并没有异常流的使用,netstat查看tcp数量一千多条,还算正常。所以暂时排除第一个点。

native代码内存没有释放

只能进一步看看进程内存是否存在问题。

通过pmap -x pid|sort -n -r -k3|less查看进程内存情况。除了4g的堆内存(出问题之后临时解决方案,将jvm堆从6—>4g,保证服务可以多活一阵),还有很多64m的异常内存块。其实这个时候已经定位,这些内存就是RES持续上涨的原因。

C57D9534-9E37-45EA-A28E-480A405AB9B9.png 通过网上搜索得知。linux glibc中有经典的 64M 内存问题,通过getconf GNU_LIBC_VERSION看了一下版本。

glibc 2.12

可以定位应该是通过glibc分配的内存没有释放。这里可以定位是native代码的锅。

于是就想着dump一块内存看看到底是什么东西。通过cat /proc/50/smaps > smap查看进程内存消耗情况,随便找了一块异常内存起止地址。

39BC5751-90D4-45A6-A4DE-8803E0302EA0.png 通过gdb去dump此内存块,使用gdb -pid pid 进入gdb。通过dump memory memory.dump startAddress endAddress命令进行dump。成功之后退出gdb。使用strings memory.dump|less分析dump的内存。

6229CB23-CAEC-4432-8FD0-1C112CBA77FF.png 发现内部有很多业务的返回值字符串,还有就是一串密文。通过查看代码只有加解密才会有这些东西,这个时候把思路转移到加解密,因为加解密是通过native实现的。

加解密定位

于是我对服务进行了一个对照实验压测

  • 基础组:加密压测接口A
  • 对照组:明文压测接口A(不走加解密)

发现基础组的RES随着时间一直在涨,并没有释放的痕迹。对照组的RES一直稳定在一个值。所以这个时候已经可以定位是加解密的native代码存在内存泄露。

因为这个jni代码不是我们维护,所以只能找相关基础平台组进行排查。通过排查发现他们的代码存在内存泄露的bug。修改代码释放内存后问题解决。

总结

整个排查过程比较坎坷,但是也让自己学习了一些东西。对于内存泄露,在jvm中还是比较少见的,存在也就是那么几种情况,通过现场挨个进行分析,就一定能够解决。