为了了解原因,您可以设想应用程序上有一个按钮,单击按钮时要做一些工作, 于是您对它添加了一个事件处理程序。 当用户单击该按钮时,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 线程上长时间运行的操作的技术,以及它们的优缺点:
技术 | 优点 | 缺点 |
---|---|---|
使用分析器 | 如果您有一个分析器,那么设置它并不麻烦。 |
一般要花钱。 耗费的运行时间可能非常高。 |
记录 JDK |
设置好后可以和应用程序很好地合作,直到升级该 JDK。 耗费的运行时间很少。 |
不容易和他人共享。 |
记录代码 | 很多用户可以共享,因为启用记录后,客户、QA、开发人员和其他人都能运行。 |
可能要求您改变应用程序的架构以发现所有做了网络调用的位置。 必须注意不要添加未被记录的新方法。 需要记录处理日志,日志文件会变得很大。 |
记录技术
您可以用很多技术来洞察应用程序所做的事情。 本节介绍其中一些技术。
使用方面(aspect)
您可以用面向方面(aspect-oriented)技术将变化 “编织” 到被记录的类中。 举例而言,可以直接将代码组合到 SocketInputStream 和 SocketOutputStream 检查流是否被 UI 线程访问 (请参阅 参考资料 上关于面向方面技术和工具的更多信息的链接。)
Swing 与 SWT 的对比
Swing 和 SWT 的不同之处在于对 UI 线程的命名方式。在 SWT 中,UI 线程往往 命名为 main。在 Swing 中,您使用 java.awt.EventQueue.isDispatchThread() 询问 当前线程是否是分发线程。本文后面的例子按照 SWT 方法;如果您用的是 Swing,做对应的替换即可。
使用断点
如果能在调试器里运行您的应用程序,有些时候用条件断点记录 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. 对每个选择改变做响应的选择侦听器