
1.3 全面的性能调优
本书关注于如何以最佳方式利用JVM和Java平台API,让程序运行得更快。但除了这两点,还有许多外在的因素影响性能。书中这些因素时不时会出现,但因为它们不只影响Java,所以不会深入讨论。JVM和Java平台的性能只是高性能主题中的一小部分。
本书会覆盖一些外部因素,这些因素的重要性不亚于Java的性能调优。本书中基于Java的调优方法可以和这些因素相互补充,但这些因素多数已经超过了本书讨论的范围。
1.3.1 编写更好的算法
Java的许多细节和性能标志都可以影响应用的性能,只不过从来都没有一个叫-XX:+RunReallyFast的神奇标志。
归根结底,应用的性能取决于它的代码如何编写。例如,如果程序循环遍历数组中的所有元素,JVM就可以优化数组的边界检查,使循环更快,展开循环能提供额外的加速。但如果循环是为了找到特定元素,那目前还没有什么优化的办法,使得遍历数组和采用HashMap的版本一样快。
需要更高性能时,算法是否优秀就是重中之重了。
1.3.2 编写更少的代码
有些人写代码是为钱,有些是为乐趣,还有些人将代码回馈社区,但不管怎样,大家都是码农(或者在写程序的团队里工作)。很难想象,我们对项目的贡献是少写代码,因为仍然有管理者通过所写的代码量来评估开发人员的绩效。
我能理解这种想法,不过这种想法与现实并不吻合。同样是正确的程序,小程序运行起来要比大程序快。对所有的计算机程序来说都是如此,Java程序自然也不例外。要编译的代码越多,等待程序启动所耗费的时间就越长;要创建和销毁的对象越多,垃圾收集的工作量就越大;要分配和持有的对象越多,GC的周期就越长;要从磁盘装载进JVM的类越多,程序启动所花费的时间就越长;要执行的代码越多,机器硬件缓存的效率就越低;而执行的代码越多,花费的时间就越长。
无法取胜的战争
与直觉相反(和令人沮丧)的是,所有应用的性能都会随着时间,即应用新版本的发布而降低。但由于硬件的改善使得新程序的运行速度可以被接受,所以通常都不会有人注意到性能上的差异。
想象一下,在曾经运行Windows 95的机器上运行Windows Aero界面,会是什么样子?我以前喜欢Mac Quadra 950,但它无法运行Mac OS X(如果真这么做了,它将比Mac OS 7.5慢许多许多)。从更小的层次上看,Firefox 23.0比Firefox 22.0快,但它们之间的版本差别很小。具有按tab页浏览、同步滚动和安全特性的Firefox要比之前的Mosaic强大,但Mosaic从我硬盘里装载基本HTML文件的速度比Firefox 23.0快50%。
当然,Mosaic几乎不能从任何的热门网站上装载实际的URL,所以不太可能把Mosaic作为主要的浏览器。一般来说,特别是在两个小版本之间,代码会进行优化,从而运行得更快。性能优化工程师应该注意到这点。如果我们擅长这份工作,那就能赢得这场战斗。这是美好而有意义的事。我认为我们应该改善现有应用的性能。
但铁一般的事实是:随着新特性的添加和新要求的采纳(为了与对手竞争),程序会越来越大,越来越慢。
我把这总结为“积少成多”原则。开发人员总争辩说,只是增加了很小的功能,压根就不会有什么时间损耗(特别是不使用该功能的时候)。接着项目中的其他开发人员也同样拍着胸脯保证,结果却发现性能突然下降了好几个百分点。下次发布的时候又重复出现这样的情景,而此时程序性能已经下降了10%,反复几次这样的过程之后,性能测试就会检测到资源瓶颈——内存使用达到临界点、代码缓存溢出等情况。对于这些情形,常规的性能测试可以捕获发生状况的原因,性能调优小组也可以修正主要的性能衰减。但随着时间的推移,小衰减积少成多,会越来越难以修复。
我并不是在鼓吹永远不要为产品增加新特性或者新代码,很显然增强程序是有利可图的。但你得小心权衡,尽可能提高效能。
1.3.3 老调重弹的过早优化
“过早优化”一词公认是由高德纳发明的,开发人员常常据此宣称:只有在运行时才能知道代码的性能有多要紧。但你可能从来没注意到,完整的原话是“我们不应该把大量时间都耗费在那些小的性能改进上;过早考虑优化是所有噩梦的根源”。
这句名言的重点是,最终你应该编写清晰、直接、易读和易理解的代码。这里的“优化”应该理解为虽然算法和设计改变了复杂程序的结构,但是提供了更好的性能。那些真正的优化最好留到以后,等到性能分析表明这些措施有巨大收益的时候才进行。
而这里所指的过早优化,并不包括避免那些已经知道对性能不好的代码结构。每行代码,如果有两种简单、直接的编程方式,那就应该选择性能更好的那种。
在某种程度上,有经验的Java开发人员都能很好地领会到这点(这也是一个例证,说明他们日积月累而掌握了调优艺术)。思考以下代码:
log.log(Level.FINE, "I am here, and the value of X is" +calcX()+" and Y is "+calcY());
代码包含了一个看起来不太必要的字符串连接。因为除非日志级别很高,否则字符串的信息并不会记录到日志中,如果不打印日志消息,那就没必要调用calcX()和calcY()。有经验的Java开发人员会下意识地避免这种写法。有些IDE(例如NetBeans)会在代码上打标记并建议更改。(然而没有完美的工具:NetBeans会在字符串连接操作上打标记,却不会建议去掉不必要的方法调用。)
像这样的日志代码会更好:
if (log.isLoggable(Level.FINE)) { log.log(Level.FINE, "I am here, and the value of X is {} and Y is {}", new Object[]{calcX(), calcY()}); }
除非启用了日志功能,否则就可以在避免字符串连接(消息体中有格式化字符,不会提高性能,但使代码更清晰)的同时,避免方法调用或者对象分配。
这样写出来的代码仍然清晰易读,与原来的代码相比,没有太多额外工作。好吧,我们还是需要多敲几下键盘,多加一行逻辑。不过这仍然不属于应该避免的过早优化,它是好码农所熟悉的选择。在你思考如何写代码的时候,请不要生搬硬套前辈们的教条。
本书中我们还会看到其他例子,例如第9章讨论了处理Vector前先进行循环的性能。
1.3.4 其他:数据库很可能就是瓶颈
如果你开发的是独立运行不使用外部资源的Java应用,性能就(几乎)只与应用本身相关。一旦添加了外部资源(例如数据库),那这两者的性能就都很重要了。在分布式环境中,比如Java EE应用服务器、负载均衡器、数据库和后台企业信息系统,Java应用服务器的性能问题可能只是其中很小的部分。
本书并不关注整体系统的性能。对于整体系统,我们需要采取结构化方法针对系统的所有方面分析性能。CPU使用率、I/O延迟、系统整体的吞吐量都必须测量和分析。只有到那时,我们才能判定到底是哪个组件导致了性能瓶颈。关于这个主题有大量优秀的资源,相关的方法和工具也不只针对Java。假定你已经完成了分析,并且判断出是运行环境中Java组件的性能需要改善。
不只JVM有bug和性能问题
这节以数据库的性能为例,但运行环境的任何部分都可能会引起性能问题。
我曾经遇到过一个问题,客户正在安装新版本的应用服务器,而测试显示请求发送到服务器上的时间变得越来越长。于是我根据奥卡姆剃刀原则(参见下一条贴士),考察应用服务器中所有可能产生问题的部分。
逐一排除之后,性能问题依旧,而且我也没发现后台数据库有问题。因此最可能的原因是测试框架,通过性能分析判定负载发生器——Apache JMeter——才是性能衰退的原因。它将每个响应保留在列表中,每次有新响应到来时,它都要遍历整个列表,以便找到响应时间90%的请求(如果不熟悉这些词,请参见第2章)。
部署应用的系统,它的任何部分都可能会引起性能问题。常规案例分析建议应该首先考虑系统最新变动的部分(通常是JVM中的应用),但仍然要准备检查环境的每一个可能出现问题的组件。
另一方面,不要忽视初步分析。如果数据库是瓶颈(提示:的确是的话),那么无论怎么优化访问数据库的Java应用,都无助于整体性能;实际上可能适得其反。作为一般性原则,系统负载增加越大,系统性能就会越糟糕。如果更改了Java应用使得它更有效,这只会增加已经过载的数据库的负载,整体性能实际反而会下降。导致的风险是,可能会得出错误结论,即认为不应该改进JVM。
增加系统某个组件的负载从而导致整个系统性能变慢,这项原则不仅限于数据库。CPU密集型的应用服务器增加负载,或者越来越多线程试图获取已经有线程等待的锁,还有许多其他场景,也都适用这项原则。第9章展示了一个仅涉及JVM的极端例子。
1.3.5 常见的优化
如果所有的性能问题同等重要,从而“积少成多”地改进性能,那是多么吸引人。但常见的用例场景才是真正应该关注的重点。
我们可以从以下几方面阐述这条原则。
• 借助性能分析来优化代码,重点关注性能分析中最耗时的操作。然而请注意,这并不意味着只看性能分析中的叶子方法(参见第3章)。
• 利用奥卡姆剃刀原则诊断性能问题。性能问题最可能的原因应该是最容易解释的:新代码比机器配置更可能引入性能问题,而机器配置比JVM或者操作系统的bug更容易引入性能问题。隐藏的bug确实存在,但不应该把最可能引起性能问题的原因首先归咎于它,而只在测试用例通过某种方式触发了隐藏的bug时才关注。但不应该一上来就跳到这种不太可能的场景。
• 为应用中最常用的操作编写简单算法。以估算数学公式的程序为例,用户可以决定他所期望的最大容许误差为10%或1%。如果10%的误差适合多数用户,那么优化代码就意味着即便误差范围缩小为1%,但是速度变慢了。