摘 要:
从 Java 垃圾收集的原理分析 Java 内存泄漏问题的成因,找到 Java 内存泄漏问题预防、发现、定位、解决的方法。
关键词:
Java垃圾收集 原理 内存泄漏 内存耗尽 定位 解决
垃圾收集是一种成熟的技术,早在C、C++、Java这些传统编程语言诞生以前的20世纪 60 年代早期就开始使用了,Java的垃圾收集技术也早已经成熟,但目前很多开发人员对Java的垃圾收集认识并不够,很多人对 Java 垃圾收集存在错误的认识,典型的有两种:第一种认为它是万能的,根本不会产生内存泄漏;第二种认为它是低能的,很容易产生内存泄漏。第一种想法导致一些人根本不关心内存问题,使得内存泄漏不断增加;第二种想法导致编码时大量加入清理内存的代码,使得我们的代码中充满这种冗余代码。上述错误使我们不断地付出代价,甚至某些产品的编码规范都加入了一些不合理的条款。
Java垃圾收集的原理
所有垃圾收集算法所面临的问题是相同的 ―― 找出由分配器分配的,但是用户程序不可到达的内存块,什么是不可达的内存?请看下图:
图1 可到达和不可到达的对象
在程序运行时,HEAP 中从 “Root set of references” 通过直接或间接方式引用到的对象是可达对象(白色),其它对象是不可达对象(蓝色),不可达的对象就是垃圾、需要回收。在 Java 程序中,“Root set of references”是对静态变量中或者活动线程栈上所有变量中所包含的对象的引用。简单地讲,可达对象就是从任一静态变量、任何一个线程的栈上通过直接或间接方式可以引用到的对象。
Java 的垃圾收集的实现方法有很多种,但都离不开一个最基本的方法,那就是“跟踪收集”算法,这种算法从根集开始沿着引用跟踪,直到检查了所有可到达的对象。可以在程序注册表中、每一个线程堆栈中的(基于堆栈的)局部变量中以及静态变量中找到根。从这个原理上可以看出,静态变量和线程在垃圾收集的过程中扮演了非常重要的角色。
最早的“跟踪收集”算法是由 Lisp 的发明人 John McCarthy 于 1960 年提出的“标记―清除”收集法。现在 Java 虚拟机中各种垃圾收集方法都是在“跟踪收集”算法的基础上进行效率提升后的产物,或者是做为“跟踪收集”算法的补充。
Java内存些漏
什么是 Java内存泄漏:在 C/C++ 语言中,内存泄漏通常是指再也引用不到而且没有释放的内存块,但是在 Java 虚拟机中刚好相反,恰恰是由于内存还能被引用造成了内存泄漏。在上图“可到达和不可到达的对象”中,不可达的对象都能被释放,那什么是 Java 的内存泄漏?在 Java 程序中,如果对象已经被丢弃、或者对象虽然没有被丢弃但已经对程序没有意义了,那么这些对象需要回收,当这些对象不可以回收时,就形成了内存泄漏。内存泄漏最明显的特征是某些类型的对象实例数不断增加,或者虽然某些情况下实例数减少了但总的趋势是实例数增加了。当然,某些虚拟机实现本身的内存泄漏问题也可能存在,有些 C/C++写的 JNI 代码也会产生内存泄漏,不属于Java内存泄漏的范畴,这些泄漏不在本文讨论范围。
内存耗尽问题:当 Java 虚拟机无法分配更多的内存时,用new运算符分配新内存时虚拟机会抛出java.lang.
OutOfMemoryError,这时说明 Java虚拟机内存已经耗尽。内存泄漏会引起内存耗尽,但不是所有内存耗尽问题都是由内存泄漏引起的,除了内存泄漏外,引起内存耗尽的可能性还有很多,比如某些程序本来就要使用很多内存但可用的内存不够,可以尝试在启动虚拟机时增加–Xmx参数指定虚拟机最大堆内存大小。某些情况下仅增加堆内存大小也不起作用,曾经遇到过由于代码编译方案不合理代码造成jar包大小超过200M而引起的类无法加载的问题,最后不得不用-XX:MaxPermSize 参数增加 Perm 段最大值才解决的内存耗尽问题。
内存泄漏的原因:根据上述的讨论可以得出结论,内存泄漏是由于无用对象还“可达”造成的,那为什么这些对象已经没有用了它还是“可达”的呢?从垃圾收集的原理看,只有两种可能:从线程可以引用到这些对象;或者从静态变量可以引用到这些对象;当然兼而有之的情况也会发生。如果线程本身就是无用对象,那么它必然是内存泄漏,而且这个线程对象引用的所有资源,包括成员变量、栈上的局部变量都不能释放。静态变量也会引起泄漏,当静态变量被赋值指向某些对象后,在静态变量被重新赋值前,这些对象都是可达的,当这些对象完成自己的使命后,静态变量应当被重新赋值(赋新值或置null),否则就形成了内存泄漏。
常见的内存泄漏问题:从Swing前台的开发经验看,最常见的内存泄漏问题是由异步消息线程引起的,最典型的就是关闭界面时没有关闭异步消息处理线程,这种情况占 90% 以上。由于静态变量引起内存泄漏的情况也时有发生,特别是当静态变量是容器时,经常会有些模块不断地往静态容器中加对象造成大量内存泄漏。在某些情况下,实例变量也能引起内存泄漏,曾经有人将每次打开的对话框放入一个实例变量容器中但对话框关闭后也没有从这个容器中清除引用,尽管模块关闭后内存可以释放,但在模块运行的过程中已经产生泄漏了,这种泄漏可称为暂时性内存泄漏,危害不太大。
观察gc日志, JVM参数-verbose:gc 或-Xloggc:filename可用于获取gc日志。分析思路如下:
(1) 系统已完成初始化并稳定运行;
(2) 选取一个时间段的日志,对FULL GC的日志,查看垃圾回收后的值;
(3) 以垃圾回收的值,进行内存的使用分析:
A. 如果垃圾回收后,内存持续增长, 有达到参数Xmx设置的内存的趋势,基本上是有内存泄漏问题了。
B. 如果垃圾回收后,内存增长又能回落,可以动态平衡,那么就没有问题。
市场上有多种专业检查Java内存泄漏的工具,基本工作原理大同小异,我们以开源的MAT(Memory
Analyzer Tool)工具来进行说明。MAT是基于heap dumps来进行分析的,可以使用JAVA自带的JMAP得到JAVA堆的DUMP文件,通常我们都会采用下面的“三步曲”来分析内存泄漏的问题。
第一步是找出泄露的类,导入DUMP文件后,MAT会自动解析并生成报告。在底部的Action有:(1)Histogram,列出了有每个种有多少实例,每种类型的实例集合的 shallow
size 和
retained size . shallow size指的是对象所消耗的内存大小,如每个对象引起消耗4个字节,或者8个字节,取决于你的操作系统(32位,还是64位), retained size的概念依赖于Retained set 的概念,Retained set 指的是当对象X被回收时,所有被垃圾回收器移除的对象集合, Retained size 即是Retained set所保持的内存大小。当然histogram 不仅可以通过类进行数据组织,还可以通过class loader, packages or superclass .来进行数据的组织。(2)Dominator Tree,列出了堆中最大的对象,第二层级的节点表示当被第一层级的节点所引用到的对象,当第一层级对象被回收时,这些对象也将被回收。这个工具可以帮助我们定位对象间的引用情况,垃圾回收时候的引用依赖关系。
Histogram视图和Dominator Tree视图的角度不同,前者是基于类的角度,后者是基于对象实例的角度,并且可以更方便的看出其引用关系。
图2 MAT分析报告
第二步是找出实例被谁引用无法释放。在两个视图中找出疑似溢出的对象或者类(可以通过Retained Heap排序,并且可以在Class Name中输入正则表达式的关键词只显示指定的类名),然后右键选择Path To GC Roots(Histogram中没有此项)或Merge Shortest Paths to GC Roots,然后选择exclude all phantom/weak/soft etc. reference:
图3 GC Roots分析
GC Roots意为GC根节点,后面的exclude
all phantom/weak/soft etc. reference意思是排除虚引用、弱引用和软引用,即只剩下强引用,因为除了强引用之外,其他的引用都可以被JVM GC掉,如果一个对象始终无法被GC,就说明有强引用存在,从而导致在GC的过程中一直得不到回收,最终就内存溢出了。
第三步要做的就是根据代码分析为什么对象一直被它引用,这一步没有工具帮忙需要自己分析,通过上面分析可以很方便的定位到具体的代码,然后分析是什么原因无法释放该对象,是否有静态变量或线程,找到这个原因后问题就定位了。
单纯处理泄漏问题可以斩断引用链中的任何一个环节,但是只有从最根部清除引用才能从根本上解决泄漏问题。由静态变量引用造成的泄漏,处理方法分两种情况:
*静态变量没有用了,要将静态变量置null。
*静态变量是有用的,但引用链中的某个环节以后的对象没有用了,则需要从这个环节中清除引用,可以将这个环节中对应的变量置null。
由线程引起的内存泄漏处理方法也类似:
*当线程是无用线程时,直接将线程关闭即可。
*当线程还是有用的时,但引用链中的某个环节以后的对象没有用了,则需要从这个环节中清除引用,可以将这个环节中对应的变量置null。
一句话,就是要将指向第一级无用对象的引用清除。通常,处理一处内存泄漏问题仅需一两行代码即可搞定,不过要确定这几行代码在哪里写、以及怎么写都是比较费劲的,真是“两句三年得,一吟双泪流”。也可以这么认为,每处超过 10 行、甚至几百代码量的内存泄漏处理代码是失败的,很可能是不彻底的。在多数情况下(但不是绝对),这几行代码可以加在 GUI 关闭事件处理函数中、模块关闭事件处理函数中。
首先要发现内存泄漏,建议系统测试阶段后期使用MAT做内存泄漏检测;然后是定位泄漏的根因,只有定位到了根因才能从根本上解决问题,如果定位报告中没有描述线程或静态变量那么肯定没有定位完全;最后才是修改代码处理泄漏问题,不要在定位到根因前修改代码。
成功处理内存泄漏问题的前提条件是理解好Java垃圾收集的原理(线程和静态变量)、关键是定位出问题的根本原因、代码只要一两行,秘诀就是:三个步骤定位问题、两个标志确定根因、一行代码解决泄漏。
从设计上防止内存泄漏:前面讲了那么多,都是围绕线程、静态变量开展的,要防止内存泄漏,在设计时要考虑减少线程和静态变量(特别是静态容器)的使用,当这两个方面都没有用到时,绝对不会发生内存泄漏。在Swing中,减少异步消息线程使用可以减少很多泄漏的可能性。在必须使用线程时,需要在设计时考虑线程启动、关闭的时机,比如可以由框架组件封装线程的启动、关闭的算法,业务仅需实现线程的运行实体,当大量的业务模块不必关心线程启动、关闭时,由线程引起的泄漏就会大大减少。
编码时防止内存泄漏:模块退出时一定不要忘记关闭线程、清除静态引用,如果线程的生命周期很短,注意不要在run()函数中使用while(true)这种永久性的循环。设计GUI界面时,注意要将各种窗口(包括对话框)的defaultCloseOperation属性设置为 DISPOSE_ON_CLOSE,有人可能会有意见,因为某些窗口可以hide(),不过99%的窗口都是关闭了就没用了,建议对话框如果不这样设计则先拿出来晒一晒,评审一下看有没有必要。在编写GUI代码时,注意不要破环AWT/Swing 的事件处理流程,比如避免直接覆写事件处理函数,建议处理事件时通过增加监听器实现,当不得不覆写事件处理函数时,注意要super一下。编码过程中也要注意预防暂时性内存泄漏,编码时对引用的处理要尽量严谨,只在必要时才将引用传递给其它对象,特别是要在容器中保存对象时要慎之又慎(不管是静态容器还是非静态容器),应当尽量避免容器中保存了没用的对象。
盲目导致忙碌,置空不如治本。内存释放不困难,找出根因是关键。绝大多数置空代码、remove代码不能从根本上解决内存泄漏问题,有些代码还会引起泄漏。成功处理内存泄漏问题的前提条件是理解好Java垃圾收集的原理(线程和静态变量)、关键是定位出问题的根本原因、代码只要一两行,秘诀就是:三个步骤定位问题、两个标志确定根因、一行代码解决泄漏。
参考文献
1、 林胜利,王坤茹,《Java优化编程(第2版)》,2007年7月1日
2、 陶召胜,JAVA问题定位跟踪技术,51cto,2011年9月9日
3、 洪丽娟,Java内存泄漏发现技术研究,南京航空航天大学,2015年
4、 Jet Ma,使用MAT的Histogram和Dominator Tree定位溢出源,爪哇堂,2017年11月8日