当前位置: 首页 > Linux

疑似JVM原生内存泄漏的排查记录

时间:2023-04-06 04:00:29 Linux

近日,有小伙伴反映某定时任务服务疑似内存泄漏。整个进程的内存占用比xmx内存大很多,而且似乎在慢慢上升。我做了以下分析本次分析包括以下内容:分析JVM原生内存的一些常用思路内存增长,如何识别是否是内存泄漏一个完全陌生的项目如何找到可能导致原生内存分配的代码经典Linux64M内存问题是内存吗?碎片或内存泄漏本次定时任务的应用将Xmx设置为925M,但是native内存缓存不断增长,但是增长到一定阶段会保持稳定,不会继续增长。是内存泄漏吗?不管是不是内存泄漏,首先要弄清楚这个增加的内存是什么。native方法是使用pmap-x持续观察内存地址空间的变化。后台运行pmap几个小时后,很快发现堆内存几乎没有变化,增加的区域在64M内存空间。这就是经典的glibc内存分配64M问题。关于Linux64M内存问题,之前写过几篇相关的文章,有兴趣的可以看看。从这里基本可以确定问题是native引起的,接下来就是dump一下,看看里面存的是什么。下面介绍几种使用gdb写脚本读取/proc//mem的方法我用Go写的一个小工具(过段时间可能会发布)脚本内容如下:cat/proc/$1/地图|grep-Fv".so"|grep“0”|awk'{print$1}'|grep$2|(IFS="-"whilereadab;doddif=/proc/$1/membs=$(getconfPAGESIZE)iflag=skip_bytes,count_bytes\skip=$((0x$a))count=$((0x$b-0x$a))of="$1_mem_$a.bin"done)复制执行此脚本的代码,传入进程号和起始地址,对应的内存转储可以转储到文件中。接下来,您可以使用字符串来初步检查文件中是否有可识别的字符串。通过strings,找到了很多jar包文件的内容,部分如下:该内容是项目依赖的jar包HikariCP-2.5.1.jar的MANIFEST.MF文件的内容。├──MANIFEST.MF└──maven└──com.zaxxer└──HikariCP├──pom.properties└──pom.xml复制的代码好像是程序读取HikariCP-2.5.1的内容。jar,可以通过十六进制分析进一步确认。众所周知,jar包是一个zip。如果读取了zip,那么理论上内存中会有一个幻数的zip。问ChatGPTzip的神奇数字是多少。用010编辑器搜索504B0304的内存,可以看到在这个1M的内存文件中有15个zip幻数。您可以进一步将此文件解析为一个zip文件,您可以看到该zip文件对应了哪些zip条目。下一步是找出谁在阅读这些jar包。会有读取文件的系统调用,所以这里的strace可以看到是怎么读取的。(也可以通过jstack查看java层的栈,发现同理,这里就不展开了)这里有个未知的临时文件,有个前缀FastClasspathScanner,去代码里找,原理是项目使用FastClasspathScanner扫描class文件FastClasspathScanner项目地址在github.com/classgraph/...,FastClasspathScanner提供了一种简单快速的扫描Java类路径的方式。它可以轻松地找到类路径上的所有类、资源、包和模块,并获取有关它们的信息。这个项目用它做什么?看了代码,大概是用来在jar包中搜索哪些类实现了com.seewo.school.statistics.counter.Counter接口,然后去classpath中寻找实现这个接口的类,即就是遍历所有的jars包找到实现类。FastClasspathScanner的做法是先将这些依赖的jar包复制到一个临时目录下(注意这里的tempFile.deleteOnExit()虽然和这个问题无关,但也是内存隐患,后面会介绍),然后读取这些临时jar包package中,java.util.zip.Inflater类中有大量释放内存的应用,调用它的end方法会释放native内存。如果不调用end方法,会造成内存泄漏。java.util.zip.InflaterInputStream类的close方法在某些场景下不会调用Inflater.end方法,如下图。但是Inflater类有一个finalize方法。Inflater对象不可达后,JVM会帮助调用Inflater类的finalize方法。publicclassInflater{publicvoidend(){synchronized(zsRef){longaddr=zsRef.address();zsRef。清除();如果(地址!=0){结束(地址);缓冲区=空;}}}protectedvoidfinalize(){end();}privatenativestaticvoidinitIDs();//...privatenativestaticvoidend(longaddr);}有几种复制代码的可能性。Inflater无法释放,因为它被其他对象引用,所以无法调用finalize方法。内存自然是不能释放的。Inflater还没有被FinalizerThread执行。Fianlize方法,导致没有释放Inflater的finalize方法被调用了,但是被libc的ptmalloc缓存了,无法释放回操作系统。(增强版)》heapdump.cn/article/265...于是转储堆内存分析是否有大量Inflater类没有被回收,经过内存分析发现有6k多没有被回收的java.util.zip.Inflater类,之所以没有被回收,是因为被Finalizer引用,需要两次GC才能回收,而且FinalizerThread的优先级比较低,如果CPU是比较紧,执行队列中f对象的finalize方法会耗时较长,而且由于这个时间比较长,可能会导致f对象经过多次GC后进入老年代。如果GC频率为老年代不高,那么f对象存活的时间会更长。此类native内存短时间不释放,由于定时任务长期执行,可能会造成内存碎片和glibc内存不归还(等待验证),即使释放libc,也未必返回给操作系统。通过多次手动触发GC,确认java.util.zip.Inflater可以全部回收,但natvie内存变化不大。所以怀疑glibc的内存碎片和内存还没有归还给操作系统。如何修改有几种可能的修改方法。解决方案一:其实很明显这里的程序设计不合理。不必每次安排计划任务时都扫描包。要修改代码,缓存第一次扫描的结果。然后我做了一个包在开发环境中运行,效果很明显。新版运行了一整天,内存几乎没有任何波动,而老版则慢慢增加了400M左右。方案二:修改FastClasspathScanner的代码。stream关闭时,顺便关闭Inflater,这是SpringBoot中实现的。(不想再改了)SpringBoot的改动如下:github.com/spring-proj...方案三:我怀疑是因为glibc的内存碎片,尝试换成tcmalloc或者jemalloc,这样对碎片整理比较友好,看看效果吧。LD_PRELOAD=/usr/local/lib/libtcmalloc.sojava-jarxxx复制代码下面是更改tcmalloc后的效果,tcmalloc很稳定。可以看出,切换到对内存碎片更友好的内存分配器后,内存的增长得到了很好的控制。上面extra章节说了,tempFile.deleteOnExit()会有一个很大的坑。通过分析内存转储,我们可以看到java.io.DeleteOnExitHook占用了将近40M。里面有一个statichashset,里面存放着几万个字符串,就是FastClasspathScanner生成的临时文件路径。是因为这里调用了File.deleteOnExit,太糟糕了。它将文件的路径添加到jvm全局DeleteOnExitHook类的静态变量文件中。并且由于每次临时文件的路径都不一样,hashset会随着定时任务的执行逐渐变大,永远无法回收。DeleteOnExitHook用于在Java虚拟机退出时删除文件。对于服务端长时间运行的程序,使用deleteOnExit太坑爹了,只有在容器退出时才会进行删除。另外这里的文件路径每次都在变化,造成内存的浪费。总结由于编程问题,经常读取jar包(其实是zip文件),需要调用native代码处理zip文件,会出现大量的native内存分配。并且因为使用了zip默认的InflaterInputStream,所以在stream关闭的时候没有办法调用java.util.zip.Inflater类的end方法来释放nativememory。只能等到多次GC后调用Finalizer机制,导致nativememory有可能在短时间内无法释放。并且因为内存碎片和libc内存分配器的实现策略,并没有真正释放内存给操作系统,导致内存增长缓慢。简单来说就是有个猪队友一直在申请内存(不能马上释放),由于libc碎片和内存贩子可能不会把本机内存还给OS,所以内存增长缓慢。一个小想法:Java的zip机制设计的确实有点瑕疵。Finalize机制是完全无用的,缺点远大于优点。新版Java确实做了修改。

猜你喜欢