富客户机应用程序的性能之二:堵住内存泄漏

  本系列的 第 1 部分 解决了基于 Eclipse 的富客户机应用程序性能问题的几个方面,包括 CPU、I/O 以及线程问题等。内存泄漏是导致性能问题的另一可能原因。本文阐述如何监视应用程序的内存使用,描述您在开发富客户机应用程序中可能遇到的几种内存泄漏,并介绍一些用于解决内存泄漏的技术。

理解内存使用

  理解一个富客户机(Rich Client Platform(RCP))平台应用程序的完整内存使用会是一项脑力劳动。操作系统(OS)会指出应用程序耗费了多少内存,Java™ 平台会指出您已经耗费了多少堆。操作系统汇报的内存使用情况总是高于可用堆大小。不幸的是,有时操作系统所报告的数目会远远大于堆大小。对于堆分析的一个挑战就是判断这片 “黑暗空间” 中藏匿着什么。

  一般而言:进程使用的内存 = Java 堆 + 已编译的本地代码 + 字节码 + 其他 / 本地

  很不幸,JVMS 根据其发行版本和供应商的不同,指示出的堆大小也不同。我所运行的一个 Java 应用程序就可以给出一些例子:Sun 1.6 JDK 报告堆大小为 32.7MB ,而操作系统报告为 48.6MB 私有字节,有 16MB 未作说明。总的来说这还算不错。已编译代码和字节码是这 16MB 的一部分。用 IBM® 1.5 JDK 运行同一应用程序,堆加上类加载器和已编译代码总共是 39MB,而 OS 报告的大小为 45.8MB。

  一般而言,您可以把问题简化为只关注 Java 堆。这对绝大多数 Java 应用程序而言已经足够了,而且也可以让应用程序做到最大程度的改进。如果还不够,那么您应该使用操作系统工具检查未被 Java 堆覆盖的本地内存。

  差异分析(Differential analysis)

  处理内存使用问题中最为行之有效的一种手段是关注对象数目。举例而言,如果要在某个邮件应用程序中显示 50 条邮件消息,那么需要多少个 MailMessage 类的实例? 50,对吗?那么邮件详情或其他邮件域对象呢?如果切换了文件夹,显示新的 50 条邮件消息,又将发生什么情况呢?您会拥有多少个对象:50 还是 100?

  一旦开始进行此类分析,您就会对实例数目大大超过期望数目这一常见情形感到惊讶。注意:在您收集堆转储之前,确保已经发生了垃圾收集行为,因为您不会想去考虑那些已经死亡的对象。一般情况下,我会在捕获堆转储前做一个 System.gc() 操作。

  我并不想去描述司空见惯的一般性堆分析(请参阅 参考资料)。 相反,我将介绍差异分析(differential analysis),这是用于发现应用程序中内存泄漏的技术。

  它的基本思想很简单:

   1.得到一个堆转储。
   2.在应用程序中多次做某件事(假设做 10 次)。
   3.得到另一个堆转储。
   4.比较两个堆转储中应用程序对象的数目。

  这样就可以构建所需应用程序对象集合。随着泄漏的发现和处理,将泄漏到脚本的类添加到一个列表。这样一来,不长时间就可以构建经常检查的应用程序对象集合。

  单元测试

  我所用的另一个技术是写单元测试,解析堆转储并对期望的域对象实例数目做断言。比如说,您可以启动应用程序,运行一个场景,得到一个对转储,接着做断言。下面是一个例子:在邮件应用程序中发现一个内存泄漏,当该泄漏被处理后,我希望确定在以后的代码改变中不会再发生该问题,于是为此构建了一个单元测试。这是一个资源使用 单元测试,如清单 1 所示:

清单 1. JUnit 测试用例,解析堆转储

public void testOpenTenMessages() throws Exception {
Heap heap = Heap.from("openMessages.phd");
assertEquals(10, heap.instancesOf("cbg/mail/ui/message/MessageController"));
assertEquals(10, heap.instancesOf("cbg/mail/ui/message/viewer/AttachmentModel"));

Heap heapAfter = Heap.from("openMessagesClosed.phd");
assertEquals(0, heapAfter.instancesOf("cbg/mail/ui/message/MessageController"));
assertEquals(0, heapAfter.instancesOf("cbg/mail/ui/message/viewer/AttachmentModel"));
}

  其工作原理是:打开 10 条邮件消息,创建名为 openMessages.phd 的堆转储。然后关闭消息并创建第二个堆转储,命名为 openMessagesClosed.phd。

  针对这两个堆转储文件,现在对内存中所需域对象数目做断言。我期望在第一个转储中有 10 条邮件消息(MessageControllers),在第二个中没有任何邮件消息。

  这种自动堆分析是对不同构建之间的变化做跟踪的有力途径。和标准单元测试一样,您可以仅在发现和处理内存泄漏时才创建此类单元测试。把应用程序中的资源使用看作应被跟踪的另一个量度信息是有益的。即便是知道应用程序在运行后分配了多少个对象,也有助于构建的发展。

  不幸的是,不同的 JVM(即便是相同 JVM 的不同版本)在堆分析上有着极大的不同。 IBM JVM 改变过几次堆分析格式。Sun 的 JVM 使用另一种格式,并且在每次发布时也做过改动。

图形设备接口资源的泄漏

  在 Windows® 操作系统中,每个颜色、字体、图形上下文(graphics context(GC))、图像、光标或者区域都对应于一个单独的图形设备接口(graphical device interface(GDI))资源。 GDI 是 Windows 的术语,不过每个 OS 都有一个对应物。重要的是整个 OS 所拥有的 GDI 资源数目是有限的。如果应用程序泄漏或使用了过多的资源,将会影响到系统上所运行的所有应用程序。GDI 泄漏很糟糕。

  判断 GDI 资源是否泄漏比较简单。在 Windows OS 中,您可以使用 Task Manager 或 Process Explorer。添加 GDI 列,观察它是否随时间而增长(参看图 1)。比如说,您可能注意到每当打开一条邮件消息,与 javaw 进程关联的 GDI 资源数目就会增加 50,但是当您关闭邮件消息后, GDI 资源的数目只减少 46。您每阅读一条邮件消息,会泄漏 4 个 GDI 资源。

  尽管 Task Manager 能告诉您何时 发生了泄漏,但它不能帮您发现哪里 发生着泄漏。要做到这点,最好的办法是使用 Sleak,一个 SWT 开发工具(请参阅 参考资料)。您可以启用 SWT 所拥有的调试标记,使它跟踪 GDI 资源的创建位置。 Sleak 让您看到 GDI 资源以及它们是从哪里分配的。

  SWT 和 JFace 提供了几个不同的类,帮助您在几个缓存中管理 GDI 资源。缓存往往比您想的更灵巧。如何以及何时使用缓存并不总是显而易见的。设计时应该注意的几个问题是:

  GDI 泄漏总是不可接受的,必须进行处理。
  处理完泄漏后,您应该考虑下面两个问题:
   应用程序需要的总共的 GDI 资源数目。
   创建这些资源导致的开销。

  总共的 GDI 资源数目

  您需要清醒地了解应用程序所需要的总共的 GDI 资源数目,以及有多少资源是副本。副本相当重要,因为您只要有可能就应该共享 GDI 资源以便降低应用程序使用的资源数目。很容易创建副本,而且您可以都没有意识到(我曾修改了 Sleak 工具使之发现副本,并将此改变以及其他有用的改变添加到 Eclipse 中。)

  创建 GDI 资源所需的开销

  一般而言,创建字体和图像耗费的资源比创建颜色多。根据应用程序的不同,图像的创建可能成为某些用户动作的重大开销。如果遇到这种情况,您可以考虑使用一些 SWT/JFace 所提供的缓存。

  如果可能,应让平台来管理资源。当您在平台扩展中指定图像或图标属性时 —— 比如视图、动作等 —— 平台负责保证资源被正确地创建和删除。最好的代码往往就是您无需编写并维护的代码。上述提示的合理推论就是可能时使用当前平台的字体和图像以提高共享。

  只要有可能,就应共享资源。为此,最行之有效的方法是把资源集中到一个公共包中。您可以将此视为重构公共资源。

  每个 UI 包都有一个与之相关的 ImageRegistry。该注册项可用于存储常用图像。这里的关键是常用。我曾见到过开发人员把所有的资源都放到了这个注册项内,这并不合适。该注册项维护着一个由名称 > 图像或名称 > 图像描述符构成的映射。图像描述符是对图像的轻量描述;它们并没有与之相关的任何 GDI。您可以用图像描述符提前得到图像注册项,那么当首次需要用到该图像时,注册项会为您创建它。

  对于较为不常用的图像,您可以自行创建或删除之,此外您还可以使用 LocalResourceManager。其构造函数的一种形式采用了小部件。以此方式创建时,LocalResourceManager 会在销毁小部件时清空与之相关的资源。

  侦听器泄漏(Listener leaking)

  侦听器相关的泄漏是 UI 代码常常出现的问题(请参阅参考资料)。侦听器泄漏往往会浪费内存和时间。当您向一个对象添加侦听器时,您是在小部件和侦听器之间创建了一个直接的、牢固的关联(参看图 2)。只要小部件存在,侦听器及其引用的一切都会一直驻留在内存中。当小部件或它的父容器被关闭, SWT 删除侦听器,从而打破那个牢固的关联。我曾见过的很多代码都说明了开发人员往往在这个问题上不甚清楚。

  只要从中添加侦听器的对象会及时删除,您就无需删除 JFace/SWT 侦听器。关键是理解从中添加侦听器的对象的生命周期。不管什么时候要向某个对象中添加侦听器,您都需要自问一下,侦听器被添加到哪个对象,侦听器的生命期有多长。

  举例而言,假设应用程序创建了一个视图。该视图包含一个按钮。在您构建该视图时,您为按钮添加了一个选择侦听器,以便应用程序能够对按钮单击作出响应。您无需为删除按钮的侦听器而对视图添加一个删除侦听器,您也无需为按钮被撤销时删除按钮的侦听器而对按钮再添加一个删除侦听器。 SWT 会在按钮被撤销时执行对按钮侦听器的删除。您不必写这些冗余的代码和管理多余的工作。

  在 RCP 应用程序中,经常会有某人创建一个视图并将其自身添加为 workbench 页侦听器。 Workbench 页往往很长寿,直到该应用程序关闭,workbench 页才会被关闭(从而清空侦听器)。在此情况下,您不应该依赖 workbench 页清空侦听器关联。您应当在视图被关闭时把该视图作为侦听器删除。

  我曾在一个聊天程序中看到过另一个受惑于对象生命周期的例子。每当打开一个聊天窗口,都会向伙伴列表添加一个侦听器。聊天窗口永远不删除侦听器,只要伙伴列表没有被撤销,不会有什么问题。最终结果是越来越多的侦听器被添加到伙伴列表,而且它们永远不会被删除。需要强调的是这不仅是内存泄漏,也是对性能的破坏。侦听器泄漏的后果是,每个聊天窗口以及它所有可访问的对象都驻留在内存。同时,每次当伙伴列表向列表内的侦听器发信号,都会浪费时间去通知那些本来已经被关闭的聊天窗口。

  还有一种常见的情形,是把侦听器添加到偏好存储以便您能够在偏好改变时更新 UI。我曾见过有开发人员在创建视图或创建动作时添加偏好存储侦听器。问题在于如果您不删除偏好存储侦听器,您会导致侦听器累积,因为一般而言,偏好存储只有在应用程序关闭时才会被关闭。

  动作是一个特殊的例子。动作并不真的有生命周期。它们被创建后,即使被撤销或不再使用,您也并没有对它有什么控制能力。这意味着当您创建一个动作时,您一般不应该向其他对象添加侦听器,因为您并没有好的方法以删除那些侦听器。

如何发现侦听器泄漏

  为发现侦听器泄漏,我推荐两个方法:

  审查代码:我会搜寻应用程序代码中向对象添加侦听器的位置,在那些位置上我认为侦听器的生存时间超过我的预期。对这些侦听器列表后,我一般在运行时使用调试器验证我的假设。即使每个 addListener 都对应有 removeListener 也并不代表没有问题,因为开发人员往往会犯一个错误,就是把 removeListener 包含到某个方法中,他们以为该方法会被调用而实际上却没有。

  使用剖析器或差异分析,按如下步骤:
   启动应用程序。
   预热。
   得到一个内存快照。
   做 5 次动作(打开聊天窗口、读取邮件消息等)。
   得到一个内存快照。
   分析应用程序对象的实例数目。如果有侦听器泄漏的话,比如说,您可能会发现有多出的 5 个本不应存在的侦听器。

结束语

  我希望本文能为您提供一些关于如何在不同构建之间度量应用程序中堆使用的想法,还有几个手工技术用于发现并处理不可避免的泄漏。如果您还没有做好准备,试着跟踪应用程序在构建时耗费的资源量。做了这些之后,您可以尝试进行堆分析。即便您一开始对于堆转储还做不了什么,在构建时收集这些转储对于日后使用具有极大价值。开始收集堆转储之后,您就能够对域对象做差异分析。从小做起,当您熟悉这些技术后可以加入更多的分析。

mars - Sun, 2007-08-26 15:27