Java 程序里的内存洩漏是如何表现的
大多数程序员都知道使用类似于 Java 的编程语言的好处之一就是他们无需再为内存的分配和释放所担心了你只需要简单地创建对象当它们不再为程序所需要时 Java 会自行通过一个被称为垃圾收集的机制将其移除这个过程意味着 Java 已经解决了困扰其他编程语言的一个棘手的问题 可怕的内存洩漏果真是这样的吗?
在进行深入讨论之前让我们先回顾一下垃圾收集是如何进行实际工作的垃圾收集器的工作就是找到程序不再需要的对象并在当它们不再被访问或引用时将它们移除掉垃圾收集器从贯穿整个程序生命周期的类这个根节点开始扫描所有引用到的节点在遍历节点时它跟蹤那些被活跃引用着的对象那些不再被引用的对象就满足了垃圾回收的条件当这些对象被移除时被它们占用的内存资源会交还给 Java 虚拟机(JVM)
因此 Java 代码的确不需要程序员负责内存管理的清理工作它自行对不再使用的对象进行垃圾收集然而需要记住的是垃圾收集的关键在于一个对象在不再被引用时才被统计为不再使用下图对这一概念进行了说明
上图表示在一个 Java 程序执行时具有不同的生命周期的两个类类 A 首先被实例化它存在的时间比较长几乎贯穿整个进程的生命周期在某个时间点类 B 被创建类 A 添加了一个对这个新建类的引用我们假设类 B 是某个用于显示并返回用户指令的用户界面部件尽管类 B 不再被使用如果类 A 对类 B 的引用未被清除类 B 将继续存在并占据内存空间即使下一次垃圾收集被执行
什么时候需要注意内存洩漏?
如果在你的程序执行一段时间之后遇到 javalangOutOfMemoryError 的话内存洩漏无疑是最值得怀疑的除了这种明显的情况之外什么时候需要考虑内存洩漏?完美主义的程序员会回答说所有的内存洩漏都需要进行审查和更改然而在跳到这一结论之前还需要考虑其他几点因素包括程序的生命周期以及内存洩漏的大小
考虑一下在一个程序的生命周期里垃圾收集器可能从未执行的情况无法保证什么时候 JVM 会调用垃圾收集 即使程序显式调用 Systemgc()通常情况下垃圾收集器不会自动运行直到程序需要比目前可用内存还要多的内存此时JVM 会首先尝试调用垃圾收集器以获取更多可用内存如果这个尝试仍旧不能够释放出足够的资源JVM 将会从操作系统获取更多内存直到达到所允许内存的最大值
举个例子来说一个小型的 Java 应用程序用来显示一些简单的配置修改的用户界面元素出现了内存洩漏垃圾收集器可能在程序关闭之前都不会被调用到因为 JVM 可能总是有足够的内存来创建程序所需要的所有对象因此在这种情况下即便是一些已死对象在程序运行的时候仍旧占据着内存但这并不影响实际应用
如果开发中的 Java 代码将以每天 小时运行在服务器上这时内存洩漏将会比上面的那个配置工具程序要明显的多了即便是代码中最小的内存洩漏在持续运行的情况下最终也将耗尽所有可用内存
相反的情况下即使一个程序只是短暂存活却分配了大量临时对象(或者少量的占用大量内存的对象)在这些对象不再需要时没有取消引用这样的 Java 代码也会达到内存限制
最后一个值得注意的问题是不必过于担心(Java 程序所造成的)内存洩漏Java 内存洩漏不应该被认为是像其他语言中所发生的那样危险比如 C++ 的内存丢失将永远不会返回给操作系统Java 应用程序中我们把不再需要的却占据着内存资源的对象都交给 JVM所以在理论上来说一旦 Java 程序和它的 JVM 关闭掉所有分配的内存都将归还给操作系统
如何断定程序具有内存洩漏
查看一个运行在 Windows NT 平台上的 Java 程序是否具有内存洩漏你可以简单地在程序运行的时候去观察任务管理器中的内存设置然而在观察一些运行中的 Java 程序之后你会发现它们跟本地应用程序相比使用更多内存我开发过的一些 Java 项目会启用 到 MB 的系统内存与这个数字相比本地的操作系统自带的 Windows Explorer 程序使用到 MB
另外一个关于 Java 程序的内存使用要注意的是典型的运行在 IBM JDK JVM 上的程序似乎在其运行时不断吞噬了越来越多的系统内存程序似乎永远不会返回一些内存给操作系统直到一个非常大的物理内存分配给它这会不会就是内存洩漏的迹象?
要明白是怎么回事我们需要熟悉 JVM 是如何将系统内存使用作自己的堆的在运行 javaexe 时你可以使用一些特定的选项来控制垃圾收集的堆的启动容量和最大容量(分别是 ms 和 mx)Sun 的 JDK 默认使用 MB 的启动设置和 MB 的最大设置IBM JDK 默认使用机器物理内存容量的一半作为最大设置这些内存设置对 JVM 发生内存溢出时的做法具有直接影响这时 JVM 可能会继续增长堆内存而不是等待一个垃圾回收的结束
因此为了寻找并最终消除内存洩漏我们需要比任务监视程序更好的工具当你想检测内存洩漏的时候内存调试程序(参见下文的参考资料)可以派上用场了这些程序通常会给你关于堆内存里对象的数量每个对象实例的个数以及对象使用中的内存等一些信息此外它们还会提供很有用的视图这些视图可以显示每个对象的引用和引用者以便你跟蹤内存漏洞的来源
接下来我将展示如何使用 Sitraka Software 的 JProbe 调试工具来检测和消除内存洩漏希望会对你就如何部署这些工具并成功消除内存洩漏产生一些启发
一个内存洩漏的例子
这个示例主要展示了我们部门开发的一个商业版应用的一个问题这个问题在 JDK 上工作了几个小时后被测试人员找出来这个 Java 应用程序的相关代码和包是由几个不同团队的程序员开发出来的程序里出现的内存洩漏的原因我怀疑是由一些没有真正理解其他(团队)开发的代码的程序员所引起讨论中的 Java 代码允许用户不必去写 Palm OS 本地代码来创建 Palm 个人数码助理应用通过使用图形界面用户可以创建表单使用控件对它们进行填充然后连接控件事件来创建 Palm 应用程序测试人员发现这个 Java 应用最终发生了内存溢出表单和控件的创建和删除延时开发人员并没有发现这个问题存在因为他们的机器(相对 Palm)拥有着更多的物理内存
为了讨论这个问题我使用了 JProbe 来断定问题的存在即使拥有 JProde 提供的强大工具和内存快照调查仍然是一个繁琐的反复的过程它涉及先确定内存洩漏的原因然后做出代码更改并验证其效果
JProbe 有几个选项来控制在一次调试回话期间什么样的信息会被记录经过一些试验后我判定获取所需信息的最有效的方式是关掉性能数据收集专注于捕获的堆数据JProbe 提供了一个叫做运行时堆摘要的视图来显示 Java 应用程序在一段时间内使用的堆内存的数量它同时也提供了一个工具栏按钮用来在需要时强制 JVM 执行垃圾收集 在想要看一下一个类的给定实例不再为 Java 应用程序需要时是否会被垃圾收集这个功能是很有用的下图显示了在一段时间内使用的堆存储量
在堆使用情况图中蓝色部分表示已分配的堆空间量我启动 Java 程序之后它达到了一个稳定点我强制垃圾收集器执行这由绿线之前的蓝色曲线的一个骤降表示(这条绿线表示一个检查点被插入)接下来我先是添加而后删掉了四个表单并再次调用垃圾收集器检查点之后的蓝色曲线的水平线比检查点之前的蓝色曲线的水平线高的事实告诉我们很可能出现了内存洩漏因为该程序已经回归其只有一个简单可见的表单的初始状态我检查实例确认了洩漏总之结果表明 FormFrame 类(表单的主 UI 类)的数量在检查点之后增加了四个
寻找原因
要想将测试人员提交的问题隔离出来第一步就是提供一些简单的重复的测试用例以上面那个例子为例我发现简单地添加一个表单删除这个表单然后强制垃圾收集器的结果是一些关联到已经删除掉的表单的实例仍然存活着这种问题通过 JProbe实例摘要视图来看是显而易见的视图中统计了堆内存中每个类的实例的个数
要定位垃圾收集器工作时具体实例的引用我使用了 JProbe 的引用画面如下图所示来断定哪些类仍然在引用已被删除掉的 FormFrame 类这是调试这种问题的巧妙地方法之一我通过它发现了很多不同的对象仍然在引用那些无用的对象而通过试错来查明究竟是哪个引用者真正造成这个问题的过程却是相当耗时的
在这个案例中根类(左上角红色的那个)是出现问题的起源右侧用蓝色突出的那个类就是追蹤到的 FormFrame 类
对于这个具体的例子找到的罪魁祸首是一个包含一个静态的哈希表的字体管理类通过引用列表追蹤后我发现根节点是一个静态的哈希表这个哈希表保存了每个表单使用的字体各种表单可以被独立地放大或缩小所以哈希表包含了一个具有每个指定的表单的所有字体的向量当表单的缩放视图改变时带有字体的向量被获取并选择合适的缩放因素来适应字体大小
这个字体管理器的问题是在创建表单时当代码将字体向量放进哈希表时却没有定义表单删除时对向量的移除因此这个在整个应用程序的生命周期都存在的静态的哈希表却从来没有移除指向每个表单的键值所以所有的表单和其相关联的类被遗留在了内存中
问题修正
对于这个问题的简单解决方案就是字体管理器增加一个方法来允许哈希表的 remove() 方法会在用户删除表单时被调用到增加的 removeKeyFromHashtables() 方法如下所示
public void removeKeyFromHashtables(GraphCanvas graph) {
if (graph != null) {
viewFontTableremove(graph) // remove key from hashtable
// to prevent memory leak
}
}
然后我在 FormFrame 类里添加了对这个方法的一个调用FormFrame 使用 Swing 的内部框架来实现表单 UI因此对于字体管理器的调用被添加到当内部框架完全关闭时所执行的方法如下所示
/**
* Invoked when a FormFrame is disposed Clean out references to prevent
* memory leaks
*/
public void internalFrameClosed(InternalFrameEvent e) {
FontManagerget()removeKeyFromHashtables(canvas)
canvas = null;
setDesktopIcon(null)
}
在我对代码做出修改以后我使用调试工具来确认在相同的测试用例被执行时删除表单所关联到的对象的数目
内存洩漏的防止
可以通过对一些常见问题的注意来防止内存洩漏容器类比如哈希表和向量是找到引起内存洩漏的常见的地方尤其是当这些类被声明为静态的并存活于应用程序的整个生命周期之中时
另一个常见(导致内存洩漏的)问题是当你将一个类注册为事件监听器却没考虑到当这个类不再需要时将其注销还有指向其他类的成员变量在恰当的时候要设置为 null
结束语
寻找内存洩漏的原因可能是一个繁琐的过程还没有提到的一点是这将需要特殊的调试工具然而一旦你熟悉了追蹤对象引用的工具和模式你将能够跟蹤内存洩漏此外你还会获得一些有价值的技能不仅可以节省项目编程投入而且在以后的项目中你将拥有找出可以防止发生内存洩漏的编程做法的眼光