求知 文章 文库 Lib 视频 iPerson 课程 认证 咨询 工具 讲座 Modeler   Code  
会员   
 
  
 
 
 
软件的性能设计
 

2010-10-08 作者:刘彦清 来源:网络

 

[前言]

开发人员一般都是在软件开发结束时才会考虑到性能管理问题。通常情况下,只有等到开发工作的尾声才会进行性能方面的调节工作,希望以此来避免在整个开发周期都需要考虑这个问题━━在许多情况下,这个策略也许是成功的。然而,早期的设计决策会对性能调节是否成功以及是否有必要进行性能调节产生影响。如果你开发的软件对性能非常敏感,从设计阶段和开发周期的第一天起就应该考虑到性能管理的问题。

接口设计对软件性能的影响

性能方面的问题有好多种。最容易修正的一种是,在执行一项计算任务时使用了一个性能不好的算法,例如,在对数目很多的数据进行排序时采用了起泡算法,每次使用时对一个经常使用的数据项进行计算而不是将它保存起来,这些问题一般我们都能很容易发现,而且一旦发现后,都能很方便地进行改正。然而,许多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倍。

临时对象对软件性能的影响

临时对象的存在时间一般都比较短暂,除了作为其他数据的容器外,没有其他什么用途,开发人员一般用它向方法传递数据或从方法中返回数据。文章的第一部分探讨了创建临时对象是如何影响程序性能的,并表明恰当的类的接口设计可以有效地减少临时对象的创建。通过避免设计这样的接口,就可以减少临时对象的创建,降低对程序性能的影响程度。在本篇文章中,我将讨论过多地创建临时对象的问题并在后面的文章中提供一些成熟的技术来避免过多地创建临时对象。

仅仅对String说NO?

说到创建临时对象,String类是最大的"罪魁祸手"。为了说明这一点,我在这篇文章的第一部分中开发了一个表达式匹配类的例子,并演示了一个看起来颇为正常的接口是如何因为创建了临时对象而比一个具有较好接口的类似的类运行速度慢数倍的。下面是最初的和性能较好的类的接口:

  BadRegExpMatcher

  public class BadRegExpMatcher {

   public BadRegExpMatcher(String regExp);

   /** 把输入文本与特定的表达式进行匹配,如果匹配则返回匹配的文本,否则返回空字符 */

   public String match(String inputText);

  }

  BetterRegExpMatcher

  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();

  }

大量使用BadRegExpMatcher的程序要比使用BetterRegExpMatcher的程序运行速度慢一些。第一,调用程序必须创建String对象向match()传递参数,match()也必须创建一个String对象向调用程序返回匹配的文本。每次调用时都会创建二个对象,这听起来也许没有什么大问题,但如果频繁地调用match(),创建这二个对象对性能产生的影响就大了。使用BadRegExpMatcher的程序的性能问题并不源于其编码而源于其接口,象这样设计的接口,临时对象的创建是不可避免的。

BetterRegExpMatcher用比较简单的数据类型(整型、字符数组)取代了在match()中使用的String对象,从而无需在调用程序和match()之间通过中间对象传递数据。

由于在设计阶段比在完成整个程序后再进行修改能够更好地避免程序性能方面的问题,因此应该在类的接口如何处理对象的创建这个问题上多花些时间。在RegExpMatcher中,其方法要求输入和返回String对象就可能对性能有潜在的影响,因为String类的对象是不可变的,因此对String类对象参数进行处理就会要求在每次调用时创建一个新的String对象。

由于不可变性通常与额外的对象创建联系在一起━━这大部分原因都要"归功"于其不可变性,许多编程人员就断定不可变的对象一定会影响程序的性能。其实真实的情况要复杂得多,实际上,不可变性有时还能够提升程序的性能,可变的对象也能够引起程序性能的下降,可变性对程序性能的影响取决于其使用方式。

程序会经常对文本字符串进行操作和修改━━不可改变性确实是一个麻烦。在每次对String进行操作时━━例如查找或选择一个前缀或子串,把它转换为大写或小写,或者将二个字符串合并成一个新的字符串时,就必须创建一个新的String类对象。

另一方面,我们可以自由地共享一个不可变对象的地址而无需担心对象会被改变,此时,不可变对象在性能上就比可变对象要好许多。

可变对象也存在临时对象问题

在RegExpMatcher中,当一个方法返回的数据类型为String类时,就有必要创建一个新的String类对象。在BadRegExpMatcher中存在的问题之一是match()返回的是一个对象而不是一个简单类型的数据━━因为一个方法返回一个对象,并不意味着一定会创建一个新的对象。考虑一下Point和Rectangle等java.awt中的几何类,一个Rectangle只不过是由四个整数━━左上角点的X、Y坐标以及宽度和高度组成的,AWT组件类存储了组件的位置并通过getBounds()方法将它作为一个Rectangle类对象返回:

  public class Component {
   ...
  public Rectangle getBounds();
  }

在上面的例子中,getBounds()方法仅仅起一个辅助性作用,它只是声明一些组件内部的有关信息。getBounds()真的必须创建它返回的Rectangle对象吗?也许是这样的吧,我们来看一下getBounds()的编码:

  public class Component {
   ...
  protected Rectangle myBounds;

  public Rectangle getBounds() { return myBounds; }
  }

当有程序调用上面例子中的getBounds()时,并不会创建新的对象,因为组件已经知道它的位置,因此getBounds()是比较高效的。然而,Rectangle的可变性还引起了其他问题,当一个调用它的程序执行下面的代码时会出现什么样的情况呢?

  Rectangle r = component.getBounds();
   ...
   r.height *= 2;

因为Rectangle具有可变性,上面的代码将引起组件的改变,对于象AWT这样的GUI工具包而言,这将是灾难性的,因为当一个组件变化时,需要重新刷新屏幕,同时还需要通知事件监视程序。因此上面的Component.getBounds()的运行是相当危险的,下面所示的方式才是比较安全的:

  public Rectangle getBounds() {
   return new Rectangle(myBounds.x, myBounds.y,
   myBounds.height, myBounds.width);
  }

但是,就象RegExpMatcher那样,每次调用getBounds()都会创建一个新的对象,下面的代码将会创建四个临时对象:
  int x = component.getBounds().x;
  int y = component.getBounds().y;
  int h = component.getBounds().height;
  int w = component.getBounds().width;

对于String类而言,创建对象是必要的,因为String是不可变的。但是在这个例子中,创建临时对象似乎也是必需的,因为Rectangle具有可变性,我们可以通过不在接口中使用任何对象来避免象String引起的那样的问题。尽管在与RegExpMatcher类似的场合中,这一方案并非总是可行的或理想的,然而,幸运的是,在设计类时可以采用一些技术,既能使用小一些的对象又不会遇到使用太多的小对象所引起的问题。

数据类型对软件性能的影响

BadRegExpMatcher要求MailBot将输入文本由字符数组转换为一个String对象,从而导致了不必要的对象创建。然而,滑稽的是为了更方便地使用输入文本,BadRegExpMatcher会立即将String对象转换为一个字符数组,这样不但会生成另一个对象,还意味着你在费了九牛二虎之力得到的结果,跟调用程序最初提供的数据没有什么二样,MailBot和BadRegExpMatcher都不要求使用String对象,String对象似乎仅仅是为也在组件之间交换文本数据而存在的。

在上面的BadRegExpMatcher例子中,String仅仅是一种供交换用的类型,无论是调用程序还是被调用程序都不需要使用供交换用的数据类型来表示数据,但它们可以很方便地把它与其他的数据类型进行转换。尽管在定义接口时采用供交换用的数据类型可以在保持灵活性的前提下降低复杂性,但有时会带来性能上的损失。

供交换用数据类型的一个最常见的例子是JDBC ResultSet接口,没有一种数据库的接口会用JDBC ResultSet表示返回的结果,但JDBC驱动程序可以很方便地把数据库返回的结果转换为JDBC ResultSet型数据。同样,也没有一种客户端软件用DBC ResultSet型数据表示数据记录,但你也可以不费什么劲儿就将ResultSet型数据转换为所需要的数据类型。在JDBC中我们可以接受这种"多此一举",因为它具有标准性和跨数据库的可移植性等好处。无论如何,由供交换用数据类型所带来的性能损失都是一个值得注意的问题。

在RegExpMatcher中,在定义接口时使用String对象对减少复杂性或增加可移植性的帮助并不大,对性能却有着相当大的影响,这么做是得不偿失的。在设计接口时使用可互换类型往往很有吸引力,因为这样能够使接口看起来更"干净"一些,但你应该确信你为此所作的折衷是明智的。有时━━就象RegExpMatcher那样,调用程序可以使用多种输入、输出格式,应该考虑如何能够更方便地满足它们的需求。

值得指出的是,对使用可互换类型对性能的影响进行量化不是一件容易的事儿。如果对调用BadRegExpMatcher的代码进行分析,它将指派运行时间库创建输入用的String对象,这个String对象是为满足BadRegExpMatcher的需求而生成的。如果要衡量一个组件对性能的真正影响,不仅需要评估代码的资源利用情况,还需要评估建立调用的代码以及调用返回的代码的资源利用情况,要使用标准的分析工具完成这些分析工作是非常困难的。

优化软件性能的方法

添加较小的辅助性函数

在最初版本的Swing工具包中,创建过多的Point、Rectangle和Dimension对象会严重地影响程序的性能。尽管一次在一个Point或Rectangle对象中返回多个值似乎更有效率,但这样作的代价要比调用多个方法高得多。在最近的Swing版本推出之前,这个问题可以通过简单地在组件或其他类中添加一些辅助性的方法得到改善,如下所示:

  public int getX() { return myBounds.x; }
  public int getY() { return myBounds.y; }
  public int getHeight() { return myBounds.height; }
  public int getWidth() { return myBounds.width; }

现在,调用程序可以在不创建临时对象的情况下得到同样的结果,如下所示:

  int x = component.getX();
  int y = component.getY();
  int h = component.getHeight();
  int w = component.getWidth();

原来的getBounds()仍然可以使用,较小的辅助性函数只不过提供了一种实现同样目标的效率更高的方法,其结果是,Rectangle的接口将完全暴露在组件的接口中。当修改Swing使之支持并可以使用这些较小的辅助性函数后,其结果是,Swing中的许多操作的运行速度都比原来快了二倍。由于GUI代码对性能比较敏感,这一改进的意义是重大的。

这种技术带来的负作用是对象拥有的方法更多了,而且要获取同一种信息可以有多种方法,这就使文件变得相当大而且也更加复杂了,不利于用户采用这一技术。但是,象Swing的例子显示的那样,在对性能要求比较高的情况下,这种优化技术还是十分有效的。

可变性的利用

除了在组件中添加象上面讨论的getX()等具有简单数据类型值的临时函数外,Java 2还使用了其他的技术来减少在AWT和Swing中的对象创建活动。在组件和其他的GUI类中添加另一种版本的getBounds(),就可以在没有创建临时对象的前提上使调用程序得到象Rectangle类型的返回值:

  public Rectangle getBounds(Rectangle returnVal) {

   returnVal.x = myBounds.x;

   returnVal.y = myBounds.y;

   returnVal.height = myBounds.height;

   returnVal.width = myBounds.width;

   return returnVal;

  }

调用程序仍然必须创建一个Rectangle对象,不过,可以在以后的调用中重用它。如果一个调用程序反复调用许多Component对象,就可以创建一个Rectangle对象并在每个Component中使用它。需要注意的是,这一技术只适用于可变的对象类型,不可能通过这种方式减少创建String类对象。

结合二家之长

一个更好的解决Point等简单类的对象创建问题的方法是使得Point类成为不可变的,然后再定义一个可变的子类,具体方法如下面的例子所示:

  public class Point {

   protected int x, y;

   public Point(int x, int y) { this.x = x; this.y = y; }

   public final int getX() { return x; }

   public final int getY() { return y; }

  }

  public class MutablePoint extends Point {

   public final void setX(int x) { this.x = x; }

   public final void setY(int y) { this.y = y; }

  }

  public class Shape {

   private MutablePoint myLocation;

   public Shape(int x, int y) { myLocation = new MutablePoint(x, y); }

   public Point getLocation() { return (Point) myLocation; }

  }

在上面的例子中,Shape可以安全地返回myLocation的地址,因为调用程序如果试图修改这些域或调用他们的"调节器"就会返回一个出错信息。当然,调用程序仍然可以将Point转换为MutablePoint,但很明显这会带来不安全性,虽然调用程序也会得到它们所需要的返回值。)C++的开发人员会注意到,这一技术与C++中的返回一个Rectangle的常量地址(const Rectangle&)有点类似━━Java不具备这样的特性。

在Java 1.3类库中的java.math.BigInteger类中,一个类无需创建新的对象就返回一个"只读的"对象。MutableBigInteger类不是公开的,它只供java.math类库内部使用。但由于BigInteger类中的一些方法(例如gcd())是由许多的算术操作组成的,完成这些操作而无需创建临时对象将极大地改善程序的性能。



专家视角看IT与架构
软件架构设计
面向服务体系架构和业务组件
人人网移动开发架构
架构腐化之谜
谈平台即服务PaaS


面向应用的架构设计实践
单元测试+重构+设计模式
软件架构师—高级实践
软件架构设计方法、案例与实践
嵌入式软件架构设计—高级实践
SOA体系结构实践


锐安科技 软件架构设计方法
成都 嵌入式软件架构设计
上海汽车 嵌入式软件架构设计
北京 软件架构设计
上海 软件架构设计案例与实践
北京 架构设计方法案例与实践
深圳 架构设计方法案例与实践
嵌入式软件架构设计—高级实践
更多...