处理 Java 程序中的内存泄漏

91 阅读12分钟

处理 Java 程序中的内存泄漏

了解内存泄漏何时是一个问题以及如何防止它们内存泄漏如何在 Java 程序中表现出来

吉姆·帕特里克 (Jim Patrick) 于 2001 年 2 月 1 日发布


内存泄漏如何在 Java 程序中表现出来

大多数程序员都知道,使用 Java™ 等编程语言的好处之一是他们不再需要担心分配和释放内存。您只需创建对象,当应用程序不再需要它们时,Java 会通过称为垃圾收集的机制来删除它们。这个过程意味着 Java 已经解决了困扰其他编程语言的一个棘手问题——可怕的内存泄漏。或者有吗?

在深入讨论之前,让我们先回顾一下垃圾收集的实际工作原理。垃圾收集器的工作是找到应用程序不再需要的对象,并在无法再访问或引用它们时将其删除。垃圾收集器从根节点(在 Java 应用程序的整个生命周期中持续存在的类)开始,并扫描所有引用的节点。当它遍历节点时,它会跟踪哪些对象正在被主动引用。任何不再被引用的类都有资格被垃圾收集。当这些对象被删除时,这些对象使用的内存资源可以归还给Java虚拟机(JVM)。

因此,Java 代码确实不需要程序员负责内存管理清理,并且它会自动垃圾收集未使用的对象。然而,要记住的关键点是,一个对象只有在不再被引用时才算作未使用。图 1 说明了这个概念。

图 1. 未使用但仍被引用

未使用但仍被引用的对象

该图说明了在 Java 应用程序执行期间具有不同生命周期的两个类。类A首先被实例化,并在程序的整个生命周期内存在很长时间。在某个时刻,B创建了类,并且类A添加了对这个新创建的类的引用。现在让我们假设 classB是一些用户界面小部件,它被用户显示并最终关闭。即使B不再需要class ,如果 classA对 class的引用B没有清除,即使在执行下一个垃圾回收周期之后,classB仍将继续存在并占用内存空间。

什么时候需要关注内存泄漏?

如果您的程序java.lang.OutOfMemoryError在执行一段时间后出现异常,则内存泄漏肯定是一个强烈的嫌疑。除了这种明显的情况之外,内存泄漏何时应该成为一个问题?完美主义程序员会回答说所有内存泄漏都需要调查和纠正。但是,在得出这个结论之前,还有其他几点需要考虑,包括程序的生命周期和泄漏的大小。

考虑在应用程序的生命周期中垃圾收集器甚至可能永远不会运行的可能性。无法保证 JVM 何时或是否会调用垃圾收集器——即使程序显式调用System.gc(). 通常,垃圾收集器不会自动运行,直到程序需要比当前可用内存更多的内存。此时,JVM 将首先尝试通过调用垃圾收集器来提供更多可用内存。如果这种尝试仍然没有释放足够的资源,那么 JVM 将从操作系统获取更多内存,直到它最终达到允许的最大值。

以一个小型 Java 应用程序为例,该应用程序显示一些用于配置修改的简单用户界面元素,并且存在内存泄漏。有可能在应用程序关闭之前甚至不会调用垃圾收集器,因为 JVM 可能有足够的内存来创建程序所需的所有对象,而剩余的内存可以备用。因此,在这种情况下,即使在执行程序时一些死对象正在占用内存,但对于所有实际目的而言,这确实无关紧要。

如果正在开发的 Java 代码打算一天 24 小时在服务器上运行,那么内存泄漏比我们的配置实用程序的情况要严重得多。即使是一些本应持续运行的代码中最小的泄漏,最终也会导致 JVM 耗尽所有可用内存。

而在程序生命周期相对较短的相反情况下,任何分配大量未取消引用的临时对象(或少数占用大量内存的对象)的 Java 代码都可以达到内存限制不再需要的时候。

最后一个考虑是内存泄漏根本不是问题。Java 内存泄漏不应被视为与其他语言(如 C++)中发生的泄漏一样危险,其中内存丢失且永远不会返回到操作系统。在 Java 应用程序的情况下,我们有不需要的对象附着在操作系统提供给 JVM 的内存资源上。因此理论上,一旦 Java 应用程序及其 JVM 关闭,所有分配的内存都将返回给操作系统。

确定应用程序是否存在内存泄漏

要查看在 Windows NT 平台上运行的 Java 应用程序是否正在泄漏内存,您可能想在应用程序运行时简单地观察任务管理器中的内存设置。但是,在观察一些运行中的 Java 应用程序后,您会发现它们与原生应用程序相比使用了大量内存。我参与过的一些 Java 项目一开始可能会使用 10 到 20 MB 的系统内存。将此数字与操作系统附带的本机 Windows 资源管理器程序进行比较,后者使用大约 5 MB 的大小。

关于 Java 应用程序内存使用的另一件事是,使用 IBM JDK 1.1.8 JVM 运行的典型程序似乎在运行时不断吞噬越来越多的系统内存。在大量物理内存分配给应用程序之前,程序似乎永远不会将任何内存返回给系统。这些情况可能是内存泄漏的迹象吗?

要了解发生了什么,我们需要熟悉 JVM 如何将系统内存用于其堆。运行时java.exe,您可以使用某些选项来控制垃圾收集堆的启动和最大大小(分别为 -ms 和 -mx)。Sun JDK 1.1.8 使用默认的 1 MB 启动设置和 16 MB 的最大设置。IBM JDK 1.1.8 使用机器总物理内存大小的二分之一的默认最大设置。这些内存设置直接影响 JVM 在内存不足时的操作。JVM 可能会继续增加堆,而不是等待垃圾收集周期完成。

因此,为了找到并最终消除内存泄漏,我们需要比任务监控实用程序更好的工具。当您尝试检测内存泄漏时,内存调试程序(参见 参考资料)会派上用场。这些程序通常会为您提供有关堆中对象数量、每个对象的实例数量以及对象正在使用的内存的信息。此外,它们还可以提供有用的视图,显示每个对象的引用和引用,以便您可以追踪内存泄漏的来源。

接下来,我将展示我如何使用 Sitraka Software 的 JProbe 调试器检测和消除内存泄漏,让您了解如何部署这些工具以及成功消除泄漏所需的过程。

内存泄漏示例

这个例子集中在一个问题,在测试人员花了几个小时处理我的部门正在开发用于商业发布的 Java JDK 1.1.8 应用程序之后,这个问题就出现了。这个 Java 应用程序的底层代码和包是由几个不同的程序员组随着时间的推移开发的。我怀疑,应用程序中出现的内存泄漏是由没有真正理解在别处开发的代码的程序员造成的。

有问题的 Java 代码允许用户为 Palm 个人数字助理创建应用程序,而无需编写任何 Palm OS 本地代码。通过使用图形界面,用户可以创建表单,用控件填充它们,然后连接来自这些控件的事件以创建 Palm 应用程序。测试人员发现,随着时间的推移,他创建和删除表单和控件时,Java 应用程序最终会耗尽内存。开发人员没有检测到问题,因为他们的机器有更多的物理内存。

为了调查这个问题,我使用 JProbe 来确定出了什么问题。即使使用 JProbe 提供的强大工具和内存快照,调查仍然是一个乏味的迭代过程,首先要确定给定内存泄漏的原因,然后更改代码并验证结果。

JProbe 有几个选项来控制在调试会话期间实际记录哪些信息。经过一些实验,我决定获取所需信息的最有效方法是关闭性能数据收集并专注于捕获的堆数据。JProbe 提供了一个名为 Runtime Heap Summary 的视图,该视图显示在 Java 应用程序运行时随时间使用的堆内存量。它还提供了一个工具栏按钮来强制 JVM 在需要时执行垃圾收集。事实证明,当 Java 应用程序不再需要某个类的给定实例时,该功能非常有用。图 2 显示了随时间使用的堆存储量。

图 2. 运行时堆摘要

运行时堆摘要

在 Heap Usage Chart 中,蓝色部分表示已分配的堆空间量。在我启动 Java 程序并达到稳定点后,我强制垃圾收集器运行,这表现为绿线之前的蓝色曲线突然下降(此线表示插入了检查点)。接下来,我添加,然后删除四个表单并再次调用垃圾收集器。检查点之后的蓝色级别区域高于检查点之前的级别蓝色区域这一事实告诉我们可能会发生内存泄漏,因为程序已经返回到只有一个可见窗体的初始状态。我通过查看 Instance Summary 确认了泄漏,这表明FormFrame该类(它是表单的主要 UI 类)在检查点之后的计数增加了 4。

查找原因

我试图隔离测试人员报告的问题的第一步是想出一些简单的、可重现的测试用例。对于这个例子,我发现简单地添加一个表单,删除该表单,然后强制进行垃圾回收会导致与被删除表单关联的许多类实例仍然存在。这个问题在 JProbe Instance Summary 视图中很明显,它计算每个 Java 类的堆上的实例数。

为了查明阻止垃圾收集器正常工作的引用,我使用了 JProbe 的参考图(如图 3 所示)来确定哪些类仍在引用现已删除的FormFrame类。这个过程被证明是调试这个问题最棘手的方面之一,因为我发现许多不同的对象仍在引用未使用的对象。找出这些引用者中的哪一个真正导致问题的反复试验过程非常耗时。

在这种情况下,根类(红色的左上角)是问题的根源。右侧以蓝色突出显示的类沿着从原始类追踪的路径FormFrame

图 3. 跟踪参考图中的内存泄漏

跟踪参考图中的内存泄漏

对于这个特定示例,结果证明主要罪魁祸首是包含静态哈希表的字体管理器类。追溯引用者列表后,我发现根节点是一个静态哈希表,用于存储每个表单使用的字体。各种表格可以独立放大或缩小,因此哈希表包含一个向量,其中包含给定表格的所有字体。当表单的缩放视图更改时,获取字体向量并将适当的缩放系数应用于字体大小。

这个字体管理器类的问题是,虽然代码在创建表单时将字体向量放入哈希表中,但没有规定在删除表单时删除向量。因此,这个静态哈希表本质上存在于应用程序本身的生命周期中,永远不会删除引用每个表单的键。因此,表单及其所有关联的类都留在内存中。

应用修复

这个问题的简单解决方案是向字体管理器类添加一个方法,remove()当用户删除表单时,该方法允许使用适当的键调用哈希表的方法。该removeKeyFromHashtables()方法如下所示:

{:#}
public void removeKeyFromHashtables(GraphCanvas graph) {
  if (graph != null) {
    viewFontTable.remove(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) {
  FontManager.get().removeKeyFromHashtables(canvas);
  canvas = null;
  setDesktopIcon(null);
}

展示更多

在我进行这些代码更改后,我使用调试器来验证在执行相同的测试用例时与已删除表单关联的对象计数是否减少。

防止内存泄漏

您可以通过观察一些常见问题来防止内存泄漏。集合类(例如哈希表和向量)是查找内存泄漏原因的常见位置。如果类已经声明并且在应用程序的生命周期内存在,则尤其如此static

当您将一个类注册为事件侦听器而不在不再需要该类时取消注册时,会出现另一个常见问题。此外,很多时候指向其他类的类的成员变量只需要在适当的时候设置为 null。

结论

查找内存泄漏的原因可能是一个乏味的过程,更不用说需要特殊调试工具的过程了。但是,一旦您熟悉了在跟踪对象引用时要查找的工具和模式,您将能够跟踪内存泄漏。此外,您将获得一些宝贵的技能,这些技能不仅可以节省编程项目,还可以深入了解要避免哪些编码实践以防止未来项目中的内存泄漏。