软件的性能设计(一)接口设计对软件性能的影响
刘彦清·yesky
性能方面的问题有好多种。最容易修正的一种是,在执行一项计算任务时使用了一个性能不好的算法,例如,在对数目很多的数据进行排序时采用了起泡算法,每次使用时对一个经常使用的数据项进行计算而不是将它保存起来,这些问题一般我们都能很容易发现,而且一旦发现后,都能很方便地进行改正。然而,许多Java程序性能方面的问题都是是由一些比较深奥的、不容易修改的代码━━程序组件的接口设计引起的。
大多数的程序都是由内部人员开发的或从外部购买的组件"组装"而成的。即使软件不完全依赖于原有的组件,面向对象的设计过程也使得应用程序在开发时采用组件形式,因为这样可以简化程序的设计、开发和调试方面的工作。尽管采用组件的好处是不可否认的,我们还应该意识到组件的接口会对使用它们的程序的性能和运行状态产生重大的影响。
也许会有读者问,接口跟性能有什么关系?一个类的接口不但定义了类可以完成的功能,而且还定义了它的对象创建行为和使用它所需要调用的方法的顺序,一个类如何定义它的构造器和方法会影响这个对象是否可以重用,是它本身的方法创建还是要求其客户创建中间对象,客户要使用这个类需要调用多少个方法。
所有这些因素都会影响到程序的性能。Java软件性能管理方面的基本原理之一是:避免创建过多的对象。这并不意味着你不能创建任何对象从而不充分利用面象对象语言带来的诸多好处,而是说在开发对性能敏感的代码时需要对对象的创建保持谨慎。对象创建的代价相当高昂,我们应该在对性能敏感的软件中尽量避免创建临时或中间对象。
在处理字符的程序中,String类是引起对象创建的最大源。因为String类是不可变的,每当一个String类的对象被修改或构造时,都会创建一个新的对象。因此,一个具有性能意识的编程人员总是避免过多地使用String类对象。然而,尽管你在编程中尽量避免使用String对象,还是会经常发现使用的组件接口必须使用String对象,因此,你不可能不使用String类对象。
例子:表达式的匹配
作为一个例子,可以假设你在编写一个名字为MailBot的邮件服务器。MailBot需要处理每个邮件顶部的MIME头部━━例如发送日期或者发送者的邮件地址,它将通过使用一个匹配表达式的组件处理MIME头部,以使这一处理过程会更简单一些。它把输入的字符放在一个字符缓冲区中,通过对缓冲区进行索引处理标题。由于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的设计上。
注意:不返回String对象而返回一个"轻量级"的Match对象也不会在性能上带来很大的改进。尽管创建一个Match对象的代价要比创建一个String对象的代价低一些,它还是会产生一个char数组,并拷贝数据,仍然创建了一个对调用者并非必需的临时性的对象。
BadRegExpMatcher只接受它需要的输入数据类型,而不是可以接受我们方便提供的数据类型,仅就这一点,它就非常不理想。使用BadRegExpMatcher还会带来别的危害,其中的一个潜在的危害是这样将对MailBot的性能带来更多的影响。尽管在处理邮件的标题时必须避免使用Strings,但又必须创建许多的Strings对象供BadRegExpMatcher使用,因此你可能放弃不使用String对象的目标,而更加不受限制地使用它。一个设计不恰当的组件会影响使用它的程序的性能,即使以后找到了一个无需使用String对象的表达式组件,整个程序仍然会受到影响。
一个恰当的接口
如何定义BadRegExpMatcher才能避免上述的问题呢?首先,BadRegExpMatcher应该不指定其输入文本的格式,它应该能够接受其调用者可以高效地提供的任何一种数据类型。其次,它不应该为匹配结果自动地生成一个String对象,只需要返回足够的信息让调用者来决定是否需要生成匹配结果字符串。(也可以提供一个方法来完成这一任务,但这并非是必需的。)一个性能比较好的接口应该是这样的:
class BetterRegExpMatcher {
public BetterRegExpMatcher(...);
/** 使匹配器可以接受多种格式的输入━━ String对象、字符数组、字符组数的子集,如果不匹配,返回-1;如果匹配,则返回开始匹配的偏移地址。*/
public int match(String inputText);
public int match(char[] inputText);
public int match(char[] inputText, int offset, int length);
/** 如果匹配,则返回匹配的长度;如果不是完全匹配,则调用程序应该能够从匹配的偏移处生成匹配的字符串 */
public int getMatchLength();
/** 如果调用程序需要,就可以很方便地得到匹配字符串的子程序 */
public String getMatchText();
}
新的接口消除了调用者将输入文本转化为匹配子程序所要求的格式的需求。MailBot可以用如下的方式调用match():
int resultOffset = dateMatcher.match(myBuffer, thisHeaderStart, thisHeaderEnd-thisHeaderStart);
if (resultOffset < 0) { ... }
这样就既达到了设计目标又没有创建任何新的对象,另外,它的接口设计也体现了Java所倡导的"多而简单的方法"的设计思想。
创建对象对性能的精确影响取决于match()完成的工作量。通过创建和对二个不作任何实际工作的表达式匹配程序类的运行进行计时,就会发现它们在性能上存在着巨大的差异,在Sun 1.3 JDK中,使用BetterRegExpMatcher类的上述代码的运行速度比使用BadRegExpMatcher类快50倍。通过简单地支持子串匹配,BetterRegExpMatcher的运行速度就可以比BadRegExpMatcher快5倍。