诊断Java代码:平台相关性“gotcha问题”

一次编写,随处运行。这是承诺,但 Java 语言有时候并不能做到。诚然,JVM 把跨平台互操作性的程度提到了前所未有的高度,然而,规范和实现级别上的一些小毛病却使得程序无法在多平台上表现出正确的行为。

用 Java 编程的主要优点之一是它给您带来的很大程度的平台无关性。您只要将您的产品编译成字节码,然后分发到任何带有 JVM 的平台就行了,而不必为每个目标平台构建一个独立的构建版。或者说,至少事情应该是这样的。

但事情并没有那么简单。尽管通过对多平台的支持,Java 编程能够为开发者节约无数的时间,但是,不同的 JVM 版本之间存在许多兼容性问题。其中一些问题很容易就可以找到和纠正,例如:在构造路径名的时候使用特定于平台的分隔符字符。但其它问题可能就很难或者不可能截查到。

因此,一些难以解释的不正常的程序行为在某个特定的 JVM 中有可能是一个错误,记住这一点是很重要的。

与供应商相关的错误

当然,如果想看看存在于 JVM 中的众多微妙的与平台相关的错误中的一些,您只需偶而查查 Sun 的 Java Bug Parade(请参阅 参考资料)。这里所列的许多错误都是仅仅适用于某一特定平台上的 JVM 的 实现错误。如果碰巧不在该平台上进行开发,您甚至可能不知道您的程序会在那个平台上受阻。

但是,并非所有的 Java 平台相关性都是 JVM 实现错误的结果。显著的平台相关性是 JVM 规范自身带来的。当 JVM 的细节在规范级别上不受限制时,就可能在 JVM 之间产生与供应商相关的行为。

例如,正如我们回顾“ Improve the performance of your Java code”(2001 年 5 月)所看到的,JVM 规范对 尾递归调用(tail-recursive call)的优化不作要求。尾递归调用就是作为方法的最后一个操作出现的递归的方法调用。更一般地说,任何方法调用,不管是不是递归的,只要出现在方法的末尾就是 尾调用(tail call)。例如,考虑以下简单的代码:

清单 1. 一个尾递归的 factorial

public class Math {  public int factorial(int n) {   return _factorial(n, 1);  }  private int _factorial(int n, int result) {   if (n <= 0) {    return result;   }   else {    return _factorial(n - 1, n * result);   }  }}

在这个示例中,公共的 factorial方法和私有的 助手方法 _factorial 都包含尾调用; factorial 包含一个对 _factorial 的尾调用, _factorial 包含一个对它自身的尾递归调用。

如果您觉得用这种办法编写 factorial特别复杂,那您并不是唯一有这种感受的人。为什么不用如下自然得多的形式编写它呢?

清单 2. 一个纯递归的 factorial

public class Math {  int factorial(int n) {   if (n <= 0) {    return 1;   }   else {    return n * factorial(n-1);   }  }}

回答是尾递归考虑到了很强有力的优化 — 尾递归让我们用为 被调方法构建的堆栈帧来代替为 主调方法构建的堆栈帧。这可以极大地减小运行时的堆栈深度,从而避免堆栈溢出(尤其是如果尾调用是递归的话,例如清单 2 中对 _factorial 的尾调用)。

有些 JVM 实现这种优化;有些则不然。结果是有些程序在有些平台上会引起堆栈溢出,在其它平台上则不会。要是这种优化可以静态地进行,我们就可以只将字节码编译成尾调用优化过的形式,这样就能同时享有平台无关性和这种优化。不幸的是,正如我在上面所引用的讨论这个主题的文章中所讲解的那样,这种优化无法静态地进行。

与版本相关的错误

尾调用产生的平台相关性是 JVM 规范自身的产物。但是,平台相关性更常见的起因是 JVM 实现中的错误。对于 Swing 的情况,这种错误广泛存在。

例如,JDK 1.4 中的 JOptionPane 组件就一个有关的错误。如果用户把 JOptionPane 中的文本添加到紧跟在空白行后的一行中,然后按“下箭头”键,什么事也没发生。自己试试看:

打开一个新的 JOptionPane。

在 OptionPane 中,接着按 Enter 键两次。

输入“test”。

按“上箭头”键。

按“下箭头”键。

看来,上述的操作序列(以及类似的操作序列)使 JOptionPane 进入了一种奇怪的状态。如果您程序的某个用户发现了这个错误,那么它多半是通过疯狂敲击他的键盘从这种状态恢复的。(从这样一种状态中恢复并不困难;按“右箭头”键即可搞定。)一旦恢复后,他可能再不会将程序的冻结放在心上,甚至可能永远也不会报告这个错误。用户的可接受标准已经被几十年来满是错误的软件大大降低了。

而肇事者在这里。这种错误在针对我所测试的每种平台 — Windows、Solaris 和 Linux — 的所有 Sun JDK 1.4 版本中都存在。所以,这很可能是 Sun 的 JDK 中的一个与操作系统相关的错误。

这个示例说明,平台相关性不是仅仅与操作系统相关性有关,也不是仅仅与供应商相关性有关 — 它与 JVM 版本相关性有关,包括向前的和向后的。

各小组通常对提供向后兼容性很关心,但他们也希望他们的代码在后来的版本中能保持其行为。理想情况下,这种期望可能是合理的,但在现实中却不然。事实上,考虑到 Sun 在提高版本 1.4 的 Swing 的性能方面付出了大量努力,给其中带来一个错误就不那么令人惊诧了。

顺便说一下,并不是只有 Sun 一家对 Swing 的性能不满意。Eclipse 项目是一个开放源代码的项目,它旨在为开发高度集成的工具提供健壮的、开放源代码的、功能全面的并且是商业级别的平台,它实现了一个全新的窗口小部件工具箱,称为 Standard Widget Toolkit(SWT)。SWT 是极轻量级的,因为,不同于 Swing,它利用了底层的特定于平台的窗口系统(windowing system)(请参阅 参考资料)。这个 API 在所有实现它的平台上是相同的,但它的观感则完全取决于平台。所以,我们可以预期,这个工具箱会有一大堆新的与平台相关的问题。

与操作系统相关的错误

作为最后一个示例,这个示例是关于您可以在 Java 平台上体会到的平台相关性的某些潜伏形式的,假设我们正在为一个编辑器编写代码,这个编辑器将打开文件并将它们读入到编辑器视窗。刚开始,我们可能编写如下代码:

FileReader reader = new FileReader(file);_editorKit.read(reader, tempDoc, 0);

对 _editorKit.read 的调用把文件的内容读入到一个临时文档,这个文档稍后将被添加到打开的文档的集合中。但是在这两行之后,我们再也没有引用 reader。

这段代码取自 DrJava IDE — Rice 大学的免费的、开放源代码的 Java IDE — 的早期版本(请参阅 参考资料)。现在,如果您熟悉 Split Cleaner 错误模式,那您可能已经注意到这段代码是该模式的一个很好的示例。

FileReader 被构造来读取文件的内容,但这个 FileReader 从未被关闭。当然,与 Split Cleaner 的其它实例一样,这个错误直到试图对这个文件进行其它访问为止才会出现症状。但是,尽管那样,依据平台,它有可能不出现任何症状!

假设用户后来试图删除这个文件。在 UNIX 上,打开的文件可以被删除,所以,这个残留的、未被关闭的 FileReader 不会引起任何问题。但如果用户是在 Windows 上,则打开的文件无法被删除,所以将会有异常被抛出。我们是在我们的一个单元测试在 UNIX 上成功通过了,而在 Windows 上却通不过时发现前面代码清单中的错误的。一旦诊断出了这个问题,修正它并不难:

FileReader reader = new FileReader(file);_editorKit.read(reader, tempDoc, 0);reader.close(); // win32 needs readers closed explicitly!

跨平台并不是毫无代价的

正如本专栏的示例所演示的那样,Java 语言并不能不受潜伏的与平台相关的错误的影响。这些错误的症状多种多样,但说不定什么时候,某些错误就会咬您一口。

诚然,用 Java 语言比用许多其它语言编写跨平台的代码,其代价要小得多,但并不是毫无代价的。我能给出的最好建议是在尽可能多的平台上、使用尽可能多的 JVM 运行您的单元测试。还有,跟往常一样,避免编写易于出错的代码。易于出错的代码与平台相关性的结合是致命的。这里是我们这个月讲述的内容的总结:

模式:与供应商相关的错误。

症状:错误可能出现在某些 JVM 上,但在其它 JVM 上则不出现。

起因:JVM 规范未加以指定的某些方面(例如,未对尾递归调用的优化作出要求)。这类起因比 与版本相关的错误少见。

处方和预防措施:随所碰到问题的不同而不同。

模式:与版本有关的错误。

症状:错误可能出现在 JVM 的某些版本上,但在其它版本上则不出现。

起因:某些 JVM 实现中的错误,例如 Swing。这是比 与供应商相关的错误更常见的起因。

处方和预防措施:随所碰到问题的不同而不同。

模式:与操作系统相关的错误。

症状:错误可能出现在某些操作系统上,但在其它操作系统上则不出现。

起因:系统行为的规则在不同操作系统上有所不同(例如:在 Unix 上,打开的文件可以被删除;在 Windows 上则不能)。

处方和预防措施:随所碰到问题的不同而不同。

我要感谢 DrJava 开发人员 Brian Stoler 和 John Garvin,谢谢他们协助找出本文所讨论的后两个错误。

未经一番寒彻骨,焉得梅花扑鼻香

诊断Java代码:平台相关性“gotcha问题”

相关文章:

你感兴趣的文章:

标签云: