一、接口设计对性能的影响
许多开发者直到开发工作的最后一个周期才会考虑程序的性能,希望以此来彻底避免可能出现的性能问题。一些时候,他们的策略能够成功。然而,对于性能调整的必要性和最终是否能够成功,早期的设计决策将产生重大的影响。假如某个程序的性能可能会成为问题,那么,性能优化应当从设计和编写代码的第一天开始。
本文将从程序早期设计的角度探讨Java程序的性能。讨论围绕一个Java程序设计中常见的问题展开:创建临时对象。类的对象创建操作往往是在设计时确定的,假如不经全面深入的考虑,很可能为后期的性能问题留下隐患。
性能问题的产生有着多种原因。最简单的问题是程序员选择了一种拙劣的算法,例如用冒泡排序对一个规模很大的数据集进行排序,又如频繁地重新计算一个经常使用的数据,而不是缓冲这个数据。这类性能问题都很轻易发现,而且发现之后也可以很快地改正。然而,许多Java性能问题起源于一个更深层的、更难修正的地方——程序组件的接口设计。
今天,许多程序都使用了组件。这些组件有的是程序员自己设计的,有的是从外部购入的。即使程序并不真正地依靠于预先构造的组件,面向对象的设计原则也鼓励开发者采用组件,因为组件化设计有助于简化程序的设计、开发和测试。尽管组件化设计在这些方面的优势是不可否认的,但必须熟悉到,由组件实现的接口会对使用它的程序的行为和性能产生重要影响。
读到这里,也许有的读者会问,接口到底对性能有哪些影响呢?类的接口定义不仅规定了类可以调用哪些函数,而且还规定了它的对象创建过程以及为了使用对象而调用方法的过程。类如何定义构造函数和方法将决定对象是否可以重用,决定它的方法是否要创建(或要求客户程序创建)临时对象,决定客户程序为了使用该类而必须调用的方法数量。所有这些因素,都将对程序的性能产生影响。
改善Java程序性能的重要措施之一是:避免不必要的对象创建操作。创建对象是一种代价昂贵的操作,减少临时对象和交换对象(起过渡作用的对象)的创建是十分必要的。
Java的String对象是一个不可变的对象,每次修改或者构造字符串都会有一个新的String对象被创建。在涉及文本处理的程序中(比如Web应用),String对象占了很大的比重,避免或者减少String对象成为一个重要问题。下面我们就以String对象为例,看看如何减少不必要的对象创建操作。
二、实例:正则表达式匹配器
假设有一个邮件服务器MailBot,它要处理邮件MIME头中的发件日期和邮件地址信息。为减少创建String对象的操作,MailBot没有为邮件头中的每一行或每一个元素创建String对象,而是把这些信息放入一个字符缓冲区,然后调用正则表达式匹配器处理邮件头中的每一行。在这个方案中,正则表达式匹配器的性能是十分重要的。下面是一种可能的正则表达式匹配器接口设计:
public class AwfulRegEXPMatcher {
/** 以指定的正则表达式为基础,创建一个对指定输入字符串操作的
* 匹配器 */
public AwfulRegExpMatcher(String regExp, String inputText);
/** 查找输入文本的下一次模式匹配
* 返回匹配的文本,或返回null。*/
public String getNextMatch();
}
由于这个匹配器对象和输入文本紧密结合,每次调用它的时候,都需要创建一个新的匹配器对象。即使这个匹配器实现了高效的表达式匹配算法,在大量使用该对象的场合,它仍会对性能产生不利影响。为减少对象创建操作,以一种可重用的形式构造匹配器是十分必要的。
下面是另一种可能的接口定义,它支持匹配器重用,但效果仍然不是很好:
public class BadRegExpMatcher {
public BadRegExpMatcher(String regExp);
/** 匹配指定的正则表达式和输入文本,
* 如匹配成功,则返回匹配的字符串;否则
* 返回null */
public String match(String inputText);
/** 查找下一次匹配,如不能匹配,则返回null */
public String getNextMatch();
}
从表面上看,这个接口定义已经相当不错,问题在哪里呢?假如只考虑功能,它没有错;但从性能的角度来看,它存在不少问题。首先,这个匹配器要求调用者创建一个表示待匹配文本的String对象。尽管MailBot极力避免创建String对象,但为满足BadRegExpMatcher的要求,程序不得不创建一个String对象:
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要为每一个邮件头的每一行创建两个对象,累积的性能影响仍是相当可观的。
值得指出的是,我们可以用返回一个轻量级的Match对象的方法来替代返回String的方法,然后在Match对象中提供getOffset()、getLength()和getMatchString()方法,但性能的改善程度仍然有限。创建String对象意味着创建一个char[]数组以及复制全部数据,创建Match对象的开销或许比创建String对象稍小一些,但这种方案仍然要求创建中间对象,对于调用者来说这仍然是一种不小的开销。我们看到,拙劣的组件接口设计影响了使用组件的程序。即使到了最后,我们找到了一个更好的、不要求提供String对象的正则表达式组件,那时的代码修改工作可能也是相当复杂了。
三、改进接口设计
那么,如何定义BadRegExpMatcher才能避免出现上述问题呢?首先,BadRegExpMatcher不应该限制输入参数的类型,它应该能够接受各种便于调用者提供的输入格式。第二,BadRegExpMatcher不应该自动生成匹配结果的String表示形式,而是应该返回足够的信息,使得调用者能够在必要时得到匹配结果的String表示形式。下面是改进后的接口定义:
class BetterRegExpMatcher {
public BetterRegExpMatcher(...);
/** 支持各种输入形式的匹配器:字符串,字符数组,字符数组的子集
* 如不能匹配,则返回-1;如找到匹配结果,则返回匹配结果开始位
* 置的偏移量 */
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();
/** 返回匹配的字符串,为想要获得String形式返回值的调用者
提供方便 */
public String getMatchText();
}
新的接口降低了对输入文本格式的要求。现在,MailBot可以按照如下方式调用match():
int resultOffset = dateMatcher.match(myBuffer, thisHeaderStart,
thisHeaderEnd-thisHeaderStart);
if (resultOffset
改进后的接口在不创建任何新对象的条件下,达到了预期的目标。不仅如此,新的接口设计符合Java语言接口设计“大量的简单方法”的设计原则。
额外的对象创建操作对性能的具体影响程度与match()方法的工作量有关。要获得性能影响程度的上限,可以创建和测试两个空的正则表达式匹配器(不执行任何实际操作的正则表达式匹配器)。在Sun JDK 1.3环境下,分别用上面的代码片段调用空的BetterRegExpMatcher类和空的BadRegExpMatcher,前者的效率要比后者高出50倍。对于只支持子字符串匹配的简单实现,BetterRegExpMatcher要比BadRegExpMatcher快5倍。
四、交换类型
在前面的例子中,MailBot原先拥有一个字符数组,但BadRegExpMatcher强制MailBot提供一个字符串,因此MailBot不得不把字符数组转换成String再调用匹配器,从而导致一次应该可以避免的对象创建操作。具有讽刺意义的是,为了便于访问输入的文本,许多BadRegExpMatcher的实现又会马上把String转换成字符数组。这不仅又导致一次对象创建操作,而且它还意味着,做了那么多工作之后我们又回到了一开始就有的数据类型。无论是MailBot还是BadRegExpMatcher,两者实际上都不需要String类型的数据,String只是各个组件之间交换数据的一种格式。