我们前面讲过,我们可以使用 jmap –histo 这种命令去分析哪些对象占据着我们的堆空间。但是那是比较容易分析的问题,如果是遇到内存情况比较复杂的情况,命令的方式是看不出来的,这个时候我们必须要借助一下工具。当然前提是通过 jmap 命令把整个堆内存的数据 dump 下来。
内存分析工具
VisualVM
这个是上节的那个案例抛出了 OOM 后到处的内存的 dump 日志信息,我们可以导入。
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 对象的引出
案例解释理解 Incoming/Outgoing References
代码中对象和引用关系如下:
对象 A 和对象 B 持有对象 C 的引用
对象 C 持有对象 D 和对象 E 的引用
我们具体分析对象 C 的 Incoming references 和 Outgoing references 。
1、 程序跑起来:
2、 MAT 连接上(MAT 不单单只打开 dump 日志,也可以打开正在运行的 JVM 进程,跟 arthas 有点类似,效果是一样的,只是一个是动态的,一个是日 志导出那个时刻的):
对象 C 的 incoming references 为对象 A、对象 B 和 C 的类对象(class)。
我们再来分析下 outgoing reference
对象 C 的 outgoing references 为对象 D、对象 E 和 C 的类对象(class)。
这个 outgoing references 和 incoming references 非常有用,因为我们做 MAT 分析一般时对代码不了解,排查内存泄漏也好,排查问题也好,垃圾回收中有一个很重要的概念,可达性分析算法,那么根据这个引入和引出,我就可以知道这些对象的引用关系,在 MAT 中我们就可以知道比如 A,B,C,D,E,F 之间的引用关系图,便于做具体问题的分析。
MAT 中的浅堆与深堆
浅堆(shallow heap)代表了对象本身的内存占用,包括对象自身的内存占用,以及“为了引用”其他对象所占用的内存。
深堆(Retained heap)是一个统计结果,会循环计算引用的具体对象所占用的内存。但是深堆和“对象大小”有一点不同,深堆指的是一个对象被垃圾回收后,能够释放的内存大小,这些被释放的对象集合,叫做保留集(Retained Set)。
需要说明一下:JAVA 对象大小=对象头+实例数据+对齐填充;
非数组类型的对象的 shallow heap:
shallow_size=对象头+各成员变量大小之和+对齐填充;
其中,各成员变量大小之和就是实例数据,如果存在继承的情况,需要包括父类成员变量;
数组类型的对象的 shallow size:
shallow size=对象头+类型变量大小*数组长度+对齐填充,如果是引用类型,则是四字节或者八字节(64 位系统);
如果是 boolean 类型,则是一个字节;
注意:这里 类型变量大小*数组长度 就是实例数据,强调是变量不是对象本身。
案例分析
对象 A 持有对象 B 和 C 的引用。
对象 B 持有对象 D 和 E 的引用。
对象 C 持有对象 F 和 G 的引用。
Shallow Heap 大小
请记住:对象的 Shallow heap 是其自身在内存中的大小。
引用变动的影响
在下面的示例中,让对象 H 开始持有对 B 的引用。注意对象 B 已经被对象 A 引用了。
在这种情况下,对象 A 的 Retained heap 大小将从之前的 70 减小到 40 个字节。 如果对象 A 被垃圾回收了,则将仅会影响 C、F 和 G 对象的引用。因此,仅对象 C、F 和 G 将被垃圾回收。另一方面,由于 H 持有对 B 的活动引用,因此对象 B、D 和 E 将继续存在于内存中。
因此,即使 A 被垃圾回收,B、D 和 E 也不会从内存中删除。因此,A 的 Retained heap 大小为:= A的 shallow heap 大小 + C 的 shallow heap 大小 + F 的 shallow heap 大小 + G 的 shallow heap 大小 = 10 bytes + 10 bytes + 10 bytes + 10 bytes = 40。
bytes. 总结:我们可以看到在进行内存分析时,浅堆和深堆是两个非常重要的概念,尤其是深堆,影响着回收这个对象能够带来的垃圾回收的效果,所以在内存分析中,我们往往会去找那些深堆比较的大的对象,尤其是那些浅堆比较小但深堆比较大的对象,这些对象极有可能是问题对象。
使用 MAT 进行内存泄漏检测
如果问题特别突出,则可以通过 Find Leaks 菜单快速找出问题。
运行以下代码, 我们开始跑程序:
这里一个名称叫做 king-thread 的线程,持有了超过 99% 的对象,数据被一个 HashMap 所持有。
这个就是内存泄漏的点,因为我代码中对线程进行了标识,所以像阿里等公司的编码规范中为什么一定要给线程取名字,这个是有依据的,如果不取名字的话,这种问题的排查将非常困难。
所以,如果是对于特别明显的内存泄漏,在这里能够帮助我们迅速定位,但通常内存泄漏问题会比较隐蔽,我们需要做更加复杂的分析。
支配树视图
支配树列出了堆中最大的对象,第二层级的节点表示当被第一层级的节点所引用到的对象,当第一层级对象被回收时,这些对象也将被回收。这个工具可以帮助我们定位对象间的引用情况,以及垃圾回收时的引用依赖关系。
支配树视图对数据进行了归类,体现了对象之间的依赖关系。我们通常会根据“深堆”进行倒序排序,可以很容易的看到占用内存比较高的几个对象,点击前面的箭头,即可一层层展开支配关系(依次找深堆明显比浅堆大的对象)。
从上图层层分解,我们也知道,原来是 king-thread 的深堆和浅堆比例很多(深堆比浅堆多很多、一般经验都是找那些浅堆比较小,同时深堆比较大的对象)
1、 一个浅堆非常小的 king-thread 持有了一个非常大的深堆;
2、 这个关系来源于一个 HashMap;
3、 这个 map 中有对象 A,同时 A 中引用了 B,B 中引用了 C;
4、 最后找到 C 中里面有一个 ArrayList 引用了一个大数据的数组。
经过分析,内存的泄漏点就在此。一个线程长期持有了 200 个这样的数组,有可能导致内存泄漏。
MAT 中内存对比
我们对于堆的快照,其实是一个“瞬时态”,有时候仅仅分析这个瞬时状态,并不一定能确定问题,这就需要对两个或者多个快照进行对比,来确定一个增长趋势。
我们导出两份 dump 日志,分别是上个例子中循环次数分别是 10 和 100 的两份日志:
对比:打开柱状图,要注意通过包来分组快速找到我们项目中对象的类。
经过内存日志的对比,分析出来这个类的对象的增长,也可以辅助到问题的定位(快速增加的地方有可能存在内存泄漏)。
线程视图
想要看具体的引用关系,可以通过线程视图。线程在运行中是可以作为 GC Roots 的。我们可以通过线程视图展示了线程内对象的引用关系,以及方法调用关系,相对比 jstack 获取的栈 dump,我们能够更加清晰地看到内存中具体的数据。
我们找到了 king-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 的全路径。通常在排查内存泄漏的时候,会选择排除虚弱软等引用。
使用这种方式,即可在引用之间进行跳转,方便的找到所需要的信息(这里从对象反推到了线程 king-thread),也可以快速定位到有内存泄漏的问题代码。
高级功能—OQL
MAT 支持一种类似于 SQL 的查询语言 OQL(Object Query Language),这个查询语言 VisualVM 工具也支持。
查询 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 。
实战
java -jar -XX:+HeapDumpOnOutOfMemoryError jvm-1.0-SNAPSHOT.jar 。
ab -c 10 -n 1000 http://127.0.0.1:8080/jvm/mat 。
在这里我们就可以很明显地查看到是 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 等语言,对问题一一排查、确认。