关于linux:一次疑似-JVM-native-内存泄漏的排查实录

62次阅读

共计 4047 个字符,预计需要花费 11 分钟才能阅读完成。

最近开发同学反馈,某定时工作服务疑似有内存透露,整个过程的内存占用比 Xmx 内存大不少,而且看起来是迟缓回升的,做了上面这次剖析,包含上面的内容:

剖析 JVM native 内存的一些常见思路
内存增长了,怎么甄别是不是内存透露
一个齐全不相熟的我的项目如何找到可能导致 native 内存调配的代码
经典的 Linux 64M 内存问题
到底是内存碎片还是内存透露

景象
这个定时工作的利用设置 Xmx 为 925M,然而 native 内存缓存持续增长,然而增长到肯定阶段也会保持稳定,不再持续增长。

是内存透露吗?
不论是不是内存透露,首先要搞清楚的是这段增长的内存是什么,土办法就是用 pmap -x 继续察看内存地址空间的变动。
通过几个小时的 pmap 后盾运行,很快发现堆内存简直无变动,增长的区域都在 64M 内存空间,这就是经典的 glibc 内存调配 64M 问题。
对于 Linux 64M 内存问题,我之前写过几篇相干的文章,大家感兴趣能够去看。

从这里根本能够确定是 native 带来的问题,接下来就是 dump 进去看外面到底存了什么。这里有几个办法

应用 gdb
写一个脚本读取 /proc/<pid>/mem
我本人用 Go 写的一个小工具(可能过段时间释放出来)

脚本内容如下:
cat /proc/$1/maps | grep -Fv “.so” | grep ” 0 ” | awk ‘{print $1}’ | grep $2 | (IFS=”-“
while read a b; do
dd if=/proc/$1/mem bs=$(getconf PAGESIZE) iflag=skip_bytes,count_bytes \
skip=$((0x$a)) count=$((0x$b – 0x$a)) of=”$1_mem_$a.bin”
done )
复制代码
执行这个脚本,传入过程号和起始地址就能够把对应内存 dump 到文件中。接下来能够通过 strings 初步查看文件外面有没有意识的字符串。通过 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 的内容,通过 16 进制剖析能够进一步确认。家喻户晓 jar 包就是一个 zip,如果读取了 zip,那实践内存中会有 zip 的魔数,问一下 ChatGPT zip 的魔数是多少。

用 010 Editor 拿着 50 4B 03 04 去内存里搜,能够看到这个 1M 多的内存文件里有 15 个 zip 魔数。

能够进一步把这个文件当做 zip 文件来解析,能够看到 zip 文件对应的 zip entry 有哪些。

接下来就是去找是谁在读这些 jar 包,读文件会有零碎调用,于是这里 strace 就能够看看到底是怎么读的。(也能够通过 jstack 看 java 层的堆栈找到同样的起因,这里不开展)

这里呈现了一个不意识的临时文件,还有一个前缀 FastClasspathScanner,去代码里搜,原理是我的项目用了 FastClasspathScanner 来扫描 class 文件
FastClasspathScanner 我的项目地址在 github.com/classgraph/…,FastClasspathScanner 提供了一种简略疾速的办法来扫描 Java 类门路。它能够轻松找到类门路上的所有类、资源、包和模块,并获取无关它们的信息。这个我的项目用它来做什么呢?

通过看代码,它大略是用来去 jar 包里搜哪些类实现了 com.seewo.school.statistics.counter.Counter 接口,而后去 classpath 中的找到实现了这个接口的类,也就是遍历所有的 jar 包去找实现类。
FastClasspathScanner 的做法是先把这些依赖的 jar 包先拷贝到长期目录(留神这里的 tempFile.deleteOnExit(),尽管跟此次问题不相干,但也是一个内存隐患,等下介绍)

而后读取这些长期 jar 包,

大量申请开释内存的中央在 java.util.zip.Inflater 类,调用它的 end 办法会开释 native 的内存。如果 end 办法没有调用,就会导致内存透露,java.util.zip.InflaterInputStream 类的 close 办法在一些场景下是不会调用 Inflater.end 办法,如下所示。

然而 Inflater 类有实现 finalize 办法,在 Inflater 对象不可达当前,JVM 会帮忙调用 Inflater 类的 finalize 办法
public class Inflater {

public void end() {synchronized (zsRef) {long addr = zsRef.address();
        zsRef.clear();
        if (addr != 0) {end(addr);
            buf = null;
        }
    }
}
protected void finalize() {end();
}
private native static void initIDs();
// ...
private native static void end(long addr);

}
复制代码
有几种可能性

Inflater 因为被其它对象援用,没能开释,导致 finalize 办法不能被调用,内存天然没法开释
Inflater 因为还没被 FinalizerThread 执行 fianlize 办法,导致没有开释
Inflater 的 finalize 办法被调用,然而被 libc 的 ptmalloc 缓存,没能真正开释回操作系统

更多对于 finalize 机制,大家能够移步笨神的文章:「JVM 源码剖析之警觉存在内存透露危险的 FinalReference(增强版)」heapdump.cn/article/265…
于是 dump 堆内存去剖析是不是有大量的 Inflater 类没有被回收,通过内存剖析看,发现 java.util.zip.Inflater 类有 6k 多没有被回收。

没有被回收的起因是它们被 Finalizer 援用,须要两次 GC 才有可能被回收。
而且 FinalizerThread 的优先级比拟低,如果 CPU 比拟缓和的状况下,会导致须要很久才会把队列中 f 对象的 finalize 办法执行完。又因为这个工夫比拟长,可能导致 f 对象屡次 GC 当前进到老年代,如果老年代 gc 频率不高,那 f 对象存活的工夫就更久了。
这样的 native 内存短时间不开释,又因为定时工作长期执行,就可能会导致内存碎片、glibc 内存不偿还的呈现(等下验证),就算开释 libc 也有可能不会还给操作系统。
通过手动屡次触发 GC,确认能够将所有的 java.util.zip.Inflater 回收掉,然而 natvie 内存并没有太大的变动。于是狐疑是 glibc 的内存碎片和内存没有归还给操作系统。
如何批改
有几种可能的批改形式
计划 1:其实这里显著是程序上设计不合理,没必要每次定时工作都去扫描包,这些包又不会变,扫描一次就能够了,让开发的同学去批改代码,把第一次扫描的后果缓存起来。而后打了一个包去开发环境运行,成果非常明显,新版本跑了一整天都内存简直没有什么稳定,旧版本则迟缓的上涨了 400M 左右。

计划 2:批改 FastClasspathScanner 代码,在流敞开的时候,顺带敞开 Inflater, SpringBoot 外面是这么实现的。(不想改了)
SpringBoot 外面的改变如下:github.com/spring-proj…

计划 3:后面狐疑是因为 glibc 的内存碎片,尝试替换碎片整顿更敌对的 tcmalloc 或者 jemalloc,看看成果。
LD_PRELOAD=/usr/local/lib/libtcmalloc.so java -jar xxx
复制代码
上面是换了 tcmalloc 当前的成果,tcmalloc 贼稳。

能够看到换到了对内存碎片更敌对的内存分配器当前,内存的增长失去了十分好的管制。
番外篇
下面提到 tempFile.deleteOnExit() 会有微小的坑,通过内存 dump 的剖析,能够看到 java.io.DeleteOnExitHook 占了将近 40M。

外面有一个动态的 hashset,外面存了 10 几万个字符串,就是 FastClasspathScanner 产生的临时文件门路。

是因为这里调用了 File.deleteOnExit,这个可太坑了。

它把文件的门路加到了一个 jvm 全局 DeleteOnExitHook 类的动态变量 files 中。

又因为临时文件每次的门路都是不一样的,导致这个 hashset 随着定时工作的执行逐步变大,永远无奈回收。
DeleteOnExitHook 本意是用来在 Java 虚拟机退出的时候删除文件。

对于 server 端这种长时间运行的程序,用 deleteOnExit 就太坑了,只有等容器退出那会才会执行删除。再加上这里的文件门路每次都变,导致内存白白浪费。
小结
因为程序设计的问题导致频繁读取 jar 包(理论是 zip 文件),须要调用 native 的代码去解决 zip 文件,会有十分多 native 内存调配的产生。又因为用了 zip 默认的 InflaterInputStream,导致没有方法在流敞开时调用 java.util.zip.Inflater 类的 end 办法开释 native 内存,只能等到 Finalizer 机制在屡次 GC 当前调用,导致了 native 内存可能在短时间内无奈开释。
又因为内存碎片和 libc 内存分配器的实现策略,导致了它没有将内存真正开释给操作系统,导致了迟缓的内存增长。
简略来说,有一个猪队友在不停的申请内存(无奈立即开释),又因为 libc 碎片化和内存二道贩子不肯定会把 native 内存还给 os,导致了内存的迟缓增长。
一点想法:

Java 的 zip 机制是真的设计有点坑,
Finalize 机制齐全帮倒忙,弊远大于利,新版本 Java 的确也做了批改。

正文完
 0