内容


富客户机应用程序的性能,第 1 部分

性能分析的工具、技术和技巧

Comments

系列内容:

此内容是该系列 # 部分中的第 # 部分: 富客户机应用程序的性能,第 1 部分

敬请期待该系列的后续内容。

此内容是该系列的一部分:富客户机应用程序的性能,第 1 部分

敬请期待该系列的后续内容。

即使做了很好的前期规划,应用程序仍可能出现重大的性能问题。 这篇由两个部分构成的文章给出了一些帮助您分析这些问题的技术, 重点关注的是基于 Eclipse 的富客户机 Windows 应用程序。 这是第 1 部分,我将向您展示如何度量基于 Eclipse 的 RCP 应用程序性能, 判断速度降低的原因是由于 CPU 还是 I/O 瓶颈, 保持 UI 线程空闲以保持响应性。 我还会给您提供一些避免线程错误以及提高应用程序启动性能的技巧。 第 2 部分将讨论一些跟踪内存问题的方法。 这些技术中的大部分也适用于 Eclipse 之外的应用程序。

关键概念

在您探究性能问题时,第一步就是要判定出问题的任务究竟是受限于 CPU(CPU-bound) 还是受限于I/O(I/O-bound)

CPU-bound 意味着 CPU 是完成工作的瓶颈,因此一个更快的 CPU 会 更快地完成动作。举例而言,如果您的 CPU 是 100MHz,用 50 秒 对 100,000 个电子邮件排序,那么 您可以期望一个 1GHz 的 CPU 能在 5 秒内完成排序。

但提高 CPU 频率并不一定会使任务运行更快。I/O-bound 任务是指那些以 I/O 为瓶颈的任务。 一个很好的例子就是从磁盘读取大型文件或从 Web 站点下载文件。 一般而言,CPU 速度对 I/O-bound 任务没有影响,因为 处理文件读取的是 I/O 子系统。典型情形是,源设备不能保持足够高的传输速率以 使得 CPU 保持繁忙。CPU 在其等待数据时没有事情做,干脆就休眠了。

所以有多个因素可能导致速度降低:CPU 繁忙、应用程序做的 I/O 过多、 等待 I/O 完成或上述情况的某种组合。凭空猜测其原因没有意义,使用工具判断则更为有效。 下一节介绍了一些工具,可以帮助判定任务是受限于 CPU 还是 I/O。

用于 Windows 的监视工具

在 Windows® 操作系统上,我通常喜欢组合使用 Performance Monitor(Perfmon)和 Sysinternals Process Explorer(请参阅 相关主题)。Process Explorer 可以完全取代内置的 Task Manager,所以我基本上不用它。 对于所有的监视工具,您都需要小心使用(请参阅 监控应用程序的成本)。 如果您命令 Process Explorer 过于频繁地刷新其 视图,机器所承受的负载就会更高。

我根据是否进行特别的性能分析或更长时间的运行来选择工具。 如果要更长时间地监视应用程序或对应用程序日志关联性能数据, 我一般会使用 Perfmon。设置 Perfmon 记录带时间戳的记录逗号分隔值(comma-separated value(CSV))文件很容易, 您可以用它关联到其他日志。

按如下步骤打开 Perfmon:

  1. 单击 Start > Run > perfmon
  2. 单击 Perfmon 的 + 按钮,打开 Add Counters 对话框(参见 图 1)。
  3. 单击 Explain 按钮,可以看到每个计数器的详细说明。
图 1. Perfmon Add Counters 对话框
Windows performance monitor add counters 对话框
Windows performance monitor add counters 对话框

我一般会添加如下计数器,选中 Process 作为 Performance 对象:

  • % User Time:该进程正在处理的工作量。
  • Handle count:该进程打开的句柄数。 其中,句柄数代表了某个应用程序打开的文件或套接字数目。
  • IO Data Bytes/sec:该进程正在操作的磁盘、网络或设备 I/O 量。
  • Private Bytes:与该进程相关的不能被共享的内存量 —— 应用程序大小的粗略估算。(该值对应于 Task Manager 中的 VM 大小。)
  • Thread Count:与该进程相关的线程数目。

另一方面,如果我要 “实时” 观察重复问题, 我会使用 Sysinternals Process Explorer。它的优势是能够关注一个进程而不是整台机器。 在考察一个特定问题时,您通常希望只观察的涉及到的那个应用程序。

在 Process Explorer 内双击您要监视的应用程序,打 开该进程的 Properties 对话框(参见 图 2):

图 2. Javaw 进程的属性
JEdit procexp
JEdit procexp

图 2 中的 javaw 进程来自于 JEdit。在本例中,我从磁盘打开了一个 14MB 的文本文件。 从下往上观察图 2 的三个图表,您可以发现:

  • I/O Bytes History 图表中的大型峰值表示磁盘 I/O 要读取那个 14MB 文件。 在线上停留片刻,会显示已读取 14MB。
  • Private Bytes 跳升至 33MB。Java™ 堆(heap)会为 14MB 文本文件要求 28MB 空间, 这主要是因为 Java 语言使用 16 位 Unicode 字符。Swing 和 JEdit 为管理编辑还需要另外 5MB 空间。
  • CPU Usage History 的大型峰值说明文件读取到内存后的执行了处理动作。 在此例中, JEdit 在更新显示,对文件做语法高亮等处理。

如果操作缓慢是由于 I/O 所致,您需要判断是哪部分应用程序导致了 I/O 问题。如果 操作缓慢是由于 CPU 所致,就需要使用分析器。

设置分析器

为 RCP 应用程序所做的分析器设置与为其他很多类型的应用程序所做的不同, 因为 RCP 应用程序一般由一个可执行程序或 shell 脚本启动,而不是直接启动 Java 运行库。 问题还可能更复杂,因为 RCP 启动器为 Java 处理程序创建命令行参数并启动它。 这种更高级别的间接性会在您尝试分析或精细控制 JVM 调用参数时造成困难。 为了不依赖于应用程序启动器而启动 Java 运行库, 我经常提取 Java 命令行并直接启动它。下面是一种方法:

  1. 正常地启动应用程序。
  2. 运行后,启动 Process Explorer 并找到 javaw 或 Java 进程。打开进程属性 并从详细信息(参见 图 3)里复制命令行参数。
  3. 将复制内容粘贴到一个批处理文件并按照需求做修改。(按这种方式,您可以创建一个核心 批处理文件,配备几个变量用来添加或删除 VM 参数、类路径入口等等。)
图 3. 用于 Java 进程的命令行参数
用于 Java 进程的命令行参数
用于 Java 进程的命令行参数

确定 UI 线程中的长时间运行动作

绝大多数现代操作系统都有一个单独的 UI 线程。 同样的,Standard Widget Toolkit(SWT)也是如此。您必须小心 不要让这个单独的线程执行长时间运行操作,比如大量的磁盘 I/O、网络调用或 其他那些大工作量操作。

为了了解原因,您可以设想应用程序上有一个按钮,单击按钮时要做一些工作, 于是您对它添加了一个事件处理程序。 当用户单击该按钮时,OS 调用 GUI 工具包,转而调用 您定义的事件处理函数。事件的处理代码现在运行于 UI 线程, 而且只要这段代码在运行,UI 线程就无法对其他 UI 事件作出响应。 这意味着 UI 看上去冻结了,用户将对此情况感到不安。 问题的重点在于如果 UI 线程在运行您的代码,应用程序将无法 再处理来自 OS 的 UI 事件。 如果应用程序有一个按钮用于取消某个长时间运行的操作,而您正在使用 UI 线程 执行操作,那么这个取消事件只有到 UI 线程所做的操作完成后才会被处理! (如果代码在 UI 线程中运行过久,OS 会提醒用户,为用户提供选项以 终止该应用程序。)

上述情形说明了为什么 UI 线程上的缓慢和无法预期的 I/O 会造成问题。 每种类型的 I/O 都可能拥有极为不同的特性。磁盘 I/O 一般遵从线形 模型反应时间(latency)+ 传输速率 * 数据量。 另一方面,网络 I/O 则没有这么规则。它不仅比磁盘 I/O 慢,而且在可靠性上也远不如磁盘 I/O, 这是因为它会受到(可能是暂时性地)网络端点间拥塞的影响。

由于在开发时网络可能很快而且延迟很小,所以很容易忽视 网络 I/O 在 UI 线程上造成的影响。在开发环境下,容易无意识地在 UI 线程上 做网络调用,直到在较慢或稳定性较差的网络上运行的用户注意到每次进入网络 UI 线程都会冻结时,才发现这个问题。 再加上套接字超时,应用程序如果五秒钟内没有响应 UI 事件,则 Windows 经常出现 “死亡白屏” 的情况。

表 1 介绍了一些用于发现 UI 线程上长时间运行的操作的技术,以及它们的优缺点:

表 1. 用于发现 UI 线程上长时间运行的操作的技术
技术优点缺点
使用分析器如果您有一个分析器,那么设置它并不麻烦。一般要花钱。
耗费的运行时间可能非常高。
记录 JDK设置好后可以和应用程序很好地合作,直到升级该 JDK。
耗费的运行时间很少。
不容易和他人共享。
记录代码很多用户可以共享,因为启用记录后,客户、QA、开发人员和其他人都能 运行。可能要求您改变应用程序的架构以发现所有做了网络调用的位置。
必须注意不要添加未被记录的新方法。
需要记录处理日志,日志文件会变得很大。

记录技术

您可以用很多技术来洞察应用程序所做的事情。 本节介绍其中一些技术。

使用方面(aspect)

您可以用面向方面(aspect-oriented)技术将变化 “编织” 到被记录的类中。 举例而言,可以直接将代码组合到 SocketInputStreamSocketOutputStream 检查流是否被 UI 线程访问 (请参阅 参考资料 上关于面向方面技术和工具的更多信息的链接。)

使用断点

如果能在调试器里运行您的应用程序,有些时候用条件断点记录 JDK 更加简单。 我曾参与过一个大型应用程序,它在 UI 线程上进行网络调用。该应用程序的结构 (大量第三方代码)使得分辨谁来负责网络调用很困难,而在 Eclipse 中的 SocketInputStream 类设置条件断点(如 图 4 所示), 则很容易分辨出来:

图 4. 条件断点
条件断点
条件断点

使用安全管理器

另外,我曾成功地使用一个记录式安全管理器替换应用程序的安全管理器。 大量有趣的调用通过安全管理器传递。 比如,清单 1 中的安全管理器 记录了一条消息,它试图在 UI 线程中打开一个套接字:

清单 1. 记录在 UI 线程中打开套接字时的错误
SecurityManager securityManager = new SecurityManager() {
    public void checkPermission(Permission perm) {
        if(perm instanceof java.net.SocketPermission) {
            if(Thread.currentThread().getName().equals("main&")) {
                logger.log(Level.SEVERE, "Network call on UI thread&");
                new Error().printStackTrace();
            }
        }
    }
};
System.setSecurityManager(securityManager);

记录代码

如果您的应用程序分层很好,网络调用只经过一个(或很少)位置, 则能够在进行网络调用之前用应用程序代码检查当前线程, 如 清单 2 中所示。 在构建产品时我会保留此类代码,因为线程检查很开销较低。 创建并记录异常日志会导致一些时间开销,但是堆栈跟踪可以很好地用于 捕获问题原因。

清单 2. 记录在 UI 线程中做网络调用时的错误
if(Thread.currentThread().getName().equals("main")) {
    logger.log(Level.SEVERE, "Network call on UI thread");
    new Error().printStackTrace();
}

修改 JDK 的类

作为最后一种手段,您可以通过修改 JDK 的类达到记录 JDK 的目的。 这种手段不受支持、复杂并且有黑客嫌疑 —— 而且可能侵犯许可 —— 但是对于 某些不常见的情形,当前述技术无能为力时,它还是一个有价值的选择。 这种技术的要点是重新编译 JDK 的类,并使用 -Xbootclasspath/p: 预置 JAR 或目录到您的启动类路径中。

避免 UI 线程中的长时间运行动作

有一些技术用来避免 UI 线程中的长时间运行动作, 举一个常见的例子:使用某种数据库查询、网络调用或磁盘进行填充的表或树。

不要指望您能在 UI 线程中填充该表。 也许可以处理几百个项目,但是上千个项目则处理不了。

更好

不要指望在显示给用户初始结果之前完全填充该表或树。举例而言, 如果您正在开发一个电子邮件客户机,您定不希望先载入所有文件夹下的所有邮件消息并生成表, 然后再给用户显示一个满是邮件消息的 “页面”。

还有更好

充分利用 SWT/JFace 的虚拟部件。您可以使用几种不同的技术, 但是所有技术都归结于 “尽可能地延迟工作。” 在 UI 线程里,用占位符填充树或表,在后台的 Job 中, 检索真实数据并在获得数据后更新树。

最好

注意您在事件处理程序中做了多少工作。特别是注意捆绑到表、树和列表的 SWT 选择处理程序。 我看到过很多有此类错误的代码。比如,清单 3 中是来自一个 邮件应用程序的选择侦听器;每当选中一条消息,就执行一个数据库查询以读取邮件详情并用它更新 UI:

清单 3. 对每个选择改变做响应的选择侦听器
viewer.addSelectionChangedListener(new ISelectionChangedListener() {
    public void selectionChanged(SelectionChangedEvent event) {
        new Job("go to db") {
            protected IStatus run(IProgressMonitor monitor) {
                //do expensive work here
                return Status.OK_STATUS; 
            }
        }.schedule();
    }
});

清单 3 的开发人员知道这个操作很耗时,就把它放到一个后台 Job 中去做。但是问题在于开发人员没有预料到 用户可能在收件箱中选择一条消息后会按住键盘上的向下箭头持续几秒钟。 一般在这种情况下,每个选择的改变会导致执行一个新的后台 Job。很快,JobManager 会被 Job 填满。

清单 4 提供了一个更好的选择,不使用选择处理程序, 而是用 postSelection 处理程序。JFace 为执行事件接合 提供了 PostSelection 处理程序,从而使得最后一个选择成为您的应用程序所接受的 唯一选择 —— 而不是您的应用程序无法处理的一大堆选择。 您可以认为它忽略了过于嘈杂的细小事件,仅关注其中一些较大的。

清单 4. 仅对最后一个选择改变做响应的选择侦听器
viewer.addPostSelectionChangedListener(new ISelectionChangedListener() {
    public void selectionChanged(SelectionChangedEvent event) {
        new Job("go to db") {
            protected IStatus run(IProgressMonitor monitor) {
                //do expensive work here
                return Status.OK_STATUS; 
            }
        }.schedule();
    }
});

处理磁盘 I/O

您一定听说过:内存读写以纳秒计,磁盘读写以毫秒计。 在我曾参与的绝大多数 RCP 应用程序中,瓶颈是 CPU,而不是磁盘 I/O。 但是,磁盘 I/O 仍然可能成为问题,您不应忽视它。 RCP 应用程序的一类常见问题就是对磁盘的低效率映象读取。

我一般使用 Sysinternals Process Monitor 考察一个应用程序如何使用文件。 举例而言,图 5 说明可以容易地识别对未缓冲文件的读取:

图 5. Process monitor 显示未缓冲读取 I/O
Process monitor 显示未缓冲读取 I/O
Process monitor 显示未缓冲读取 I/O

如果您逐行阅读图 5,您会发现正在读取一个名为 big.txt 的文件。 最后一列说明对该文件每次只读取一个字节。清单 5 说明了导致这种情况的代码:

清单 5. 未缓冲的 I/O(不要这么做)
InputStream in = new FileInputStream(args[0]);
int c;
while ((c = in.read()) != -1) {
    //stuff characters in buffer, etc
}

我的 ThinkPad T60p 笔记本硬盘速度是 7200RPM,它读取一个 7MB 的文件需要 用 24 秒。如果用 BufferedInputStream,时间将减少到 350 毫秒。 绝大多数的此类提速可归功于更好地利用硬盘。可能您的绝大多数程序不会有这么巨大的效果, 但是仍然值得用象 BufferedInputStream 一样的缓冲流来解决未缓冲 I/O 问题。

我在 RCP 应用程序中观察到的另一个问题被我称为映象燃烧, 当从磁盘频繁读取一个映象然后丢弃时该问题就会发生。根据频繁程度,该映象可能最好进入缓存。

线程和 Eclipse 作业

应用程序应该充分利用计算机资源。 为了最优化地使用 CPU,应用程序所拥有的线程数目应与处理程序计数和线程工作类型相关。 每个 Java 线程都关联着一定数量的本地内存,线程切换上下文时会导致一些 CPU 耗费, 所以并不是线程越多越好。

我经常在客户端应用程序中看到对线程的错误使用。对于那些曾可能阻塞 UI 线程的长时间运行操作, 用一些线程来做是好事,但是在基于 RCP 的应用程序中,Job(几乎)应该总优先于线程。

Job 类似于 Runnable:它们描述 task,而不是所运行的线程。Job(并不令人惊讶)由 JobManager 管理,它维持着一个工作者池。即很多 Job 可以被一个工作者池管理。Job 相比于线程所具有的另一个大优势是 Job 可以从外部(out-of-the-box)进行记录。您可以设置一些标识并运行应用程序,JobManager 会告诉您每个 Job 何时被创建、安排、运行以及完成。JobManager 还会告诉您它是如何管理工作者池。 当您试图了解何时执行后台 Job 以及它们的运行需要多长时间时,这会是一个巨大的优势。

要启用这项支持,将 清单 6 的内容添加到一个文件。(此信息可以在org.eclipse.core.jobs 包的 .options 文件中找到。) 然后启动您的 RCP 应用程序,需要有 -debug Path_to_debug_file.

清单 6. 启用 Job 调试信息
# Prints debug information on running background jobs
org.eclipse.core.jobs/jobs=true
# Includes current date and time in job debug information
org.eclipse.core.jobs/jobs/timing=true
# Computes location of error on mismatched IJobManager.beginRule/endRule
org.eclipse.core.jobs/jobs/beginend=true
# Pedantic assertion checking on locks and deadlock reporting
org.eclipse.core.jobs/jobs/locks=true
# Throws an IllegalStateException when deadlock occurs
org.eclipse.core.jobs/jobs/errorondeadlock=true
# Debug shutdown behaviour
org.eclipse.core.jobs/jobs/shutdown=true

Job 还支持高级计划规则。 您可以创建简单的或复杂的计划规则,支配 Job 的运行时机。清单 7 说明了一种方式,您可以创建一条规则,禁止 两个 Job 同时运行:

清单 7. 禁止两个 Job 同时运行
ISchedulingRule onlyOne = new ISchedulingRule() {
    public boolean isConflicting(ISchedulingRule rule) {
        return rule == this;
    }
    public boolean contains(ISchedulingRule rule) {
        return rule == this;
    }
};
Job job1 = new LongRunningJob();
Job job2 = new LongRunningJob();
job1.setRule(onlyOne);
job2.setRule(onlyOne);
job1.schedule();
job2.schedule();
return onlyOne;

清单 7 中的代码能够工作,是因为计划规则的 isConflicting() 方法在 Job 运行前被调用。当 job1 执行时,它 “拥有” 该规则。请参阅 ISchedulingRule 的实现。要获得更多示例(请参阅 参考资料)。

java.util.Timer 也容易被错误使用。 很多应用程序会创建几个 Timer,每个 Timer 创建一个专门的管理线程。典型地,每个应用程序应只创建一个 Timer 并让它管理多个 java.util.TimerTask,但是很多相互孤立的开发人员会创建他们自己的 Timer,这只会浪费更多的线程。在几乎所有的 RCP 应用程序案例中,java.util.Timer 可以被 Job 取代,计划在未来某个时间执行。

如果您所运行的 JVM 版本高于 1.4,您能从 java.util.concurrent ExecutorScheduledThreadPoolExecutorTask 得到大量相同好处。java.util.Timer 类并未受到轻视,但不管出于何种目的, 它都可由 ScheduledExecutorService替代。

关于线程最后值得一提的是:除非您的确需要,不要频繁休眠。 清单 8 说明了一个不当行为的例子:

清单 8. 频繁休眠
while(someCondition) {
    ...more code here...
    Thread.sleep(aFewMilliseconds);
    ...more code here...
}

这么做的应用程序都会在该代码块处创建大量不必要的垃圾 并生成大量上下文切换。您不应当使用频繁休眠, 而应使用 java.util.concurrent 提供的更高级别同步类,如 BlockingQueueSemaphoreFutureTask 或者 CountDownLatch。 这些同步类提供了一种在等待条件变为真时不消耗 CPU 的途径。 当您调用第三方代码而这些代码没有使用监视器的有些时候不可能达到上面的要求, 在此情况下,您所能采取的最佳做法 是尝试在进行池操作时使创建的垃圾量最小。

分析并提高启动性能

对于 RCP 应用程序而言,提高其启动性能是一项挑战。 一般而言,启动性能是由磁盘 I/O、类载入和字节码验证综合而成的功能。 当然,如果在您的包内加入过多工作,也可能使其启动缓慢,但是通常情况下 这并不是启动中最耗时的一部分。 启动往往会由于许多个小的时间消耗而变得及其缓慢。 通常不会有任何一件事情消耗大量时间,而是每件事都只消耗少量时间, 但当其累积起来后就最终导致了大量时间的消耗。

RCP 应用程序构建于 OSGi 之上,它是面向 Java 的动态模块系统(Dynamic Module System)。OSGi 提供了一种简单的手段以全局方式钩取类的装载。 有人已经利用这种类装载钩子来创建 Java 类缓存,从而避免频繁访问磁盘并提高 启动速度。这种技术很有前途,不过尚需更多研究以确定其功效。

为了提高启动速度,Eclipse 鼓励的另一项技术是 包按需激活(lazy bundle activation):直到某个包需要时才被装载并激活。 一般在分析启动性能时,我会收集所有激活包的列表以及对应于它们为何激活的堆栈跟踪信息。 接着通览列表,判断我是否认为该包确实应该在启动时被激活。 如果我认为有个包在启动时不需要,我会删除它以提高启动性能(同时看发生了什么中断)。 一旦我知道了删除包后导致何种提高效果, 我就联系该代码的开发人员,与之讨论删除或延迟对该包的激活。

要想收集包激活和类装载信息,可使用如 清单 9 所示的调试选项,也可在 org.eclipse.osgi 包的 .options 文件中找到, 或者看看 CVS 的最近版本(请参阅 参考资料):

清单 9. 启用 OSGi 调试选项
org.eclipse.osgi/debug=true
org.eclipse.osgi/debug/bundleTime=true
org.eclipse.osgi/debug/monitorbundles=true
org.eclipse.osgi/monitor/activation=true
org.eclipse.osgi/monitor/classes=true

不过,插件开发人员可能自行其是阻挠按需装载。 有个例子,我曾参与一个产品,它有一套堆栈视图。对它定义了一个扩展, 以便于其他人能够贡献自己的堆栈视图。在启动的时候,可能只有一个或者没有视图可见, 但该扩展的作者提前创建了这些视图, 即使它们根本不会展现出来。后来把改扩展改为只显示视图的标题和图标, 直到终端用户真的尝试看该视图时,才激活加入到扩展中的那个包。

另外一个例子,假设您正在创建一个应用程序,它有一个登录对话框。 您的目的是仅激活用于显示登录对话框的包。 我曾经看到有些应用程序,为了显示登录对话框就激活了所有包的 70%。

作为一种手段,我建议您开发一个 shell 游戏,它的启动时间可以有所浮动但是总和保持相同。 用户不必为他尚未使用到的特性付出等待时间。 这样做的目的是只为需要付出而不是为所有东西付出时间。 如果某个应用程序在您做了所有提高性能的努力后仍然不够快, 那么不要忘记提高用户在感觉上的性能。

结束语

我特别强调在您的应用程序架构和设计阶段考虑性能。 最起码,架构师或者首席开发人员必须知道基本的顺序分析或时间复杂性(比如 Big O), 以便理解应用程序的存储需求或执行时间随应用程序增长如何改变。 最后才考虑解决性能问题是被动的 —— 也很低效 —— 因为在游戏后期几乎已不可能再去对架构做大的调整。

不过即使是拥有最好的架构的应用程序也会有性能瓶颈, 您需要使用工具和技术了解并处理瓶颈。 现在您了解了如何度量 RCP 应用程序性能,判定是 CPU 还是 I/O 瓶颈导致了速度降低, 使用一些记录技术,保持 UI 线程可响应,用 Job 回避线程误用, 以及提高启动性能。在下一部分,我将介绍一些技术用来理解内存使用和找出内存泄漏。

致谢

感谢 Brian Goetz 在审核本文时提出的建议和设想。


相关主题


评论

添加或订阅评论,请先登录注册

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Java technology, Open source
ArticleID=247860
ArticleTitle=富客户机应用程序的性能,第 1 部分: 性能分析的工具、技术和技巧
publish-date=08132007