Java性能权威指南(第2版)
上QQ阅读APP看书,第一时间看更新

本书重点关注如何最好地利用JVM和Java平台API,让程序能运行得更快。但是很多外部因素也会影响性能。这些外部影响因素会时不时地在书中出现,它们并不是Java独有的,所以不一定会详细讨论。要知道,JVM和Java平台的性能只是性能优化主题的一小部分。

本节介绍了一些外部影响因素,这些因素和本书讨论的Java性能优化话题一样重要。本书以Java为基础的内容可以与这些因素互为补充,但其中许多已经超出了我们要讨论的范围。

本书会讨论Java影响应用程序性能的很多细节和大量调优标志,只是没有如-XX:+RunReallyFast的神奇设置。

归根结底,程序的性能取决于程序怎么写。如果使用循环遍历数组中的所有元素,那么JVM可以优化数组的边界检查方式,让循环运行得更快,还可以展开循环操作以提供额外的加速。如果使用循环是为了查找某个特定的元素,那么世界上还没有可行的优化方式,能让基于数组的代码和使用哈希映射(hash map)一样快。

好的算法对于提升性能是至关重要的。

我们中有些人写代码是为了赚钱,有些是为了好玩,有些是为了回馈社区,但我们所有人都在写代码,或在写代码的团队中工作。你很难觉得删减代码是在为项目做贡献,因为有些管理者还会用代码行数来评估开发人员。

我明白这种情况,但矛盾的是,写得好的小程序比写得好的大程序跑得快。对于所有的计算机程序都是如此,这自然也适用于Java程序。需要编译的代码越多,代码快速运行之前需要的时间就越长。需要分配后销毁的对象越多,垃圾回收器需要做的工作就越多。需要分配后持有的对象越多,GC周期就越长。需要从磁盘加载到JVM的类越多,程序开始运行需要的时间就越长。执行过的代码越多,放入机器的硬件缓存的可能性就越小。需要执行的代码越多,执行的时间就越长。

我把这概括为“积少成多”原理。开发人员会说,他们只是加了一个非常小的功能,根本用不了多长时间(尤其是不使用该功能的时候),同一个项目中的其他开发人员也这么说。结果是,性能突然下降了几个百分点。下个版本中再次发生了这种事,现在程序的性能已经降低了10%。这种事发生了几次之后,性能测试会触达某个资源的阈值——内存使用到达临界点、代码缓存溢出或其他类似的情况。在这些情况下,定期的性能测试可以找出性能下降的原因,性能优化团队也可以修复导致性能大幅下降的问题。不然随着时间的推移,小的性能损耗不断积累,修复就会变得越来越难。

我们最终会输掉这场战争

一个反常(令人沮丧)的地方是,每个应用程序的性能都会随着时间下降,准确地说是随着应用程序新版本的发布而下降。这种性能差异常常被忽略,因为硬件的改进可以保证新应用程序的运行速度。

想想看,如果在曾经运行Windows 95的计算机上运行Windows 10会发生什么。我之前最喜欢的一台计算机是Mac Quadra 950,但是它无法运行macOS Sierra(如果运行起来,相对于Mac OS 7.5它会非常非常慢)。从更低的层面看,Firefox 69.0似乎比前几个版本运行得快,但这些只是小版本。随着标签式浏览、同步滚动和安全特性的增加,Firefox要比Mosaic强大得多,但Mosaic加载我本地硬盘上的HTML文件的速度要比Firefox 69.0快50%。

当然,Mosaic已经无法加载几乎所有主流网站的真实URL了,我们不能再将Mosaic作为主要的浏览器了。这也印证了普遍认同的一点,特别是在两个小版本之间,代码可以在优化后运行得更快。这是性能优化工程师应该关注的地方。我们擅长这个工作的话,就能赢得战斗。

这是有用且有价值的事。我的观点并不是不应该优化现有应用程序的性能。讽刺的是,为了赶超竞品,添加新特性和使用新标准只会导致应用程序越来越大、越来越慢。

我不是倡导永远不应在产品中添加新特性或新代码,增强程序显然会带来好处。但是注意做好权衡,尽可能提高效率。

高德纳被认为是最早创造过早优化这个词的人。开发人员经常使用这个词来宣称,代码的性能重要不重要,得运行了才知道。也许你从不知道,完整的原话是这么说的:“大部分时间里,比如在97%的时间里,我们不应该对细枝末节进行优化。过早优化是万恶之源。”3

3到底是高德纳还是Topy Hoare先说的这句话,目前尚有争议,不过这句话在高德纳的文章“Structured Programming with goto Statements”中出现过。在文中,这句话是优化代码的一个论据,即使这需要类似goto语句这种不太优雅的解决方案。

这句话的意义在于,归根结底,你应该写出清晰明了、简单易懂的代码。这里的优化指的是,通过改变代码的算法和设计让结构复杂的程序也能有更佳的性能。这种类型的优化不应该先做,在分析了程序,发现这样做能带来很大益处后再去做。

这里所说的优化并不表示不能使用已知的性能糟糕的代码结构。如果对于每行代码都可以在两种简单、直接的代码中做出选择,那么请选择性能更好的那个。

在某种程度上,有经验的Java开发人员都能理解这一点(这是他们在日积月累中学到的一种优化艺术)。思考以下代码:

log.log(Level.FINE, "I am here, and the value of X is "
        + calcX() + " and Y is " + calcY());

这段代码使用的字符串连接看起来没必要。除非日志级别很高,否则这条消息并不会记录到日志里。如果日志消息没有打印,就没必要调用calcX()方法和calcY()方法。有经验的Java开发人员会本能地抗拒这样写。有些IDE会标记这些代码并建议对其修改(然而工具是不完美的,NetBeans IDE只会标记字符串连接,不会建议去掉不必要的方法调用)。

改成下面的日志代码会更好:

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中的元素。

如果你在开发不依赖外部资源的独立Java应用程序,那么该应用程序本身的性能几乎是最重要的。一旦外部资源(例如数据库)添加进来,那两者的性能就都很重要了。在分布式环境中,如果有Java REST服务器、负载均衡器、数据库和后端企业信息系统,那么Java服务器的性能可能是最不重要的部分。

本书并不关注系统的整体性能。在这样的环境中,必须对系统的各个方面采取有组织的优化方法。系统的CPU使用率、I/O延迟和各个部分的吞吐量都必须经过测量和分析。只有这样做,我们才能判断哪部分造成了系统瓶颈。有关该主题的资源非常丰富,相关的方法和工具也并不只适用于Java。本书假设你已经完成了上述分析,并确定了你的运行环境中需要改进的是Java部分。

bug和性能问题不只局限于JVM

本节只是以数据库性能为例进行说明,运行环境中的任何部分都可能是性能问题的根源。

我曾经有个客户在安装新版本的应用服务器时,遇到测试显示发送到服务器的请求越来越耗时,我根据奥卡姆剃刀原理考虑了可能导致这个问题的方方面面。

排除了一些因素后,性能问题依然存在。后端数据库没问题,所以最有可能出问题的是测试工具。经过测试后,我们发现负载生成器Apache JMeter是问题的源头。它将所有的响应都保留在一个列表中,当得到新的响应时,它会遍历整个列表去计算第90百分位响应时间(如果你不熟悉这个名词,可以查看第2章)。

整个系统的每一部分都可能造成性能问题。常见的案例分析表明,应该首先检测系统新增加的部分(经常在JVM应用程序里),但仍要准备好检查运行环境中的每一部分。

另外,不要忽视最初的分析。如果数据库是瓶颈(提示:它的确是),对访问数据库的Java应用程序进行优化不会提升整体性能。实际上,这有可能适得其反。一个通用的准则是,给已经过载的系统增加负载,系统的性能会变差。如果在Java应用程序中做了一些更改让它更高效,这也会给过载的数据库增加负载,所以实际上整体性能是下降的。若你就此得出“不应使用某项JVM改进”的结论,那就危险了。

给系统中低效的部分增加负载会让整个系统变慢,这并不局限于数据库。例如,将负载添加到CPU密集型服务器上,或者让更多线程去获取已经有线程等待的锁,又或者在其他类似场景中,这个原理都适用。第9章将展示一个仅用了JVM的极端示例。

我们很容易将性能的所有方面同等对待,特别是知道“积少成多”的典型表现之后。但我们应该关注常见的用例场景。这一原则体现在以下几个方面。

·通过分析来优化代码,并专注于优化其中最耗时的操作。注意,这不代表只需考虑整体里的一部分(见第3章)。

·使用奥卡姆剃刀原理来诊断性能问题。对性能问题最简单的解释就是最可能的原因:新增代码的bug比机器配置更有可能带来性能问题,而后者比JVM和操作系统的bug更有可能带来性能问题。操作系统或JVM的确存在潜在的bug,随着更多确定的因素被排除之后,的确有可能是测试用例在某种情况下触发了潜在的bug。但不要一开始就考虑这种可能性很小的情况。

·为应用程序的常见操作提供简单的算法。假设一个应用程序在求解一个数学公式,用户可以选择是在10%的误差范围内还是1%的误差范围内得到答案。如果多数用户对10%的误差感到满意,那就优化代码路径——即使这意味着提供具有1%误差的代码的速度会变慢。