最近开发同学反馈,某守时使命服务疑似有内存走漏,整个进程的内存占用比 Xmx 内存大不少,并且看起来是缓慢上升的,做了下面这次剖析,包括下面的内容:

  • 剖析 JVM native 内存的一些常见思路
  • 内存增加了,怎样鉴别是不是内存走漏
  • 一个彻底不熟悉的项目怎么找到或许导致 native 内存分配的代码
  • 经典的 Linux 64M 内存问题
  • 究竟是内存碎片还是内存走漏

现象

这个守时使命的使用设置 Xmx 为 925M,可是 native 内存缓存持续增加,可是增加到必定阶段也会保持稳定,不再持续增加。

一次疑似 JVM native 内存泄漏的排查实录

是内存走漏吗?

不管是不是内存走漏,首先要搞清楚的是这段增加的内存是什么,土办法便是用 pmap -x 持续观察内存地址空间的改变。

经过几个小时的 pmap 后台运行,很快发现堆内存简直无改变,增加的区域都在 64M 内存空间,这便是经典的 glibc 内存分配 64M 问题。

关于 Linux 64M 内存问题,我之前写过几篇相关的文章,我们感兴趣能够去看。

一次疑似 JVM native 内存泄漏的排查实录

从这儿基本能够确定是 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 包文件里的内容,部分内容如下:

一次疑似 JVM native 内存泄漏的排查实录

这个内容是项目依靠 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 的魔数是多少。

一次疑似 JVM native 内存泄漏的排查实录

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

一次疑似 JVM native 内存泄漏的排查实录

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

一次疑似 JVM native 内存泄漏的排查实录

接下来便是去找是谁在读这些 jar 包,读文件会有体系调用,所以这儿 strace 就能够看看究竟是怎样读的。(也能够经过 jstack 看 java 层的仓库找到相同的原因,这儿不展开)

一次疑似 JVM native 内存泄漏的排查实录

这儿呈现了一个不认识的暂时文件,还有一个前缀 FastClasspathScanner,去代码里搜,原理是项目用了 FastClasspathScanner 来扫描 class 文件

FastClasspathScanner 项目地址在 github.com/classgraph/… ,FastClasspathScanner 提供了一种简略快速的办法来扫描 Java 类途径。它能够轻松找到类途径上的一切类、资源、包和模块,并获取有关它们的信息。这个项目用它来做什么呢?

一次疑似 JVM native 内存泄漏的排查实录

经过看代码,它大概是用往来不断 jar 包里搜哪些类完成了 com.seewo.school.statistics.counter.Counter 接口,然后去 classpath 中的找到完成了这个接口的类,也便是遍历一切的 jar 包去找完成类。

FastClasspathScanner 的做法是先把这些依靠的 jar 包先拷贝到暂时目录(注意这儿的 tempFile.deleteOnExit(),尽管跟此次问题不相关,但也是一个内存隐患,等下介绍)

一次疑似 JVM native 内存泄漏的排查实录

然后读取这些暂时 jar 包,

一次疑似 JVM native 内存泄漏的排查实录

很多请求开释内存的地方在 java.util.zip.Inflater 类,调用它的 end 办法会开释 native 的内存。假如 end 办法没有调用,就会导致内存走漏,java.util.zip.InflaterInputStream 类的 close 办法在一些场景下是不会调用 Inflater.end 办法,如下所示。

一次疑似 JVM native 内存泄漏的排查实录

可是 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 多没有被收回。

一次疑似 JVM native 内存泄漏的排查实录

没有被收回的原因是它们被 Finalizer 引证,需要两次 GC 才有或许被收回。

并且 FinalizerThread 的优先级比较低,假如 CPU 比较严重的情况下,会导致需要很久才会把行列中 f 目标的 finalize 办法履行完。又因为这个时刻比较长,或许导致 f 目标屡次 GC 今后进到老时代,假如老时代 gc 频率不高,那 f 目标存活的时刻就更久了。

这样的 native 内存短时刻不开释,又因为守时使命长期履行,就或许会导致内存碎片、glibc 内存不归还的呈现(等下验证),就算开释 libc 也有或许不会还给操作体系。

经过手动屡次触发 GC,承认能够将一切的 java.util.zip.Inflater 收回掉,可是 natvie 内存并没有太大的改变。所以怀疑是 glibc 的内存碎片和内存没有归还给操作体系。

怎么修正

有几种或许的修正方法

方案 1:其实这儿显着是程序上设计不合理,没必要每次守时使命都去扫描包,这些包又不会变,扫描一次就能够了,让开发的同学去修正代码,把第一次扫描的成果缓存起来。然后打了一个包去开发环境运行,作用十分显着,新版本跑了一整天都内存简直没有什么动摇,旧版本则缓慢的上涨了 400M 左右。

一次疑似 JVM native 内存泄漏的排查实录

方案 2:修正 FastClasspathScanner 代码,在流封闭的时分,顺带封闭 Inflater, SpringBoot 里边是这么完成的。(不想改了)

SpringBoot 里边的改动如下:github.com/spring-proj…

一次疑似 JVM native 内存泄漏的排查实录

方案 3:前面怀疑是因为 glibc 的内存碎片,测验替换碎片整理更友爱的 tcmalloc 或者 jemalloc,看看作用。

LD_PRELOAD=/usr/local/lib/libtcmalloc.so java -jar xxx

下面是换了 tcmalloc 今后的作用,tcmalloc 贼稳。

一次疑似 JVM native 内存泄漏的排查实录

能够看到换到了对内存碎片更友爱的内存分配器今后,内存的增加得到了十分好的控制。

番外篇

上面说到 tempFile.deleteOnExit() 会有巨大的坑,经过内存 dump 的剖析,能够看到 java.io.DeleteOnExitHook 占了将近 40M。

一次疑似 JVM native 内存泄漏的排查实录

里边有一个静态的 hashset,里边存了 10 几万个字符串,便是 FastClasspathScanner 发生的暂时文件途径。

一次疑似 JVM native 内存泄漏的排查实录

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

一次疑似 JVM native 内存泄漏的排查实录

它把文件的途径加到了一个 jvm 全局 DeleteOnExitHook 类的静态变量 files 中。

一次疑似 JVM native 内存泄漏的排查实录

又因为暂时文件每次的途径都是不一样的,导致这个 hashset 跟着守时使命的履行逐渐变大,永久无法收回。

DeleteOnExitHook 原意是用来在 Java 虚拟机退出的时分删去文件。

一次疑似 JVM native 内存泄漏的排查实录

对于 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 确实也做了修正。