优化Java(或任何其他类型的代码)的性能通常被视为一门暗黑艺术。性能分析有一个神秘之处——它通常被认为是骇客的一门手艺活。这些人往往能深入了解一个系统,并想出一个神奇的解决方案,使系统工作得更快。
这种形象经常与不幸的(但是非常常见的)情况联系在一起,在这种情况下,性能是软件团队的次要考虑因素。这设置了一个场景,在这个场景中,只有在系统已经陷入麻烦时才会进行分析,因此需要一个性能“英雄”来保存它。然而,现实情况略有不同。
事实是,性能分析是硬经验主义和软绵绵的人类心理的怪异结合。重要的是,一面是可观察指标的绝对值,另一面是最终用户和涉众对它们的感觉。本书其余部分的主题是如何解决这一明显的悖论。
1.1 Java Performance—The Wrong Way
多年来,谷歌上关于“Java performance tuning”的前三篇热门文章之一是一篇于1997-8年发表的文章,这篇文章在谷歌的历史上很早就被收录进了索引。据推测,这一页面之所以一直保持在顶部,是因为它的初始排名能够积极地推动访问量,从而形成一个反馈循环。
该页面包含了完全过时的、不再正确的、在许多情况下对应用程序有害的建议。然而,它在搜索引擎结果中所占据的有利位置却让很多开发者面临着糟糕的建议。
例如,非常早期的Java版本具有糟糕的方法分派性能。作为一种解决方案,一些Java开发人员提倡避免编写小方法,而是编写整体方法。当然,随着时间的推移,虚分派【virtual dispatch 】的性能有了很大的提高。不仅如此,现代Java虚拟机(JVM)尤其是自动管理内联【automatic managed inlining】,现在已经在大多数调用站点消除了虚分派【virtual dispatch 】。 现在,遵循“将所有东西都集成到一个方法”建议的代码现在处于非常不利的地位,因为它对现代即时(JIT)编译器非常不友好。
我们无法知道那些受到不良建议影响的应用程序的性能受到了多大的损害,但是这个案例巧妙地证明了不使用定量和可验证的性能方法的危险。它还提供了另一个很好的例子,说明为什么你不应该相信你在互联网上看到的一切。
Warning : Java代码的执行速度是高度动态的,基本上依赖于底层的Java虚拟机。在较新的JVM上,即使不重新编译Java源代码,旧的Java代码也可以执行得更快。
正如您所想象的,由于这个原因(以及我们稍后将讨论的其他内容),本书并不是一本提升代码性能的书。相反,我们专注于一系列方面,它们共同产生具有良好性能的工程:
- 在整个软件生命周期中的性能方法论
- 应用于性能的测试理论
- 测量、统计和工具
- 分析技能(系统和数据)
- 基础技术和机制
在本书的后面,我们将介绍一些用于优化的启发式和代码级技术,但是这些技术都带有开发人员在使用它们之前应该注意的警告和权衡。
TIP :请不要跳过这些部分,在没有正确理解给出建议的上下文的情况下就开始应用详细的技术。如果你对如何应用这些技术缺乏正确的理解,那么所有这些技术都是弊大于利的。
一般来说,有:
- JVM没有神奇的开关,让您的代码变得更快
- 没有“提示和技巧”使Java运行得更快
- 没有对你隐藏的秘密算法
当我们探索我们的主题时,我们将更详细地讨论这些误解,以及开发人员在处理Java性能分析和相关问题时经常犯的其他一些常见错误。还在这里看吗?好。然后我们来谈谈性能。
1.2 Java性能概述
要理解为什么Java的性能是这样的,让我们从Java的创建者James Gosling的经典名言开始:
Java是一种蓝领语言。它不是博士论文的材料,而是工作的语言。
也就是说,Java一直是一种非常实用的语言。它对性能的态度最初是,只要环境足够快,那么如果开发人员的生产力得到了提高,那么原始性能可能会被牺牲。因此,直到最近,随着HotSpot等jvm的日益成熟和成熟,Java环境才适合于高性能计算应用程序。
这种实用性在Java平台中以多种方式表现出来,但最明显的一个是托管子系统【managed subsystems】的使用。 其思想是开发人员放弃低级控制的某些方面,以换取不必担心所管理的功能的某些细节。
当然,最明显的例子就是内存管理。JVM以可插入垃圾收集子系统的形式提供了自动内存管理,因此程序员无需手动跟踪内存。
NOTE:托管子系统【Managed subsystems】在JVM中随处可见,它们的存在给JVM应用程序的运行时行为带来了额外的复杂性。
正如我们将在下一节中讨论的那样,JVM应用程序的复杂运行时行为要求我们将应用程序视为测试中的实验【experiments under test】。 这让我们思考观察到的测量数据,这里我们有一个不幸的发现。
JVM应用程序的性能度量通常不是正态分布的。这意味着基本的统计技术(例如标准差和方差)不适合处理JVM应用程序的结果。这是因为许多基本的统计方法隐含了一个关于结果分布正态性的假设。
理解这一点的一种方法是,对于JVM应用程序,极端值可能非常重要——例如对于低延迟的交易应用程序。 这意味着测量值的采样也存在问题,因为它很容易错过最重要的具体事件。
最后,我要提醒大家。很容易被Java性能度量所误导。环境的复杂性意味着很难隔离系统的各个方面。
度量也有开销,频繁的采样(或记录每个结果)可以对记录的性能数据产生可见的影响。Java性能数据的本质要求一定程度的统计复杂性,天真的技术在应用于Java/JVM应用程序时常常产生不正确的结果。
1.3 Performance as an Experimental Science
Java/JVM软件栈和大多数现代软件系统一样,非常复杂。事实上,由于JVM的高度优化和自适应特性,构建在JVM之上的生产系统可能具有一些难以置信的微妙和复杂的性能行为。摩尔定律和它所代表的硬件能力的空前增长使得这种复杂性成为可能。
计算机软件业最惊人的成就是它不断地抵消了计算机硬件工业所取得的稳定和惊人的成就
---Henry Petroski
虽然一些软件系统已经浪费了业界的历史收益,但是JVM在某种程度上代表了工程上的胜利。自上世纪90年代末JVM问世以来,它已经发展成一个非常高性能、通用的执行环境,可以很好地利用这些优势。然而,折衷之处是,与任何复杂的高性能系统一样,JVM需要一定的技能和经验才能获得最佳效果。
没有明确定义的度量比无用还要糟糕。
---Eli Goldratt
因此,JVM性能调优是技术、方法、可度量的数值和工具之间的综合。它的目标是以系统所有者或用户希望的方式实现可测量的输出。换句话说,性能是一门实验科学,它通过以下方式达到预期的结果:
- 定义期望的结果
- 测量现有系统
- 决定要做什么来达到要求
- 进行改善工作
- 重新测试
- 确定目标是否已经实现
定义和确定期望的性能结果的过程构建了一组量化目标。建立应该度量的内容并记录目标是很重要的,这些目标是项目工件和可交付成果的一部分。从这里,我们可以看到性能分析是基于定义并实现非功能性需求的。
如前所述,这一过程不是阅读鸡内脏或另一种占卜方法。相反,我们依赖统计数据和对结果的适当处理。在第5章中,我们将介绍准确处理JVM性能分析项目生成的数据所需的基本统计技术。
对于许多实际项目,无疑需要对数据和统计数据有更复杂的理解。鼓励您将本书中发现的统计技术视为一个起点,而不是一个明确的陈述。
1.4 A Taxonomy for Performance
在本节中,我们将介绍一些基本的性能指标。这些为性能分析提供了一个词汇表,并将允许您以定量的方式构建调优项目的目标。这些目标是非功能性需求,它们定义了性能目标。一组常见的基本性能指标是:
- Throughput:吞吐量
- Latency:延迟
- Capacity:容量
- Utilization:利用率
- Efficiency:效率
- Scalability:可扩展性
- Degradation:降级
我们将依次简要的讨论每一个指标。注意,对于大多数性能项目,并不是每个指标都将同时优化。在单个性能迭代中只改进几个指标的情况要常见得多,而且一次可以调优的指标可能就有这么多。在实际项目中,优化一个度量可能会损害另一个度量或一组度量。
Throughput:吞吐量
吞吐量是一个度量,表示系统或子系统可以执行的工作速度。这通常表示为某个时间段内的工作单元数。例如,我们可能感兴趣的是系统每秒可以执行多少事务。
为了使吞吐量号在实际的性能测试中有意义,它应该包括对所获得的参考平台的描述。例如,硬件规范、操作系统和软件堆栈都与吞吐量相关,测试系统是单个服务器还是集群也是如此。此外,测试之间的事务(或工作单元)应该是相同的。本质上,我们应该确保吞吐量测试的工作负载在运行之间保持一致。
Latency:延迟
性能指标有时通过一些比喻来解释,这些比喻让人联想到管道。如果一根水管每秒能产生100升水,那么1秒内产生的体积(100升)就是流量。在这个比喻中,延迟实际上是管道的长度。也就是说,处理单个事务并在管道的另一端查看结果所花费的时间。
它通常被引用为端到端时间。它依赖于工作负载,因此一种常见的方法是生成一个显示延迟随工作负载增加的函数的图。我们将在“Reading Performance Graphs”中看到这类图的一个例子。
Capacity:容量
容量是系统所拥有的并行工作的数量——即系统中可以同时进行的工作单元(例如事务)的数量。
容量显然与吞吐量相关,我们应该预期,随着系统上并发负载的增加,吞吐量(和延迟)将受到影响。因此,容量通常被引用为在给定的延迟或吞吐量值下可用的处理。
Utilization:利用率
最常见的性能分析任务之一是实现系统资源的有效利用。理想情况下,cpu应该用于处理工作单元,而不是空闲(或花时间处理操作系统或其他内务任务)。
根据工作负载的不同,不同资源的利用率之间可能存在巨大的差异。例如,计算密集型工作负载(如图形处理或加密)可能运行在接近100%的CPU上,但只使用了一小部分可用内存。
Efficiency:效率
将系统的吞吐量除以所使用的资源可以衡量系统的总体效率。直观地说,这是有道理的,因为需要更多的资源来产生相同的吞吐量是效率较低的一个有用定义。
在处理较大的系统时,也可以使用一种成本会计的形式来衡量效率。如果在相同的吞吐量下,解决方案A的总拥有成本(TCO)是解决方案B的两倍,那么它的效率显然是后者的一半。
Scalability:可扩展性
一个系统的整体或容量取决于可用于处理的资源。添加资源时吞吐量的变化是衡量系统或应用程序可伸缩性的一个指标。系统可伸缩性的圣杯是使吞吐量更改与资源完全同步。
考虑一个基于服务器集群的系统。例如,如果集群被扩展为双倍大小,那么可以实现什么吞吐量?如果新的集群可以处理两倍的事务量,那么系统将呈现“完美的线性伸缩”。“这在实践中是很难实现的,尤其是在各种可能的负载下。
系统可伸缩性依赖于许多因素,通常不是一个简单的常数因素。对于一个系统来说,在一定的资源范围内以接近线性的方式伸缩是非常常见的,但是在更高的负载下,会遇到一些限制,从而阻碍了完美的伸缩。
Degradation:降级
如果我们通过增加请求(或客户端)的数量或增加请求到达的速度来增加系统上的负载,那么我们可能会看到观察到的延迟和/或吞吐量的变化。
请注意,此更改取决于使用率。如果系统未被充分利用,那么在可观察到的更改发生之前应该会有一些空闲,但是如果资源被充分利用,那么我们期望看到吞吐量停止增长,或者延迟增加。这些变化通常被称为系统在额外负载下的退化
Connections Between the Observables
各种性能可观测对象的行为通常以某种方式联系在一起。此连接的详细信息将取决于系统是否在峰值实用程序中运行。例如,通常情况下,利用率会随着系统负载的增加而变化。但是,如果系统未充分利用,那么增加负载可能不会显著提高利用率。相反,如果系统已经受力,那么在另一个可观察到的载荷增加的影响。
另一个例子是,可伸缩性和降级都表示随着负载的增加,系统行为的变化。对于可伸缩性,随着负载的增加,可用的资源也会增加,而中心问题是系统是否可以利用它们。另一方面,如果增加了负载但没有提供额外的资源,则预期的结果是某些可观察到的性能下降(例如延迟)。
NOTE:在极少数情况下,额外的负载会导致违反直觉的结果。例如,如果负载的变化导致系统的某些部分切换到资源密集型但性能更高的模式,那么总体效果是减少延迟,即使接收到更多的请求。
举个例子,在第9章中,我们将详细讨论HotSpot的JIT编译器。为了被认为符合JIT编译的条件,方法必须“足够频繁地”以解释模式执行。因此,在低负载情况下,关键方法可能处于解释模式,但由于对方法的调用频率增加,这些方法在高负载情况下有资格进行编译。这会导致以后对同一方法的调用比以前的执行快得多。
不同的工作负载具有非常不同的特征。例如,金融市场上的交易,从一端到另一端看,可能有一个执行时间(即(延迟)数小时甚至数天。然而,在任何时候,大型银行都可能有数百万个这样的项目正在进行中。因此,系统的容量非常大,但是延迟也很大。
但是,让我们只考虑银行中的一个子系统。买方和卖方的匹配(本质上是双方就价格达成一致)称为订单匹配。在任何给定的时间,这个子系统可能只有数百个待处理订单,但是从接受订单到完成匹配的延迟可能只有1毫秒(在“低延迟”交易中甚至更少)。
在本节中,我们将介绍最常见的性能观察指标。偶尔会使用稍微不同的定义,甚至是不同的度量,但是在大多数情况下,这些是基本的系统编号,通常用于指导性能调优,并作为讨论感兴趣的系统性能的分类。
1.5 Reading Performance Graphs
为了结束这一章,让我们看看在性能测试中出现的一些常见行为模式。我们将通过观察真实可观测的图形来研究这些问题,并且在进行的过程中,我们还将遇到许多其他数据图形的示例。
图1-1中的图显示了在负载不断增加下,性能突然而意外地下降(在本例中是延迟)-----通常称为性能弯头【performance elbow】。
图1-1 A performance elbow
相比之下,图1-2显示了随着机器被添加到集群中,吞吐量几乎是线性扩展的更令人愉快的情况。这接近于理想的行为,并且只可能在非常有利的环境中实现----比如扩展无状态协议,不需要与单个服务器进行会话关联。
图1-2 Near-linear scaling
在第12章中,我们将遇到以IBM著名计算机科学家(“大型机之父”)Gene Amdahl的名字命名的Amdahl定律。图1-3显示了他对可伸缩性的基本约束的图形化表示;它显示了可能的最大加速比,它是用于任务的处理器数量的函数:
图1-3 Amdahl’s Law
我们展示了三种情况:底层任务是75%、90%和95%并行化的情况。这清楚地表明,无论何时工作负载中有任何部分必须串行执行,线性可伸缩性都是不可能的,并且对于可伸缩性的实现有严格的限制。这证明了图1-2周围的注释是合理的,即使在最好的情况下,线性可伸缩性也是几乎不可能实现的。
阿姆达尔定律所施加的限制令人吃惊地具有限制性。特别要注意的是,图形的x轴是对数的,因此即使算法(只有)5%的串行性,也需要32个处理器来实现12倍的加速。更糟糕的是,无论使用多少内核,最大加速比对于该算法来说都只是20倍。在实践中,许多算法的串行性远远超过5%,因此有一个更有约束的最大可能加速。
正如我们将在第6章中看到的,JVM垃圾收集子系统中的底层技术自然会产生一种“锯齿”模式的内存,用于没有压力的健康应用程序。我们可以在图1-4中看到一个示例。
图1-4 Healthy memory usage
在图1-5中,我们展示了另一个内存图,在调优应用程序的内存分配速率时,这个内存图非常重要。这个简短的示例展示了一个示例应用程序(计算fibonacci数)。它清楚地显示了分配速率在大约90秒时的急剧下降。
来自同一工具的其他图表(jClarity Censum)显示,应用程序此时开始出现主要的垃圾收集问题,因此由于来自垃圾收集线程的CPU争用,应用程序无法分配足够的内存。
我们还可以发现分配子系统正在以超过每秒4 GB的速度热分配。这远远超过了大多数现代系统(包括服务器类硬件)推荐的最大容量。当我们在第6章讨论垃圾收集时,我们将对分配这个主题进行更多的讨论。
图1-5 Sample problematic allocation rate
如果一个系统有一个资源泄漏,它更为常见的表现方式如图1 - 6所示,在一个可观测的(在这种情况下延迟)缓慢降级随着负载的增加,在达到一个拐点,系统迅速降级。
1.6 Summary
在本章中,我们已经开始讨论什么是Java性能,什么不是。我们已经介绍了经验科学和测量的基本主题,以及一个良好的性能练习将使用的基本词汇和可观察性。最后,我们介绍了性能测试结果中常见的一些情况。让我们继续并开始讨论JVM的一些主要方面,并为理解基于JVM的性能优化为何成为一个特别复杂的问题打下基础。