处理Java程序的内存泄漏Jim Patrick (patrickj@us.ibm.com)
Advisory Programmer, IBM Pervasive Computing
February 1, 2001
原文:http://www-106.ibm.com/developerworks/library/j-leaks/index.html
翻译:QQ 391741041(Puff)
Java程序会产生内存泄露(Memory Leak)吗?绝对会。
事实并不象很多人认为的那样,在java编程中内存管理仍然是一个需要考虑的问题。本文讨论了java内存泄露产生的原因,以及我们什么时候要注意内存泄露。希望你可以根据本文快速解决自己项目中的内存泄露问题。
1. 在Java程序中怎么才表明有内存泄露大多数的程序员以为:用Java这样的语言编程的一个好处就是无需再考虑内存的分配和释放。你可以很容易的产生对象,然后Java通过一种叫做垃圾收集(garbage collection)的机制来处理这些对象,也就是说:当它们不再被应用程序需要的时候来自动的消除这些对象。这个过程意味着Java解决了其它语言中及难处理的问题──可怕的内存泄露。是这样吗?
在我们深入探讨这个问题之前,让我们先看一下垃圾收集的工作原理。垃圾收集器(garbage collector)的工作就是寻找那些不再被应用程序需要的对象,当它们不会再被访问或引用的时候消除它们。垃圾收集器从一些根节点开始(根节点就是贯穿整个Java应用程序生命周期的类)扫描所有被它们所引用的节点。它会记住那些确实被它经过的节点所引用的对象。任何不再被引用的类,都会成为垃圾收集的目标。当这些对象被删除之后,它们所占用的内存资源就会归还给JVM(Java Virtual Machine ,Java虚拟机)。
所以说:Java编程的确不需要程序员再管理内存的清理,因为垃圾收集器会自动的收集那些无用的对象。然而,我们需要明确的极为关键的一点是:一个对象只有当它不再被引用的时候才会被当作是无用的。图1说明了这个概念。
图1. 无用但是仍然被引用
上图有两个类:类A和类B,它们在Java应用程序执行过程中有着不同生命周期。类A首先被实例化,并且存在很长时间或者存在于整个程序的生命周期。在其中某一点,类B被创建,然后类A添加了一个引用给这个新生成的类。现在我们假定类B只是用户界面的小部件,它只会显示一下然后就会被用户所关闭。尽管这时类B不再需要,但是由于在类A中仍然有类B的引用,即使下一次的垃圾收集执行过后,类B仍将会继续存在并占据着内存。
2. 怎样发现内存泄露问题如果你的程序在运行了一段时间之后出现了一个java.lang.OutOfMemoryError的异常,那就很可能是有内存泄露了。除了这种比较显而易见的情况以外,怎样才能发现有内存泄露问题呢?一个完美主义的程序员会说:“找到所有引起内存泄露的地方,然后修正之”。然而,还有其它几种方法,可以知道包括程序的生命周期和泄露的内存大小。
在一个应用程序的生命周期中,垃圾收集器有可能一次都不会被执行。并不能保证JVM是否会调用垃圾收集器或者调用的时间——即使程序显式的调用System.gc()。典型的情况是,在一个程序需要的内存大于当前可用内存的时候,垃圾收集器才会一定被执行。这个时候,JVM首先通过调用垃圾收集器试图获得更多的可用内存,如果通过这种手段仍然不能得到足够多的可用内存,JVM将从操作系统来得到更多的内存,直至到达所能允许的最大值。
举例如下:有一个小的Java应用程序用来显示一些简单的进行配置修改的用户界面,它有内存泄露的问题。碰巧一直到这个程序运行结束,垃圾收集器也没有被调用一次,因为JVM可能还有很多的内存来生成程序中所有对象需要的内存。因此,即使一些死的对象在程序执行过程中占据着内存,它对实际的程序运行并无影响。
如果开发的Java代码是用来一天24小时的运行在服务器端,则内存泄露会成为非常重要的问题。即使很小的泄露,意味着会持续的泄露直到JVM耗尽了它所有的可用内存。
即使应用程序的运行时间相对比较短,如果代码中生成了大量的临时对象(或者不多的对象占据了大量的内存),而这些对象不再被需要时没有对它们去引用(de-reference),也有可能达到内存的上限。
最后需要考虑的问题是:Java中的内存泄露和其它语言(如C++)的不同。C++中泄露的内存永远不能返回给操作系统了,而在Java应用程序中,无用的对象所占据的内存资源是来自于JVM,而JVM的内存由操作系统分配。也就是说,一旦Java应用程序和JVM被关闭,所有已分配的内存将会还给操作系统。
3. 判断应用程序是否有内存泄露如果你的Java应用程序运行在Windows NT平台上,你可以通过打开任务管理器来观察正在运行的应用程序的内存占用情况来判断是否有内存泄露。然而,在观察了一些运行中的Java应用程序后,你会发现它们占用的内存比Windows自带的应用程序多。我的一些Java程序一开始就会占用10到20MB的系统内存,而在系统中的应用程序,如Windows Explorer,只占用大概5MB。
还有一件要注意的事情就是运行在JDK1.1.8 JVM上的程序,在运行期间好像会不断的占用越来越多的内存。程序似乎从不会把任何内存资源返回给系统,最后这个程序占用了很大一块物理内存。这个问题算内存泄露吗?
为了可以更深入的理解这个问题,我们需要熟悉JVM的堆(heap)怎么使用系统内存。当运行java.exe时,你可以用指定的选项控制垃圾收集堆的初始大小和最大值(分别是:-ms和-mx)。Sun JDK 1.1.8默认使用1MB的初始值和16MB的最大值。IBM JDK 1.1.8默认的最大值是物理内存的一半。当JVM用完分配给它的内存时,这些内存设置对JVM的动作有着直接的影响。JVM更可能会一直增加堆的大小,而不是等待一个垃圾收集开始。
为了发现并最终杜绝内存泄露,我们需要比任务管理器更好的工具。内存调试工具(见附录:资源)可以帮助你查找内存泄露。这些程序一般都会告诉你堆中对象的数量信息,每个对象有多少实例,有多少内存被这些对象占用。此外,它们还会提供有用的视图展示每个对象的引用和引用者,使得你可以追踪内存泄露的源头。
接下来,我将通过例子说明怎样通过Sitraka Software的JProbe来查找和去除内存泄露的问题,以及怎么这种工具配置和查找内存泄露的过程。
4. 一个内存泄露的例子
这个例子中的Java应用程序是我们部门开发的一个商业软件,一个测试人员发现它在JDK1.1.8运行几个小时后有这个问题。这个应用程序的底层代码和包是由几个开发组开发的。我怀疑写其中的引起内存泄露的代码的人,并不真正理解他所写的代码。
这个Java程序可能会被用户用在掌上PDA(Personal Digital Assistant)。通过图形界面,用户可以打开新窗口,通过控制来组合它们或者产生Palm应用。测试人员发现当他不停的打开和关闭窗口时,Java应用程序最终耗尽了内存;与此同时,开发人员并没有发现这个问题,因为他们的机器有更多的物理内存。
我决定通过JProbe来看看问题到底出在哪里。尽管JProbe提供了强大的工具和内存快照(snap),查找问题的过程仍然是极其耗时而枯燥的,总是重复着同样的过程:先找有问题的代码,修改,再测试。
JProbe有几个选项,用来控制在调试过程中要记录什么信息。经过一些试验之后,我发现得到所需信息的最为有效的方法是:关闭“性能数据收集”( performance data collection)的功能,而把目光集中在关于堆的数据上。JProbe提供了一种称之为Runtime Heap Summary(运行时堆概况)的视图,可以显示Java应用程序运行时堆的使用情况。JProbe还提供了按钮使你可以在需要的时候强制运行垃圾收集,这个功能在下面这种情况下非常有用:想看看某个类的某个实例在无用之后是否会被垃圾收集器收集。图2显示了堆的使用情况。
图 2. Runtime Heap Summary
图中所示上方的Heap Usage Chart(堆使用图)中,蓝色的部分表示已经分配出去的堆空间。运行Java程序并达到一个稳定点后,我强制垃圾收集器运行,这时蓝色的部分有了一个突降(在那条绿的垂直线前,这条绿线所在的位置表示一个插入的检查点)。接下来,我关闭了4个窗口之后又运行了垃圾收集器。结果是蓝色区域比检查点(在这点程序回到只有一个可见窗口的起始状态)之前还要高,这是有内存泄露的特征。我通过观察Instance Summary(实例概况)确定了泄露是由于:FormFrame类(主用户界面的类)的实例在检查点后增加了4个。
5. 发现原因为了能找到那些使得垃圾收集器不能正常的工作的引用,我使用JProbe 的Reference Graph (引用图,见图3)来寻找哪个类仍然引用了已经删除的类FormFrame。这个过程需要非常的细心,因为我发现很多不同的对象都引用过这个对象。通过反复的试验来找到谁是真正使得问题发生的引用者,这是个是很耗时的过程。
在本例中,一个根类(root class,左上角的红色长条)是产生问题的罪魁祸首。而右边的那个蓝色的类可以沿着路径追踪到初始的FormFrame类。
Figure 3. Tracing a memory leak in a reference graph
在这个例子中,真正的罪魁祸首是一个字体管理者类,这个类中有一个静态的hashtable。在追踪了一系列引用者后,我发现根节点是一个静态hashtable,其中存储了每个窗口中的字体。不同的窗口可以独立的放大或缩小,因此这个hashtable 中有一个vector,其中含有所有的字体。当窗口的大小改变时,取出含有字体的vector和适当的缩放因子来应用到字体大小上。
这个字体管理者类的问题是:窗口创建时,代码将字体vector放入hashtable,而删除窗口时并不会删掉hashtable。因此这个静态的hashtable将会在这个应用的生命周期内一直存在,而它也没有删掉其中引用了每个窗口的那些key。最终的结果是:窗口和它所有附属的类都留在内存里。
6. 加以改正最简单的解决方案就是给字体管理者类加一个方法removeKeyFromHashtables(),这个方法用到了hashtable类中的remove()方法。当窗口被用户关掉时调用这个方法。下面是这个方法的代码:
public void removeKeyFromHashtables(GraphCanvas graph) {
if (graph != null) {
viewFontTable.remove(graph); // remove key from hashtable
// to prevent memory leak
}
}
接下来我在FormFrame 类的代码中调用了这个方法。FormFrame 使用了Swing的internal frame来实现。所以这个方法是当一个internal frame被完全关闭时调用的:
/**
* Invoked when a FormFrame is disposed. Clean out references to prevent
* memory leaks.
*/
public void internalFrameClosed(InternalFrameEvent e) {
FontManager.get().removeKeyFromHashtables(canvas);
canvas = null;
setDesktopIcon(null);
}
在我对进行了这些代码的修改之后,我在相同的测试条件下,对这些应该被删除的对象进行了检验。
7. 杜绝内存泄露问题通过对一些大家都容易出的问题的观察,你可以杜绝内存泄露的问题。 Collection类,比如hashtable和vector,是最容易导致内存泄露的问题的,尤其是当这个类被声明为static的并存在于整个程序的生命周期时。
另一个容易发生内存泄露的地方是:当你注册(register)一个类作为一个event listener,而当这个类不再被需要时你没有unregister。
还有一个要注意的是一个类中指向其它类的成员变量,它们的值在适合的时候要设为null。
8. 总结发现内存泄露的过程可能是个枯燥而漫长的过程,而且还需要专用的调试工具。然而,一旦当你熟悉了这些工具,并且也熟悉了寻找追踪对象引用的方法后,你就可以找到引起内存泄露的地方。此外,你也会得到这方面有用的经验,不但对于当前的项目有益,并且对你以后在编程过程中避免这类问题也大有裨益。
附录:资源
Sitraka Software's : JProbe Profiler with Memory Debugger http://www.sitraka.com/software/jprobe/jprobedebugger.html Intuitive System's: Optimizeit Java Performance Profiler http://www.optimizeit.com/ Paul Moeller's : Win32 Java Heap Inspector http://www.geocities.com/moellep/debug/HeapInspector.html IBM alphaWorks' : Jinsight http://www.alphaworks.ibm.com/tech/jinsight作者介绍:
Jim Patrick is an Advisory Programmer in IBM's Pervasive Computing Division. He has been programming in Java since 1996. Contact Jim at patrickj@us.ibm.com.