背景:车联网应用,高频的监控数据解析入库查询。涉及到的中间件包括OTS、KAFKA、REDIS、RDS、DUBBO、MaxCompute、DataHub等等。
表现:java堆内存呈现非常规律的锯齿状,YGC,FGC都是正常的表现。但是在top命令中JAVA进程使用的RES持续增长,涉及到车联网产品8个应用。部分应用(8G内存)连续运行2天就使用了将近80%的内存。部分应用运行大概一周后占用了80%内存。截图一个典型的内存占比增长和堆内存的变化曲线
排查的过程比较曲折,网上也翻阅了不少前人的经验。不过较多的都是一些特定的例子,并没有形成系统的方法。不过通过例子确实帮助找到几个主要的堆外内存泄露的坑。其中有一篇文章的方法思路跟我后来总结的较为接近https://www.liangzl.com/get-article-detail-5958.html 。可以借鉴。
我这里总结下核心的排查思路。
1:JAVA堆内存泄露排查,因为这里主要讲堆外内存泄露的问题。所以这里主要讲下工具方法然后直接贴图了。主要使用Eclipse的MAT(Memory Analyzer Tool)来分析dump文件。工具会给出堆内内存泄露的点,这些都是比较直接的。上图(本例里面使用了ScheduleX,使用了几个大的队列,并不是泄漏点)
2:堆外内存排查,这里直接把套路列出来,不赘述自己走过的弯路。
- 使用MAT的Histogram查找java.lang.ref.Finalizer(至于这个类是干啥的,可以自行百度)。如果这里能过滤出来上万的对象,那基本上这里一定存在着堆外内存泄露的点了(比如这里的Socket连接,通过List Objects--with outgoing reference)
一般你都可以通过reference对象一步步抽丝剥茧找到这个等待Finalizer队列回收的对象的相关属性,我就是通过这个定位到其中一个应用未使用数据库连接池,导致非常多的socket连接等待关闭,而socket连接肯定会使用到系统的读写缓存,自然进程就消耗了较多的堆外内存。
- 当然通过java.lang.ref.Finalizer我们还可能找到除socket连接之外的一些待释放资源,比如我就找到很多java.util.zip.Deflater(Inflater)对象。看了下他的代码,是重写了finalize的方法的,所以这里是个坑,高频的使用gzip解压缩得时候,就会有这个问题。而梳理我们的应用,我们使用的kafka在消息发送的时候选择了gzip的压缩方式,所以整个应用群在这部分内存泄露上存在着相同问题。将kafka的消息压缩方式改成snappy,解决了整个应用群80%的内存泄露问题。
- 前面讲到kafka的gzip消息发送配置导致了堆外内存泄露80%的问题,那剩下的20%的问题在哪里?一个是根据对应用的特点,比如我这边剩下的应用特别之处是使用了动态脚本语言,那就从这个点切入进去,这次百度没啥发现,试了google,发现这个问题
https://bugs.openjdk.java.net/browse/JDK-8197544 把这个当做怀疑点验证发现ScriptEngine.eval 方法存在着内存泄露的问题。因为开发人员使用了直接eval代码段的方式,每次处理的时候都是直接eval脚本。后来改成script function的调用方法,并且保证script的eval只执行一次,后续都是缓存这个engine然后invokeFunction传参。(这有点类似预编译之后调用的意思?)
- 上面是因为自己熟悉应用特点,所以针对性地去排查的动态脚本引擎的问题,假设我不知道这个特点,如果定位可能的问题,通过perf record,perf report去查看所有JNI的方法调用,也许这里能知道蛛丝马迹。比如这里的脚本引擎调用。
- java.lang.ref.Finalizer较多的时候,也可以考虑调优下JVM的启动参数,我这里调整了下 -XX:-UseAdaptiveSizePolicy 关闭了JVM的动态内存尺寸调整,避免ED,S0,S1的动态调整,担心S0,S1太小会不会导致更多的对象进到老年区。
结论:通过java.lang.ref.Finalizer来查找等待系统回收的资源,这里是内存泄露重灾区。通过perf等工具查找可疑的JNI调用,通过对应用特点分析找到可疑的点。
最后附上一张调整后的对比图