Skip to content

内存泄漏分析工具之 VisualVM、MAT

我们前面讲过,我们可以使用 jmap –histo 这种命令去分析哪些对象占据着我们的堆空间。但是那是比较容易分析的问题,如果是遇到内存情况比较复杂的情况,命令的方式是看不出来的,这个时候我们必须要借助一下工具。当然前提是通过 jmap 命令把整个堆内存的数据 dump 下来。

内存分析工具

VisualVM(jvisualvm.exe)

bash
# jps 获取进程号 3376
PS C:\Users\mengweijin> jps
3376 FullGCProblem
13340
17996 Jps

# 使用 jmap dump 虚拟机信息的文件。jmap -dump:live,format=b,file=heap.apps 3376
PS C:\Users\mengweijin> jmap -dump:live,format=b,file=heap.bin 3376
Dumping heap to C:\Users\mengweijin\heap.bin ...
Heap dump file created

# 然后打开 jvisualvm.exe -> 文件 -> 装入(上面导出的 heap.bin 文件)

VisualVm 属于比较寒酸的工具,基本上跟 jmap 之类的命令没多少区别,它只是可以事后看,通过 dump 信息来看,里面没有多少可以做分析的功能。

MAT 简介

MAT 工具是基于 Eclipse 平台开发的,本身是一个 Java 程序,是一款很好的内存分析工具,所以如果你的堆快照比较大的话,则需要一台内存比较大的分析机器,并给 MAT 本身加大初始内存,这个可以修改安装目录中的 MemoryAnalyzer.ini 文件。

MAT 中的 Incoming/Outgoing References

在柱状图中,我们看到,其实它显示的东西跟 jmap –histo 非常相似的,也就是类、实例、空间大小。

但是 MAT 有一个专业的概念,这个可以显示对象的引入和对象的引出。

在 Eclipse MAT 中,当右键单击任何对象时,将看到下拉菜单。如果选择“ListObjects”菜单项,则会注意到两个选项:

  • with incoming references 对象的引入
  • with outgoing references 对象的引出

MAT 中的浅堆与深堆

浅堆(shallow heap) 代表了对象本身的内存占用,包括对象自身的内存占用,以及“为了引用”其他对象所占用的内存。 深堆(Retained heap) 是一个统计结果,会循环计算引用的具体对象所占用的内存。但是深堆和“对象大小”有一点不同,深堆指的是一个对象被垃圾回收后,能够释放的内存大小,这些被释放的对象集合,叫做保留集(Retained Set)

需要说明一下:JAVA 对象大小=对象头+实例数据+对齐填充

非数组类型的对象的 shallow heap

shallow_size=对象头+各成员变量大小之和+对齐填充

其中,各成员变量大小之和就是实例数据,如果存在继承的情况,需要包括父类成员变量

数组类型的对象的 shallow size

shallow size=对象头+类型变量大小*数组长度+对齐填充,如果是引用类型,则是四字节或者八字节(64 位系统), 如果是 boolean 类型,则是一个字节

注意:这里 类型变量大小*数组长度 就是实例数据,强调是变量不是对象本身

使用 MAT 进行内存泄漏检测

如果问题特别突出,则可以通过 Find Leaks 菜单快速找出问题。

例如里一个名称叫做 AAAA-thread 的线程,持有了超过 99% 的对象,数据被一个 HashMap 所持有。 这个就是内存泄漏的点,因为我代码中对线程进行了标识,所以像阿里等公司的编码规范中为什么一定要给线程取名字,这个是有依据的,如果不取名字的话,这种问题的排查将非常困难。

所以,如果是对于特别明显的内存泄漏,在这里能够帮助我们迅速定位,但通常内存泄漏问题会比较隐蔽,我们需要做更加复杂的分析。

支配树视图

支配树列出了堆中最大的对象,第二层级的节点表示当被第一层级的节点所引用到的对象,当第一层级对象被回收时,这些对象也将被回收。这个工具可以帮助我们定位对象间的引用情况,以及垃圾回收时的引用依赖关系。

支配树视图对数据进行了归类,体现了对象之间的依赖关系。我们通常会根据“深堆”进行倒序排序,可以很容易的看到占用内存比较高的几个对象,点击前面的箭头,即可一层层展开支配关系(依次找深堆明显比浅堆大的对象)。

经过分析,内存的泄漏点就在此。一个线程长期持有了 200 个这样的数组, 有可能导致内存泄漏。

MAT 中内存对比

我们对于堆的快照,其实是一个“瞬时态”,有时候仅仅分析这个瞬时状态,并不一定能确定问题,这就需要对两个或者多个快照进行对比,来确定一个增长趋势。 我们导出两份 dump 日志,分别是上个例子中循环次数分别是 10 和 100 的两份日志

对比:打开柱状图,要注意通过包来分组快速找到我们项目中对象的类

经过内存日志的对比,分析出来这个类的对象的增长,也可以辅助到问题的定位(快速增加的地方有可能存在内存泄漏)

线程视图

想要看具体的引用关系,可以通过线程视图。线程在运行中是可以作为 GC Roots 的。我们可以通过线程视图展示了线程内对象的引用关系,以及方法调用关系,相对比 jstack 获取的栈 dump,我们能够更加清晰地看到内存中具体的数据。我们找到了 AAAA-thread,依次展开找到 holder 对象,可以看到内存的泄漏点

还有另外一段是陷入无限循环,这个是相互引用导致的(进行问题排查不用被这种情况给误导了,这样的情况一般不会有问题---可达性分析算法的解决了相互引用的问题)。

柱状图视图

柱状图视图,可以看到除了对象的大小,还有类的实例个数。结合 MAT 提供的不同显示方式,往往能够直接定位问题。也可以通过正则过滤一些信息, 我们在这里输入 MAT,过滤猜测的、可能出现问题的类,可以看到,创建的这些自定义对象,不多不少正好一百个。

右键点击类,然后选择 incoming,这会列出所有的引用关系。

Path To GC Roots

被 JVM 持有的对象,如当前运行的线程对象,被 systemclass loader 加载的对象被称为 GC Roots,从一个对象到 GC Roots 的引用链被称为 Path to GC Roots,

通过分析 Path to GC Roots 可以找出 JAVA 的内存泄露问题,当程序不在访问该对象时仍存在到该对象的引用路径(这个对象可能内存泄漏)。 再次选择某个引用关系,然后选择菜单“Path To GC Roots”,即可显示到 GC Roots 的全路径。通常在排查内存泄漏的时候,会选择排除虚弱软等引用。

使用这种方式,即可在引用之间进行跳转,方便的找到所需要的信息(这里从对象反推到了线程 AAAA-thread),也可以快速定位到有内存泄漏的问题代码。

高级功能—OQL

MAT 支持一种类似于 SQL 的查询语言 OQL(Object Query Language),这个查询语言 VisualVM 工具也支持。

bash
# 查询 A 对象:
select * from ex14.ObjectsMAT$A

# 查询包含 java 字样的所有字符串:
select * from java.lang.String s wheretoString(s) like".*java.*"

OQL 有比较多的语法和用法,若想深入了解,可以了解这个网址: http://tech.novosoft-us.com/products/oql_book.htm

实战

通过程序做一份 OOM 之前的 dump 日志。

bash
java -jar -XX:+HeapDumpOnOutOfMemoryError jvm-1.0-SNAPSHOT.jar
ab -c 10 -n 1000 http://127.0.0.1:8080/jvm/mat

程序在 OOM 之前导出了 dump 日志,在这里我们就可以很明显地查看到是 ThreadLocal 这块的代码出现了问题。

原因分析

ThreadLocal 是基于 ThreadLocalMap 实现的,这个 Map 的 Entry 继承了 WeakReference,而 Entry 对象中的 key 使用了 WeakReference 封装,也就是说 Entry 中的 key 是一个弱引用类型,而弱引用类型只能存活在下次 GC 之前。

当把 threadlocal 变量置为 null 以后,没有任何强引用指向 threadlocal 实例,所以 threadlocal 将会被 gc 回收。 当发生一次垃圾回收,ThreadLocalMap 中就会出现 key 为 null 的 Entry,就没有办法访问这些 key 为 null 的 Entry 的 value,如果当前线程再迟迟不结束的话(肯定不会结束),这些 key 为 null 的 Entry 的 value 就会一直存在一条强引用链:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value,而这块 value 永远不会被访问到了,所以存在着内存泄露。

只有当前 thread 结束以后,current thread 就不会存在栈中,强引用断开,Current Thread、Map value 将全部被 GC 回收(但是这种情况很难)。最好的做法是不在需要使用 ThreadLocal 变量后,都调用它的 remove()方法,清除数据。

总结

可以看到,上手 MAT 工具是有一定门槛的,除了其操作模式,还需要对我们前面介绍的理论知识有深入的理解,比如 GC Roots、各种引用级别等。 如果不能通过大对象发现问题,则需要对快照进行深入分析。使用柱状图和支配树视图,配合引入引出和各种排序,能够对内存的使用进行整体的摸底。

由于我们能够看到内存中的具体数据,排查一些异常数据就容易得多。 上面这些问题通过分析业务代码,也不难发现其关联性。问题如果非常隐蔽,则需要使用 OQL 等语言,对问题一一排查、确认。