第一部分: 接口事宜
概要
许多通常的 Java 性能问题都起源于在设计过程早期中的类设计的思想, 早在许多开发者开始考虑性能问题之前. 在这个系列中, Brian Goetz讨论了通常的 Java性能上的冒险以及怎么在设计时候避免它们.
许多程序员在开发周期的后期才可是考虑性能管理. 他们常常把性能优化拖延到最后, 希望能完全避免 -- 有时候这种策略是成功的. 但是早期的设计思想可以影响性能优化的需求及其成功. 如果性能是你的程序的一个重要指标, 那么性能管理应该从第一天起就和设开发周期整合在一起.
这个系列探索一些早期的设计思想能够极大影响应用程序性能的方法. 在这篇文章中, 我专注于最通常的性能问题中的一个: 临时变量的创建. 一个类的对象创建方式常常在设计时候就确定了的 -- 但不是故意的 --, 就为后来的性能问题种下了种子.
性能问题有各种形式. 最容易调整的是那些你简单地为计算选择了一个错误的算法 -- 就象使用使用冒泡算法来对一个大数据集进行排序, 或者在使用一个经常使用的数据项时不是做缓冲, 而是每次都计算. 你可以使用概要分析来简单地找出这些瓶颈, 一旦找到了,你可以很容易地改正. 但是, 许多 Java 性能问题来自一个更深的, 更难改正的源头 -- 一个程序组件的接口设计.
今天大多数程序是由内部开发的或者外部买来的组件构建而成. 甚至在程序不是很大地依于已经存在的组件时, 面向对象的设计过程也鼓励应用程序包装成组件, 这样就简化了设计, 开发和测试过程. 这些优势是不可否认的, 你应该认识到这些组件实现的接口可能极大地影响使用它们的程序的行为和性能.
在这一点上, 你可能要问什么样的接口和性能相关. 一个类的接口不仅定义了这个类可以实现那些功能, 也可以定义它的对象创建行为和使用它的方法调用序列. 一个类怎样定义它的构造函数和方法决定了一个对象是否可以重用, 它的方法是否要创建 -- 或者要求它的客户端创建 -- 中间对象, 以及一个客户端需要调用多少方法来使用这个类.这些因素都会影响程序的性能.
注意对象的创建
一个最基本的 Java 性能管理原则就是: 避免大量的对象创建. 这不是说你应该不创建任何对象而放弃面向对象的好处. 但是你必须在执行性能相关的代码时, 在紧循环中注意对象的创建. 对象的创建是如此地高代价, 以至于你应该在要求性能的情况下避免不必要的临时或者中间对象的创建.
String 类是在那些处理文本的程序中对象创建的主要来源. 因为 String 是不可修改的,每当一个 String 修改或创建, 就必须创建一个新的对象. 结果就是, 关注性能的程序应该避免大量 String 的使用. 但是, 这通常是不可能的. 甚至当你从你的代码中完全除去对 String 的依赖, 你常常会发现你自己在使用一些具有根据 String 定义的接口的组件.所以, 你最后不得不使用 String.
例子: 正规表达式匹配
作为一个例子, 假设你写一个叫做 MailBot 的邮件服务器. MailBot 需要处理 MIME 头格式 -- 象发送日期或者发送者的 email 地址 -- 在每个信息的顶部. 使用一个匹配正规表达式的组件来使处理 MIME 头的过程简单一些. MailBot 足够聪明, 不为每个头的行或者头的元素创建一个 String 对象. 相反, 它用输入的文本填充了一个字符缓冲区, 通过对缓冲区的索引来确定要处理的头的位置. MailBot 会调用正规表达式匹配器来处理每个头行, 所以匹配器的性能就非常重要. 我们以一个正规表达式匹配器类的拙劣的接口作为例子:
public class AwfulRegExpMatcher { public AwfulRegExpMatcher(String regExp, String inputText); public String getNextMatch();
}
甚至在这个类实现了一个有效的正规表达式匹配的算法的时候, 任何大量使用它的程序仍然难以忍受. 既然匹配器对象和输入的文本联系起来, 每一次你调用它, 你必须创建一个新的匹配器对象. 既然你的目标是减少不必要的对象的创建, 那么使这个匹配器可以赜将会是一个明显的开始.
下面的类定义演示了你的匹配器的另一个可能的接口, 允许你重用这个匹配器, 但仍然很坏.
public class BadRegExpMatcher {
public BadRegExpMatcher(String regExp); public String match(String inputText); public String getNextMatch();
}
忽略正规表达式匹配中的精细点 -- 象返回匹配的子表达式, 这个看起来无害的类定义会出什么问题呢? 从功能上来看, 没有. 但是从性能的角度来看, 许多. 首先, 匹配器需要它的调用者创建一个 String 来代表要匹配的文本. MailBot 试图避免创建 String对象, 但是当它要找到一个要做正规表达式解析的头时, 它不得不创建一个 String 来满足 BadRegExpMatcher:
BadRegExpMatcher dateMatcher = new BadRegExpMatcher(...);
while (...) { String headerLine = new String(myBuffer, thisHeaderStart,
thisHeaderEnd-thisHeaderStart);
String result = dateMatcher.match(headerLine);
if (result == null) { ... }
}
第二, 匹配器创建了结果字符串甚至当 MailBot 只关心是否匹配了, 不需要匹配的文本时,这意味着要简单使用 BadRegExpMatcher 来确认一个日期头是否匹配一个特定的格式, 你必须创建两个 String 对象 -- 匹配器的输入和匹配的结果. 两个对象可能看起来不多,但是如果你给 MailBot 处理的每个邮件的每个头行都创建两个对象, 这会极大地影响性能. 错误不在于 MailBot 的设计, 而在于 BadRegExpMatcher 类的设计 -- 或者使用.
注意返回一个轻量型的 Match 对象 -- 可以提供 getOffset(), getLength(), egetMatchString() 方法 -- 而不是返回一个 String, 这不会很大提高性能. 因为创建一个 Match 对象可能比创建一个 String 代价要小 -- 包括产生一个 char[] 数组和复制数据, 你仍然创建了一个中间对象, 对你的调用者来说没有价值.
这已经足够坏了, BadREgExpMatcher 强迫你使用它想看到的输入形式, 而不是你可以提供的更有效的形式. 但是使用 BadRegExpMathcer 还有另一个危险, 潜在地给 MailBot的性能带来更大的冒险: 在处理邮件头的时候, 你开始有避免使用 String 的倾向. 但是既然你被迫创建许多 String 对象来满足 BadRegExpMatcher, 你可能被引诱而放弃这个目标, 更加自由地使用 String. 现在, 一个组件的糟糕的设计已经影响了使用它的程序.
甚至你后来找到了一个更好的正规表达式的组件, 不需要你提供一个 String, 那时你的整个程序都会受影响.
一个好一些的接口
你怎样定义 BadRegExpMatcher, 而不引起这样的问题呢? 首先, BadRegExpMatcher 应该不规定它的输入. 它应该可以接受它的调用者能够有效提供的各种输入格式. 第二, 它不应该自动给匹配结果产生一个 String; 应该返回足够的信息, 这样调用者如果愿意的话可以生成它. (为方便着想, 它可以提供一个方法来做这件事, 但不是必须的) 这里有一个好一些的接口:
class BetterRegExpMatcher {
public BetterRegExpMatcher(...); public int match(String inputText);
public int match(char[] inputText);
public int match(char[] inputText, int offset, int length); public int getNextMatch(); public int getMatchLength(); public String getMatchText();
}
新的接口减少了调用者把输入转换成匹配器希望的格式这个要求. MailBot 现在可以象下面这样调用 match():
int resultOffset = dateMatcher.match(myBuffer, thisHeaderStart,
thisHeaderEnd-thisHeaderStart);
if (resultOffset < 0) { ... }
这就解决了不创建任何新对象的目标. 作为一个附加的奖励, 它的接口设计风格加到了Java 的 "lots-of-simgle-methos" 设计哲学中.
额外的对象创建给性能的确切的冲击依赖于 matth() 所作的工作量. 你可以通过创建和计时两个正规表达式匹配器类, 来确定一个性能差别的上限. 在 Sun JDK 1.3 中, 上面的代码片段在 BetterRegExpMatcher 类中大约比 BadRegExpMatcher 类要快 50 倍左右. 使用一个简单的字串匹配的实现, BetterRegExpMatcher 比相对应的 BadRegExpMatcher 要快5倍。
交换类型
BadRegExpMatcher 强迫 MailBot 把输入文本从字符数组转换成 String, 结果是造成了一些不必要的对象的创建. 更具讽刺意味的是, BadRegExpMatcher 的许多实现都立即把 String 转换成一个字符数组, 使它容易对输入文本进行访问. 这样不仅仅申请了另一龆象, 并且还意味着你做完了所有的工作, 最后的形式和开始时一样. MailBot 和 BadRegExpMatcher都不想处理 String -- String 只是看起来象是在组件之间传递文本的很明显的格式.
在上面的 BadRegExpMatcher 例子中, String 类是作为一个交换类型的. 一个交换类型是一种不管是调用者还是被调用者都不想使用或者以它作为数据格式的一种类型, 但是两个都能很容易地转换它或者从它转换. 以交换类型定义接口在保持灵活性的同时减少了接口的复杂性, 但是有时简单性导致了高代价的性能.
一个交换类型最典型的例子是 JDBC ResultSet 接口. 它不可能象任何本地数据库提供的数据集一样提供它的 ResultSet 接口, 但是 JDBC 驱动通过实现一个 ResultSet 可以很容易地把数据库提供的本地数据表示包装起来. 同样, 客户端程序也不能象这样表示数据记录, 但是你几乎可以没有困难地把 ResultSet 转换为想要的数据表示. 在 JDBC 的例子中,你接受了这个层次的花费, 因为它带来了标准化和跨数据库实现的可移植性的好处. 但是,要注意交换类型带来的性能代价.
这完全不值得, 使用交换类型对性能的冲击不容易度量. 如果你对上面调用 BadRegExpMatcher的代码片段做测试的话, 它会在运行时创建 MailBot 的输入 String; 但是, String 的产生只用来满足 BadRegExpMatcher. 如果你想评定一个组件对程序性能的真正的冲击, 你应该不仅仅度量它的代码的资源使用状况, 还有那些使用它和恢复的代码. 这对于标准的测试工具此很难完成.
结论
不是所有的程序都关注于性能的, 不是所有的程序都有性能问题. 但是对那些关注这些的程序, 这篇文章所提到的都很重要, 因为它们不是在最后一分钟就可以修改的. 既然在你编写写代码使用一个类以后再修改它的接口非常困难, 那么在你的设计时期就花费一点额外的时间来考虑性能特性.
在第二部分, 我会演示一些利用可修改性和不可修改性来减少不必要的对象创建的方法.