javaIO2
一、性能优化的一般概念
人们普遍认为Java程序总是比C程序慢,对于这种意见,大多数人或许已经听得太多了。实际上,情况远比那些陈旧的主张要复杂。许多 Java程序确实很慢,但速度慢不是所有Java程序的固有特征。许多Java程序可以达到C或C++中类似程序的效率,但只有当设计者和程序员在整个开发过程中密切注意性能问题时,这才有可能。
本文的主要目的是讨论如何优化Java IO操作的性能。许多应用把大量的运行时间花在网络或文件IO操作上,设计拙劣的IO代码可能要比经过精心调整的IO代码慢上几倍。
说到Java程序的性能优化,有些概念总是一次又一次地被提起。本文的示例围绕IO应用的优化展开,但基本原则同样适用于其他性能情况。
对于性能优化来说,最重要的原则也许就是:尽早测试,经常测试。不知道性能问题的根源就无法有效地调整性能,许多程序员因为毫无根据地猜测性能问题的所在而徒劳无功。在一个只占程序总运行时间百分之一的模块上花费数天时间,应用性能的改进程度不可能超过百分之一。所以,应当避免猜测,而是采用性能测试工具,比如一些代码分析工具或带有时间信息的日志,找出应用中耗时最多的地方,然后集中精力优化这些程序的热点。性能调整完成后,应当再次进行测试。测试不仅有助于程序员把精力集中在那些最重要的代码上,而且还能够显示出性能调整是否真地取得了成功。
在调整程序性能的过程中,需要测试的数据可能有很多,例如运行总时间、内存占用平均值、内存占用峰值、程序的吞吐能力、请求延迟时间以及对象创建情况等。到底应该关注哪些因素,这与具体的情况和对性能的要求有关。大部分上述数据都可以通过一些优秀的商品化分析工具测试得到,然而,并非一定要有昂贵的代码分析工具才能收集得到有用的性能数据。
本文收集的性能数据只针对运行时间,测试所用的工具类似于下面的Timer类(可以方便地对它进行扩展,使它支持pause()和 restart()之类的操作)。带有时间信息的日志输出语句会影响测试结果,因为这些语句也要创建对象和执行IO操作,Timer允许我们在不用这类语句的情况下收集时间信息。
public class Timer { // 一个简单的“秒表”类,精度为毫秒。 private long startTime, endTime; public void start() { startTime = System.currentTimeMillis(); } public void stop() { endTime = System.currentTimeMillis(); } public long getTime() { return endTime - startTime; } } |
引起Java性能问题的常见原因之一是过多地创建临时对象。虽然新的Java虚拟机在创建许多小型对象时有效地降低了性能影响,但对象创建属于昂贵操作这一事实仍旧没有改变。由于字符串对象不可变的特点,String类常常是性能问题最大的罪魁祸首,因为每次修改一个String对象,就要创建一个或者多个新的对象。由此可以看出,提高性能的第二个原则是:避免过多的对象创建操作。
二、IO性能优化
许多应用要进行大规模的数据处理,而IO操作正属于那种细微的改动会导致巨大性能差异的地方。本文的例子来自对一个文字处理应用的性能优化,这个文字处理应用要对大量的文本进行分析和处理。在文字处理应用中,读取和处理输入文本的时间很关键,优化该应用所采用的措施为上面指出的性能优化原则提供了很好的例子。
影响Java IO性能最主要的原因之一在于大量地使用单字符IO操作,即用InputStream.read()和Reader.read()方法每次读取一个字符。 Java的单字符IO操作继承自C语言。在C语言中,单字符IO操作是一种常见的操作,比如重复地调用getc()读取一个文件。C语言单字符IO操作的效率很高,因为getc()和putc()函数以宏的形式实现,且支持带缓冲的文件访问,因此这两个函数只需要几个时钟周期就可以执行完毕。在Java 中,情况完全不同:对于每一个字符,不仅要有一次或者多次方法调用,而且更重要的是,如果不使用任何类型的缓冲,要获得一个字符就要有一次系统调用。虽然一个依赖read()的Java程序可能在表现、功能上和C程序一样,但两者在性能上不能相提并论。幸而,Java提供了几种简单的办法帮助我们获得更好的IO性能。
缓冲可以用以下两种方式之一实现:使用标准的BufferedReader和BufferedInputStream类,或者使用块读取方法一次读取一大块数据。前者快速简单,能够有效地改进性能,且只需少量地增加代码,出错的机会也较少。后者也即自己编写代码,复杂性略有提高——当然也说不上困难,但它能够获得更好的效果。
为测试不同IO操作方式的效率,本文用到了六个小程序,这六个小程序读取几百个文件并分析每一个字符。表一显示了这六个程序的运行时间,测试用到了五个常见的Linux Java虚拟机:Sun 1.1.7、1.2.2和1.3 Java虚拟机,IBM 1.1.8和1.3 Java虚拟机。
这六个程序是:
- RawBytes:用FileInputStream.read()每次读取一个字节。
- RawChars:用FileReader.read()每次读取一个字符。
- BufferedIS:用BufferedInputStream封装FileInputStream,用read()每次读取一个字节的数据。
- BufferedR:用BufferedReader封装FileReader,用read()每次读取一个字符。
- SelfBufferedIS:用FileInputStream.read(byte[])每次读取1 K数据,从缓冲区访问数据。
- SelfBufferedR:用FileReader.read(char[])每次读取1 K数据,从缓冲区访问数据。
表一 | |||||
Sun 1.1.7 | IBM 1.1.8 | Sun 1.2.2 | Sun 1.3 | IBM 1.3 | |
RawBytes | 20.6 | 18.0 | 26.1 | 20.70 | 62.70 |
RawChars | 100.0 | 235.0 | 174.0 | 438.00 | 148.00 |
BufferedIS | 9.2 | 1.8 | 8.6 | 2.28 | 2.65 |
BufferedR | 16.7 | 2.4 | 10.0 | 2.84 | 3.10 |
SelfBufferedIS | 2.1 | 0.4 | 2.0 | 0.61 | 0.53 |
SelfBufferedR | 8.2 | 0.9 | 2.7 | 1.12 | 1.17 |
表一是调整Java VM和程序启动配置之后,处理几百个文件的总计时间。从表一我们可以得到几个显而易见的结论:
- InputStream比Reader高效。一个char用两个字节保存字符,而byte只需要一个,因此用byte保存字符消耗的内存和需要执行的机器指令更少。更重要的是,用byte避免了进行Unicode转换。因此,如果可能的话,应尽量使用byte替代char。例如,如果应用必须支持国际化,则必须使用char;如果从一个ASCII数据源读取(比如HTTP或MIME头),或者能够确定输入文字总是英文,则程序可以使用byte。
- 无缓冲的字符IO实在很慢。字符IO本来就效率不高,如果没有缓冲,情形就更糟了。因此,在编程实践中,至少应该为流加上缓冲,它可以让IO性能提高10倍以上。
- 带有缓冲的块操作IO要比缓冲的流字符IO快。对于字符IO,虽然缓冲流避免了每次读取字符时的系统调用开销,但仍需要一次或多次方法调用。带缓冲的块IO比缓冲流IO快2到4倍,比无缓冲的IO快4到40倍。
从表一不易看出的一点是,字符IO可能抵消速度较快的Java VM带来的优势。在大多数性能测试中,IBM 1.1.8 Linux Java VM大约有Sun 1.1.7 Linux Java VM两倍那么快,然而在RawBytes和RawChars的测试中,结果显示出两者差不多慢,它们花在系统调用上的额外时间开销掩盖了较快Java VM带来的速度优势。
块IO还有另一个不那么明显的优点。缓冲的字符IO有时对组件之间的协调有更多的要求,带来更多的出错机会。很多时候,应用中的IO操作由一个组件完成,应用把一个Reader或InputStream传递给组件,然后,IO组件处理流的内容。一些IO组件可能错误地假设它所操作的流是一个带缓冲的流,但又不在文档中说明这方面的需求,或者虽然IO组件在文档中说明了这方面的要求,但应用的开发者却未能留意到这一点。在这些情况下,IO 操作将不按意料之中地那样带有缓冲,从而带来严重的性能问题。如果改用块IO,这类情形就不可能出现(因此,设计软件组件时,最好能够做到组件不可能被误用,而不要依赖于文档来保证组件的正确使用)。
从上述简单的测试可以看出,用最直接的方法完成一个简单任务,比如读取文本,可能比细心选择的方法慢40到60倍。在这些测试中,程序在提取和分析每一个字符时进行了一些计算。如果程序只是把数据从一个流复制到另一个流,则非缓冲的字符IO和块IO之间的性能差异将更加明显,块IO的性能将达到非缓冲字符IO的300到500倍。
三、再次测试
性能调整必须反复地进行,因为在主要性能问题解决之前,次要性能问题往往不能显露出来。在文字处理应用的例子中,最初的分析显示出程序把绝大部分的时间花费在读取字符上,加上缓冲功能后性能有了戏剧性的提高。只有在程序解决了主要的性能瓶颈(字符IO)之后,剩余的性能热点才显现出来。对程序的第二次分析显示出,程序在创建String对象上花费了大量的时间,而且看起来它为输入文本中的每一个单词创建了一个以上的String对象。
本文例子中的文本分析应用采用了模块化的设计,用户可以结合多个文本处理操作达到预期的目标。例如,用户可以结合运用单词标识器部件(读取输入字符并把它们组织成单词)和小写字母转换器部件(把单词转换成小写字母),以及一个还原器部件(把单词转换成它们的基本形式,例如,把 jumper和jumped转换成jump)。
虽然模块化构造具有很明显的优点,但这种处理方式会对性能产生负面影响。由于部件之间的接口是固定的(每一个部件都以一个String 作为输入,并输出另一个String),部件之间也许存在一些重复的操作。如果有几个部件经常组合在一起使用,对这些情形进行优化是值得的。
在这个文字处理系统中,从实际使用情况可以看出,用户几乎总是在使用单词标识器部件之后,紧接着使用小写字母转换器部件。单词标识器分析每一个字符,寻找单词边界,同时填充一个单词缓冲区。标识出一个完整的单词之后,单词标识器部件将为它创建一个String对象。调用链中的下一个部件是小写字母转换器部件,这个部件将在前面得到的String上调用String.toLowerCase(),从而创建了另一个String对象。对于输入文本中的每一个单词,顺序使用这两个部件将生成两个String对象。由于单词标识器部件和小写字母转换器部件频繁地一起使用,因此可以添加一个经过优化的小写字母单词标识器,这个标识器具有原来两个部件的功能,但只为每一个单词创建一个String对象,从而有利于提高性能。表二显示了测试结果:
表二 | ||||||
Sun 1.1.7 | IBM 1.1.8 | Sun 1.2.2 | Sun 1.3 | IBM 1.3 | ||
A | 单词标识 | 23.0 | 3.6 | 10.7 | 2.6 | 2.9 |
B | 单词标识 + 小写字母转换 | 39.6 | 6.7 | 13.9 | 3.9 | 3.9 |
C | 结合单词标识和小写字母转换 | 29.0 | 3.8 | 12.9 | 3.1 | 3.1 |
临时字符串创建时间 (B-C) | 10.6 | 2.9 | 1.0 | 0.8 | 0.8 |
从表二我们可以得到几个有用的发现:
- 对于Java VM 1.1,简单的优化引人注目地提高了性能:大约在百分之二十五到百分之四十五之间。最后一行显示出,创建临时String对象占用了程序A和程序B之间百分之六十到九十的性能增加值。另外,正如其他几个测试项目显示出的,IBM Java VM 1.1运行速度要比Sun Java VM 1.1快。
- 对于1.2和1.3的Java VM,两个版本之间的性能差异不再那么大,大约只有百分之十到百分之二十五之间,相当于创建临时String对象所耗时间的百分比。这个结果表明,在创建对象实例方面,版本较高的Java VM确实提高了效率,但过多的对象创建操作对性能的影响仍旧值得注意。
- 对于这类创建大量小型对象的操作,1.3版本的Java VM要比1.1和1.2版本的Java VM快得多。
性能优化是一种需要反复进行的工作。在开发工作的早期阶段开始收集性能数据是值得的,因为这样可以尽早地找出和调整性能热点。通过一些比较简单的改进,比如为IO操作增加缓冲,或在适当的时候用byte替代char,常常可以戏剧性地提高应用的性能。另外,不同的VM之间也有着很大的性能差异,简单地换上一个速度较快的Java VM,可能就让程序的性能向预期的目标跨出了一大步。