Java 程序中的内存泄漏呢?难道 Java 虚拟机( JVM )的垃圾收集器不应该管理未使用的内存吗?是的,它会进行管理,但是垃圾收集的对象只能是不再被引用的对象。" name="description" />
在大型企业系统中, Java 代码中的内存泄漏是常见而且难于解决的问题。这些泄漏问题通常是在部署之后发现的,难于在测试环境中得到重现。这是为什么呢?理由之一是,已部署的系统需要处理更大量的数据,而且有可能在执行数周之后才会发现 Java 堆在缓慢地增长。最终,这将导致系统内存耗尽。
我们怎样发现泄漏呢?市场上有很多可用的工具,但是其中大多数工具,要么是基于创建 Java 堆转储,并离线分析它,要么是基于 JVMPI ,而 JVMPI 会使应用程序的执行变得非常缓慢。在 BEA 的 JVM JRockit 1.4.2_05 版中,存在一个交互式内存泄漏检测工具的技术预览, JRockit Memory Leak Detector ,它支持在系统全速运行时使用。在本文中,我们将看一看这个试验性的工具以及它可能发生的一些事情。我们还将试着展望一下该工具未来版本的功能,并复习一下内存泄漏的一些常见类型。
Memory Leak Detector
设计 Memory Leak Detector 的目的是将其用在生产环境中,而不会给系统增加很大的性能负担。许多 JVM 内部的内存泄漏检测功能直接构建在 JRockit 垃圾收集器内,以求获得更高的性能。 JRockit 有一个管理控制台,用于监控和管理一个或多个 JVM 。内存泄漏检测工具当前被构建在 JRockit Management Console 中,但是在今年的 1.5 版之后的 JRockit JDK 中,可以把它当作一个单机工具来使用。其思路是,系统将帮助开发人员理解三件事情:是否有内存泄漏,如果有,泄漏的是什么,以及哪里出现泄漏。
在我们进行详细分析之前,我想要强调一点,这是一个低级的工具。例如,它不会告诉您泄漏出现在什么 EJB 中。数据从 JVM 被直接传递给 Memory Leak Detector ,而 JVM 没有容器、 EJB 或者任何其他 J2EE 组件的概念。它只会处理类、数组和实例。只要您可以访问源代码,就应该不会引起任何问题。
方法
让我们开始吧。第一步是确定系统中是否真的存在内存泄漏。为此,我们首先要在管理控制台中创建一个连接,连接至我们感兴趣的 JVM ,并确保系统正在运行且没有过载。然后,转到管理控制台中的“ MemLeak Detector ”选项卡。这里,我们要在 JRockit 中启用内存泄漏检测系统,这将启动内存增长的一个趋势分析。 JVM 中每次进行垃圾收集时, JRockit 都会把趋势数据发送给位于一个趋势分析表中的 Memory Leak Detector (参见图 1 )。
实例研究
趋势分析显示了堆(或者类,如果您更喜欢的话)上最常见的对象类型,以及用于这些类型的内存增长速率。它还给出了各个类型的当前内存使用和实例数量。趋势分析的运行时间越长,趋势就越可靠。各个类型是按照增长速率的顺序排列的,而我们感兴趣的正是增长速率最高的类型——这些就是泄漏的类型。在图 1 的例子中,显然有三种类型要为内存泄漏负责: DemoLeakDemoObject 、 HashtableEntry 、和 HashtableEntry[] 。我将使用这个例子作为一次实例研究,以便说明 JRockit Memory Leak Detector 的功能。
哪里出现泄漏?
现在,我们已经检测到有内存泄漏,而且我们知道是何种类型的对象正在泄漏。在这个例子中,不难猜到是 混编 表项占用了 DemoObject ,因为这些类型的实例在数量上大体相等。此外, 混编 表项数组的增长也是合乎逻辑的,因为 混编 表项的数目正在不断增长。
要检查混编表项是否正在使用 DemoObject ,我们可以要求查看何种类型指向 DemoObject 。为此,我们需要冻结趋势分析的更新。那么,我们可以在表中选择 DemoLeakDemoObject 行,然后点击右键,就会出现一个带有“ Show types pointing to this type ”选项的弹出式菜单。这将会显示一个表,表中包含指向 DemoObject 的所有类型。在这种情况下,表只包含 HashtableEntry 类型。(啊哈,我们的猜测是正确的!)现在就可以对该表进行仔细分析,检查是何种类型指向 HashtableEntry 。
向后遍历这个“指向( point-to )链”(参见图 2 ),可以看到, HashtableEntry[] (数组)和 HashtableEntry 都指向了 HashtableEntry 类型。如果您认为后者比较古怪,记住如果 混编 表中出现了散列冲突,一个存储段将会保存多个项,可以建立一个 混编 表项的链接表,并对其进行顺序搜索。
既然我们知道一个或多个混编表正在泄漏,我们就需要下降到实例的层次上。一种方式是找到增长的 HashtableEntry[] 实例,这很可能就是最大的一个数组。为此,我们可以右键单击 HashtableEntry[] 类型,然后要求“ Show largest arrays of this type ”。这个动作将显示一个表,其内容为最大的数组实例以及这些数组的内存大小(以字节为单位)。在我们的例子中,它看起来就像清单 1 一样:
java.util.HashtableEntry[]<10> 6 291 472 java.util.HashtableEntry[]<3> 784 java.util.HashtableEntry[]<4> 400 java.util.HashtableEntry[]<5> 400 java.util.HashtableEntry[]<6> 400 java.util.HashtableEntry[]<7> 40 java.util.HashtableEntry[]<8> 400 java.util.HashtableEntry[]<9> 400 java.util.HashtableEntry[]<11> 208 java.util.HashtableEntry[]<12> 208
括号中的数字 <n> 是惟一标识对象的对象 ID , JRockit 的内存泄漏检测系统正好使用了它。这个列表告诉我们的是,带有对象 ID<10> 的数组显然很可疑,因为它消耗的内存比其他数组多。为了完全肯定,我们可以稍等一会,然后再次向系统请求最大的 HashtableEntry 数组的表。然后,我们可以检查带有 ID<10> 的数组消耗的内存是否仍然最多,结果发现它已经增长了数百个字节。
让我们扼要重述一下我们迄今为止的发现。系统正在泄漏内存,而且似乎有三种类型在泄漏—— DemoObject 、 HashtableEntry 和 HashtableEntry[] 。有一个混编表项的数组实例,这些混编表项还在增长,显然,系统中只有一个混编表在泄漏。到底是哪一个呢?
通过请求有关最大数组实例的指向信息,我们可以找出答案,具体方法是右键单击最大的数组实例,然后选择“ Show instances pointing to this array ”。这将会打开图 3 中所示的窗口。它显示了引用 HashtableEntry[]<10> 实例的实例或静态字段(如果有的话)。正如我们看到的那样,有一个实例指向数组:一个对象 ID 为 <20> 的混编表。现在,我们已经找出泄漏的混编表,而且通过请求这个混编表的指向信息,我们可以继续我们的搜索。结果在如图 4 中的实例图中以可视化的方式表示。有两个 DemoThread 对象和许多 ObjectMonitor 对象引用了泄漏的混编表。我们可以不管 ObjectMonitor 对象,因为它们只是同步的一种结果,而与应用程序代码无关。另一方面, DemoThread 非常值得关注,因为它是用户代码的一部分。
为了帮助我们快速在引用混编表的 DemoThread 中找到正确的实例字段,我们可以在 DemoThread 对象之一上打开一个 Inspector 。 Inspector (参见图 5 )显示了实例的所有字段以及它们的值。列表中的第一个字段恰好是“ table ”,它指出 ID 为 <20> 的 Hashtable 是正在泄漏的混编表。 Memory Leak Detector 也能帮助我们做到这一点。现在事情就变得很简单了,只要读取 DemoThread ( DemoLeak.java 中的一个内部类)的源代码,然后弄清楚我们使用“ table ”字段的实际作用即可。
限制
我们进行研究的程序是一个简单的例子,我只是出于明确性方面的考虑才选择这个例子的。现实中的生产系统要复杂的多,可能需要进行更加全面的研究。
您还应该清楚, Memory Leak Detector 工具的这个技术预览存在一些限制。一个限制是,它现在使用的是 JRockit Management Console 的基于 Java 的通信协议。这意味着 JRockit 需要创建新的 Java 对象才能发送信息给管理控制台。这不是一个完美的解决方案,因为在内存出现泄漏的情况下,系统的内存很可能已经不够用了。
另一个限制是,当有大量信息需要发送时,我们就要冒着由于超时问题而丢失到管理控制台连接的风险。这通常只在请求指向某种特定类型(理论上可以是数十万种)的所有同类实例列表时,才成问题。在用户界面中定位也着实不太让人满意。
我们把这个工具视作对概念的验证,而且我们急于获得反馈,这样我们便可以改进它未来的版本。即便它并不完美,我们仍然相信它对想找出内存泄漏的人来说十分有用。
未来的工作
那么,我们对于 Memory Leak Detector 的下一个版本有什么计划呢?首先,它将成为一个单机工具,可以独立于管理控制台而运行。它将使用本机的通信协议,这应该可以根除当前工具中内存耗尽的风险。当然,更重要的事情是要改进用户界面。以后的版本将会有对象和类型引用图(类似于图 2 和图 4 中的内容)来可视化指向链,而且用户进行定位的方式会比现今更加直观。它还可以进行分配跟踪,跟踪给特定的对象类型分配的位置。当在代码中进行精确定位时,使用这种功能将会十分方便。 JRockit 中的内存泄漏检测系统仍然可以进行实时操作,同时将开销降到最低,而且您还可以随时启用或禁用它。
常见的泄漏
内存泄漏很难检测和解决,但值得庆幸的是许多程序员往往犯下同样的错误。这听起来完全不是什么好事,但同时也意味着大多数内存泄漏十分相似。因此,典型的内存泄漏的种类并不多,而且丰富的经验和合适的工具,将有助于更容易地找出它们。下面,我将描述一些在服务器端常见的内存泄漏类型。
一个十分常见的错误是,把对象放入混编表或混编图中,但在不再需要它们时却忘了删除它们。我们的实例研究又是这样一个例子:删除混编表中的一些项时,出现了大小差 1 ( off-by-one )的错误。 DemoThread 中有错误的代码如清单 2 所示。
int total = 0; while (true) { for (int i = 0; i <= 60; i++) table.put(new DemoObject(total + i), "foo"); // Below is the faulty line: Should be <= and not just < for (int i = 0; i < 60; i++) table.remove(new DemoObject(total + i)); total += 61; }
这里给出的方法可以找出是哪个混编表实例正在泄漏,以及谁正在使用该实例,就像我们在实例研究中所做的那样。希望这样能够给您足够的信息,以便在您的代码中找出正确的混编表。
一种类似的情形是,何时存在复杂的数据结构,即某些混编表位于其他混编表的内部,以及一个或多个“内部”表在哪里泄漏。检测方法和上面一样,但是引用的信息会更加混乱,因为混编表项还可能指向混编表。
另一种常见的内存泄漏出现在,把对象插入到一些其他类的集合中,但在使用之后没有完全删除的时候。使用 java.util.LinkedList 作为例子。在趋势分析中,我们会看到 LinkedListEntry 类型正在增长。获得该类型的引用者对我们没有多大帮助,因为该类型指向它本身——它毕竟只是一个链接表。现在正是时候来使用 Memory Leak Detector 中的“ Show instances of this type pointing to type ”选项了,这种情况下的 T 就是 LinkedListEntry 。这将显示一个表,内容为指向其他项的项实例,汇聚了每个实例保持的活动数据的数量信息。活动数据的数量表明了实例使用的内存大小(假定系统中只有该对象是活动的)。活动数据最多的项通常是泄漏列表上的第一项。找到这一项之后,我们可以获得引用它的各个实例,从而了解系统中哪些地方引用了这个链接表。这样做应该可以指导我们在代码中找到正确的位置。
结束语
JRockit JVM 拥有一些用于实时内存泄漏检测的独特功能。在 Java 中,内存泄漏通常难于找出,但是借助合适的工具,它并非不可完成的任务。我已经说明了如何使用 JRockit Memory Leak Detector 检测内存泄漏,找出泄漏的内容,然后仔细分析出是什么原因导致了代码中出现的泄漏。