Java-云原生优化指南-早期发布--全-

Java 云原生优化指南(早期发布)(全)

原文:zh.annas-archive.org/md5/df95e958a0ce92b3b5aecdf89067205b

译者:飞龙

协议:CC BY-NC-SA 4.0

第一章:优化与性能定义

优化 Java(或任何其他类型的代码)的性能通常被视为黑魔法。关于性能分析有一种神秘感——它通常被视为一种由“独行侠黑客,思考深刻”的技艺(好莱坞对计算机及其操作者最喜欢的题材之一)。这种形象是一个能深入了解系统并提出魔法解决方案使系统更快运行的个人形象。

这种情况经常伴随着不幸的(但却太常见)情况,即性能成为软件团队的次要关注点。这种情况造成了一个场景,即只有在系统陷入困境时才进行分析,因此需要一个性能“英雄”来拯救它。然而,现实却有些不同。

真相是,性能分析是一种奇怪的硬经验主义和软的人类心理学的结合体。重要的是,同时关注可观测指标的绝对数值以及最终用户和利益相关者的感受。解决这一明显悖论的方式是本书其余部分的主题。

自第一版出版以来,这种情况变得更加尖锐。随着越来越多的工作负载转移到云中,并且系统变得越来越复杂,将非常不同的因素结合在一起的奇怪混合物变得更加重要和普遍。关注性能的工程师需要操作的“关注领域”继续扩展。

这是因为生产系统变得更加复杂。现在更多的系统除了要考虑单个应用程序进程的性能外,还具有分布式系统的方面。随着系统架构变得更大更复杂,必须关注性能的工程师的数量也在增加。

本书的新版本通过提供四个内容来回应我们行业中的这些变化:

  • 需要深入探讨单个 JVM 内运行的应用代码的性能

  • JVM 内部的讨论

  • 现代云堆栈与 Java/JVM 应用程序的互动细节

  • 首次查看在云环境中运行的 Java 应用程序集群行为

在本章中,我们将通过一些定义来开始,建立一个关于性能讨论的框架——从一些问题和常见的 Java 性能讨论陷阱开始。

Java 性能——错误的方式

多年来,“Java 性能调优”在 Google 的前三大热门搜索中之一是一篇来自 1997-1998 年的文章,这篇文章早在 Google 历史早期就被纳入了索引。这篇页面显然一直保持在前列,因为其初始排名积极地吸引流量,从而形成了一个反馈循环。

页面上的建议完全过时,不再正确,并且在许多情况下对应用程序有害。然而,它在搜索引擎结果中的优势位置导致许多开发人员接受了糟糕的建议。

例如,早期版本的 Java 存在糟糕的方法调度性能。作为解决方法,一些 Java 开发人员主张避免小方法,而是编写单块方法。当然,随着时间的推移,虚拟分派的性能显著改善。

不仅如此,但是通过现代 JVM 技术(特别是自动管理的内联),在大量——甚至是多数——调用站点上,虚拟分派现在已经被消除了。遵循“将所有内容整合到一个方法中”的建议的代码现在处于明显的劣势,因为对现代即时编译器(JIT)非常不友好。

我们无法知道应用程序的性能受到了多大程度的损害,但这个案例清楚地展示了不使用定量和可验证的方法来处理性能的危险。这也再次非常好地说明了为什么不应该相信互联网上的所有内容。

注意

Java 代码的执行速度非常动态,基本上取决于底层的 Java 虚拟机。即使是一段老旧的 Java 代码,在更新的 JVM 上也可能会执行得更快,即使没有重新编译 Java 源代码。

你可能会想象,出于这个原因(以及我们稍后将讨论的其他原因),这本书不是性能技巧的烹饪书,适用于您的代码。相反,我们专注于一系列因素,这些因素共同作用以产生良好的性能工程:

  • 整个软件生命周期内的性能方法论

  • 测试理论在性能上的应用

  • 测量、统计和工具

  • 分析技能(系统和数据)

  • 底层技术和机制

通过将这些方面综合起来,意图是帮助您建立一个可以广泛应用于您可能面对的各种性能情况的理解。

书中稍后我们将介绍一些启发式和代码级优化技术,但这些都伴随着开发人员在使用之前应该了解的注意事项和权衡。

提示

请不要跳过这些部分,并开始应用详细描述的技术,而不正确地理解给出建议的背景。如果缺乏正确的应用理解,所有这些技术都可能带来更多的伤害而不是好处。

总的来说,有:

  • JVM 没有魔法“加速”开关

  • 没有“技巧和窍门”可以让 Java 运行更快

  • 没有被隐藏的秘密算法

当我们探索我们的主题时,我们将详细讨论这些误解,以及开发人员在接近 Java 性能分析和相关问题时经常犯的一些其他常见错误。

我们的“无提示和技巧”方法延伸到了我们对云技术的覆盖。你几乎找不到关于云超大规模(AWS、Azure、GCP、OpenShift 等)上的特定供应商技术的讨论。这有两个主要原因:

  • 这会扩展书籍的范围,并使其变得难以管理。

  • 在如此广阔的主题领域中保持最新是不可能的。

那些产品开发团队的进展会使得任何关于它们的详细信息在书籍出版时都已经过时。因此,在云章节中,我们侧重于基础知识和模式,而这些无论应用程序部署在哪个超大规模云平台上都有效。

你还在这里?很好。那么让我们谈谈性能。

Java 性能概述

要理解为什么 Java 性能是这样的,让我们从考虑 Java 的创始人詹姆斯·高斯林的经典语录开始:

Java 是一门蓝领语言。它不是博士论文的材料,而是为了工作的语言。¹

詹姆斯·高斯林

换句话说,Java 一直是一门极其实用的语言。它最初对性能的态度是,只要环境足够,那么如果开发者的生产力得到提升,就可以牺牲原始性能。因此,直到相对最近,随着 HotSpot 等 JVM(如 Java 虚拟机)日益成熟和复杂,Java 环境才适合高性能计算应用。

这种实用性在 Java 平台上以多种方式显现,但其中最明显的之一是使用管理子系统。这个想法是,开发者放弃了低级控制的一些方面,换取不必担心管理能力的某些细节。

最明显的例子当然是内存管理。JVM 通过可插拔的垃圾收集子系统(通常称为 GC)提供自动内存管理,这样程序员就不必手动跟踪内存。

注意

管理子系统遍布 JVM 的各个部分,并且它们的存在在 JVM 应用的运行时行为中引入了额外的复杂性。

正如我们将在下一节讨论的那样,JVM 应用的复杂运行时行为要求我们将应用视为正在测试的实验。这导致我们考虑观察测量的统计学,而在这里我们做出了一个不幸的发现。

JVM 应用的观察性能测量很多时候不是正态分布的。这意味着基本的统计技术(尤其是标准偏差方差等)不适合处理来自 JVM 应用的结果。这是因为许多基本的统计方法对结果分布的正态性有隐含的假设。

理解这一点的一种方法是,对于 JVM 应用程序来说,异常值可能非常重要—例如,对于低延迟交易应用程序。这意味着测量的抽样也是有问题的,因为很容易错过最重要的事件。

最后,警告一句。通过 Java 的性能测量很容易被误导。环境的复杂性意味着很难分离系统的各个方面。

测量也会带来开销,频繁采样(或记录每个结果)可能会对记录的性能数字产生明显影响。Java 性能数字的本质需要一定的统计学技术,而天真的技术在应用于 Java/JVM 应用程序时经常会产生错误结果。

这些问题也对云原生应用程序领域产生影响。自动管理应用程序已经成为云原生体验的一部分—特别是随着诸如 Kubernetes 等技术的兴起。在云原生应用程序的架构中,平衡数据收集成本和收集足够数据以得出结论的需要也非常重要—我们将在第十章进一步讨论这一点。

性能作为一门实验科学

Java/JVM 软件堆栈,就像大多数现代软件系统一样,非常复杂。事实上,由于 JVM 的高度优化和自适应特性,建立在 JVM 之上的生产系统可能具有一些微妙而复杂的性能行为。这种复杂性得益于摩尔定律及其代表的硬件能力的前所未有的增长。

计算机软件行业最令人惊讶的成就是其不断抵消计算机硬件行业所取得的稳定而惊人的进步。

亨利·彼得罗斯基(归属性)

虽然一些软件系统已经浪费了行业的历史收益,但 JVM 则代表了工程上的一种胜利。自从 1990 年代末问世以来,JVM 已经发展成为一个非常高性能的通用执行环境,充分利用了这些收益。

然而,与任何复杂的高性能系统一样,JVM 需要一定的技能和经验才能发挥其最佳性能。

如果目标没有明确定义,则测量就比没有用的还糟糕。²

埃利·戈德拉特

JVM 性能调优因此是技术、方法论、可测量数量和工具的综合体。它的目标是以系统的所有者或用户期望的方式产生可衡量的输出。换句话说,性能是一门实验科学—它通过以下方式实现期望的结果:

  • 定义期望的结果

  • 测量现有系统

  • 确定如何实现要求

  • 进行改进练习

  • 重新测试

  • 确定是否已经达到目标

确定和定义期望的性能结果的过程建立了一组定量目标。建立应该测量的内容并记录这些目标是重要的,这些目标随后成为项目成果和交付物的一部分。从中,我们可以看到性能分析是基于定义,然后实现非功能性需求。

如前所述,这个过程并不是读取鸡肠或其他占卜方法的过程。相反,我们依靠统计数据和对结果的适当处理(和解释)。

在本章中,我们将讨论这些技术如何应用于单个 JVM。在第二章中,我们将介绍一些基本的统计技术,这些技术对于准确处理从 JVM 性能分析项目中生成的数据是必需的。稍后,主要在第十章中,我们将讨论这些技术如何推广到一个集群应用程序,并产生可观察性的概念。

需要认识到,对于许多现实世界的项目来说,对数据和统计的更复杂的理解无疑是必需的。因此,建议将本书中找到的统计技术视为一个起点,而不是一个明确的陈述。

性能的分类

在本节中,我们介绍了一些性能分析的基本可观察量。这些提供了性能分析的词汇,并将允许您以定量术语框定调整项目的目标。这些目标是定义性能目标的非功能性要求。请注意,这些量并不一定在所有情况下直接可用,有些可能需要一些工作才能从我们系统得到的原始数据中获得。

一个常见的基本性能可观察集是:

  • 吞吐量

  • 延迟

  • 容量

  • 利用率

  • 效率

  • 可伸缩性

  • 退化

我们将依次简要讨论每个问题。请注意,对于大多数性能项目,不会同时优化每个指标。在单个性能迭代中仅改善少数指标的情况更为常见,这可能是可以同时调整的指标数量。在现实世界的项目中,优化一个指标很可能会损害另一个指标或一组指标。

吞吐量

吞吐量是一个表示系统或子系统可以执行的工作速率的指标。这通常表示为某个时间段内的工作单位数量。例如,我们可能对系统每秒执行多少个事务感兴趣。

在真实的性能测试中,吞吐量数值的意义在于应包括所获得的参考平台的描述。例如,硬件规格、操作系统和软件堆栈都与吞吐量相关,以及测试系统是单服务器还是集群。此外,事务(或工作单位)在测试之间应保持一致。基本上,我们应该努力确保吞吐量测试的工作负载在运行之间保持一致。

有时会通过引用管道等隐喻来解释性能指标。如果我们采纳这个观点,那么如果水管每秒可以产生 100 升水,则在 1 秒钟内产生的体积(100 升)就是吞吐量。请注意,此值取决于水的速度和管道的横截面积。

延迟

继续上一节的隐喻,延迟是指给定升数通过管道的时间。这取决于管道的长度和水流速度。然而,它与管道直径无关。

在软件中,延迟通常被引用为端到端时间,即处理单个事务并查看结果所需的时间。它取决于工作负载,因此常见的方法是生成一个显示延迟作为增加工作负载函数的图表。我们将在“阅读性能图表”中看到这种类型的图表示例。

容量

容量是系统拥有的工作并行性的量度,即可以同时进行的工作单位(例如,事务)的数量。

容量显然与吞吐量相关,我们应该期望随着系统的并发负载增加,吞吐量(和延迟)会受到影响。因此,通常会引用在给定延迟或吞吐量值时可用的处理能力。

利用率

性能分析中最常见的任务之一是实现系统资源的有效利用。理想情况下,CPU 应该用于处理工作单元,而不是空闲(或者花时间处理操作系统或其他管理任务)。

根据工作负载的不同,不同资源的利用率水平可能存在巨大差异。例如,计算密集型工作负载(如图形处理或加密)可能接近 100%的 CPU 使用率,但只使用了少量可用内存。

除了 CPU 之外,其他类型的资源,如网络、内存和(有时候)存储 I/O 子系统,在云原生应用中成为管理的重要资源。对于许多应用程序来说,内存的浪费比 CPU 更多,对于许多微服务来说,网络流量已成为真正的瓶颈。

效率

将系统的吞吐量除以利用的资源可以衡量系统的整体效率。直观地讲,这是有道理的,因为为了产生相同的吞吐量而需要更多资源是对效率低下的一个有用定义。

处理较大系统时,也可以使用一种成本会计来衡量效率。如果解决方案 A 的总拥有成本(TCO)是解决方案 B 的两倍,但吞吐量相同,则显然效率是后者的一半。

可扩展性

一个系统的吞吐量或容量当然取决于可用于处理的资源。系统或应用程序的可扩展性可以用多种方式来定义,但其中一个有用的方式是随着资源的增加而吞吐量的变化。系统可扩展性的圣杯是让吞吐量与资源的变化完全同步。

考虑一个基于服务器集群的系统。例如,如果扩展集群,使其大小加倍,那么可以实现什么样的吞吐量?如果新的集群能处理两倍于原来的交易量,那么系统就表现出“完美的线性扩展”。这在实践中非常难以实现,尤其是在可能的负载范围很广的情况下。

系统可扩展性取决于许多因素,并且通常不是简单的线性关系。一个系统在某些资源范围内通常会接近线性地扩展,但是在更高的负载下会遇到某种限制,阻止了完美的扩展。

退化

如果我们增加系统的负载,无论是增加请求到达的速率还是增加单个请求的大小,都可能导致观察到的延迟和/或吞吐量发生变化。

注意这种变化取决于利用率。如果系统利用率不足,那么在观察值发生变化之前应该有一些余地,但如果资源已被充分利用,则我们预期会看到吞吐量停止增加或延迟增加。这些变化通常称为系统在额外负载下的退化。

观察值之间的相关性

各种性能观察指标的行为通常以某种方式相互关联。这种连接的细节取决于系统是否运行在峰值效用。例如,通常情况下,当系统负载增加时,利用率会发生变化。然而,如果系统利用率不足,则增加负载可能不会明显增加利用率。相反,如果系统已经受到压力,则增加负载的影响可能会体现在其他观察值中。

举个例子,可扩展性和退化都代表系统在增加负载时行为的变化。对于可扩展性而言,随着负载的增加,可用资源也在增加,核心问题是系统是否能够利用它们。另一方面,如果增加负载但未提供额外资源,则某些性能观察值(例如延迟)的退化是预期的结果。

注意

在罕见情况下,额外的负载可能导致出乎意料的结果。例如,如果负载的变化导致系统的某部分切换到资源消耗更多但性能更高的模式,则总体效果可能是降低延迟,即使接收到更多请求。

举个例子,在第六章中我们将详细讨论 HotSpot 的 JIT 编译器。要考虑作为 JIT 编译的候选方法,“必须在解释模式下执行足够频繁”。因此,在低负载下,关键方法可能会停留在解释模式,但随着方法调用频率的增加,这些方法可能在更高负载时变得适合编译。这导致对同一方法的后续调用比早期执行快得多。

不同的工作负载可能具有非常不同的特性。例如,金融市场上的一项交易从开始到结束可能需要几小时甚至几天的执行时间(即延迟)。然而,在任何给定时间,一家主要银行可能有数百万个交易正在进行。因此,系统的容量非常大,但延迟也很大。

然而,让我们只考虑银行内的单个子系统。买方和卖方的匹配(本质上是各方就价格达成一致)被称为订单匹配。这个单独的子系统在任何给定时间可能只有数百个待处理订单,但是从接受订单到完成匹配的延迟可能只有 1 毫秒(或者在“低延迟”交易的情况下甚至更少)。

在本节中,我们遇到了最常见的性能观察值。偶尔会使用稍有不同的定义,甚至不同的指标,但在大多数情况下,这些将是通常用来指导性能调整的基本系统数字,并作为讨论感兴趣系统性能的分类法。

阅读性能图表

总结本章,让我们来看看在性能测试中经常出现的一些行为模式。我们将通过查看实际可观察的图表来探索这些模式,随着进一步的进行,我们还将遇到许多其他数据图表的例子。

图中的图 1-1 显示了在增加负载时(通常称为性能拐点)性能(在本例中是延迟)突然而意外的下降。

ocnj2 0101

图 1-1. 性能拐点

相比之下,图 1-2 展示了通过向集群添加机器来几乎线性扩展的吞吐量的更加愉快的情况。这接近理想行为,并且只有在极其有利的情况下才可能实现——例如,使用单个服务器扩展无需会话亲和性的无状态协议。

ocnj2 0102

图 1-2. 近线性扩展

在第十三章中,我们将会遇到阿姆达尔定律,这个定律以著名计算机科学家(“大型机之父”)IBM 的基因·阿姆达尔命名。图 1-3 展示了他关于可伸缩性的基本限制的图形表示;它展示了作为处理器数量函数的最大可能加速度。

ocnj2 0103

图 1-3. 阿姆达尔定律

我们展示了三种情况:底层任务的可并行性分别为 75%、90%和 95%。这清楚地表明,每当工作负载有必须串行执行的部分时,线性可伸缩性就不可能存在,并且对可实现的可伸缩性有严格的限制。这证实了对图 1-2 的评论——即使在最好的情况下,线性可伸缩性几乎不可能实现。

阿姆达尔定律施加的限制令人惊讶地严格。特别注意图的 x 轴是对数刻度,因此即使是一个 95%可并行化的算法(因此只有 5%串行化),也需要 32 个处理器才能实现 12 倍的加速。更糟糕的是,无论使用多少核心,该算法的最大加速度仅为 20 倍。在实践中,许多算法的串行部分远远超过 5%,因此其最大可能的加速度更为受限。

软件系统中性能图的另一个常见来源是内存利用率。正如我们将在第四章中看到的那样,JVM 的垃圾收集子系统中的底层技术自然地导致了健康应用程序中内存使用的“锯齿”模式。我们可以在图 1-4 中看到一个示例——这是来自由 Eclipse Adoptium 提供的 Mission Control 工具(JMC)的截图的近距离观察。

ocnj2 0104

图 1-4. 健康的内存使用

JVM 的一个关键性能指标是分配率——即它可以多快地创建新对象(以字节每秒计)。我们将在第四章和第五章详细讨论 JVM 性能的这一方面。

在图 1-5 中,我们可以看到分配率的放大视图,也是从 JMC 捕获的。这是从一个故意对 JVM 内存子系统施加压力的基准程序生成的。我们尝试让 JVM 达到每秒 8GiB 的分配,但正如我们所见,这超出了硬件的能力,因此系统的最大分配率在 4 到 5GiB/s 之间。

ocnj2 0105

图 1-5. 样本问题的分配率

注意,分配达到极限与系统存在资源泄漏是不同的问题。在这种情况下,它常常表现为类似于图 1-6 所示的方式,其中一个可观察指标(在本例中是延迟)随着负载增加而逐渐恶化,然后在达到一个拐点后系统迅速恶化。

ocnj2 0106

图 1-6. 在高负载下延迟恶化

让我们继续讨论一些在处理云系统时需要考虑的额外事项。

云系统性能

现代云系统几乎总是由节点集群(JVM 实例)通过共享网络资源进行交互操作的分布式系统。这意味着除了单节点系统的所有复杂性外,还必须解决另一个层次的复杂性。

分布式系统的运营人员必须考虑以下问题:

  • 工作如何在集群中的节点之间分配?

  • 我们如何将软件的新版本(或新配置)推广到集群?

  • 当节点离开集群时会发生什么?

  • 当新节点加入集群时会发生什么?

  • 如果新节点在某些方面配置错误会发生什么?

  • 如果新节点在某些方面与集群中的其余部分行为不同会发生什么?

  • 如果控制集群本身的代码出现问题会怎样?

  • 如果整个集群或其依赖的某些基础设施发生灾难性故障会怎样?

  • 如果基础设施中支持集群的某个组件是有限资源并且成为可扩展性的瓶颈会怎么样?

这些问题我们稍后会在本书中全面探讨,它们对云系统的行为有重大影响。它们影响关键性能指标,如吞吐量、延迟、效率和利用率。

不仅如此,还有两个非常重要的方面——与单个 JVM 情况不同——可能对初接触云系统的新手不明显。第一个是由集群的内部行为引起的许多可能影响,这对性能工程师可能是不透明的。

当我们在第十章详细讨论现代系统中的可观察性及如何实施解决方案以解决这一可见性问题时,我们会深入探讨这一点。

第二点是,服务在使用云提供商时的效率和利用率直接影响运行该服务的成本。低效和配置错误可能会直接体现在服务的成本基础上。事实上,这是考虑云兴起的一种方式。

在过去,团队通常会拥有位于数据中心专用区域(通常称为cages)内的实际物理服务器。购买这些服务器代表了资本支出,这些服务器被视为资产。当我们使用云服务提供商(如 AWS 或 Azure)时,我们租用的是实际由亚马逊或微软等公司拥有的机器时间。这是运营支出,是一种成本(或责任)。这种转变意味着我们系统的计算需求现在更容易被财务人员审查。

总体来说,重要的是要认识到云系统在根本上是由进程集群(在我们的情况下是 JVM)组成的,这些集群会随时间动态变化。这些集群可以增长或缩小,但即使不这样做,参与的进程随时间也会发生变化。这与传统基于主机的系统形成鲜明对比,那些形成集群的进程通常更长寿,并且属于已知且稳定的主机集合。

总结

在本章中,我们开始讨论 Java 性能的真实情况。我们介绍了经验科学和测量的基本主题,以及良好性能练习中将使用的基本词汇和可观察现象。我们介绍了从性能测试中常见的一些案例。最后,我们介绍了在云系统中可能出现的各种基本问题。

让我们继续讨论性能测试的一些重要方面,以及如何处理这些测试生成的数据。

¹ J. Gosling,《The feel of Java》,Computer,第 30 卷,第 6 号(1997 年 6 月):53-57

² E. Goldratt 和 J. Cox,《The Goal》,(Gower Publishing,1984)

第二章:性能测试方法论

进行性能测试有多种原因。在本章中,我们将介绍团队可能希望执行的不同类型的性能测试,并讨论每个子类型测试的一些最佳实践。

本章稍后我们将讨论统计数据和一些非常重要的人因素,这些因素在考虑性能问题时经常被忽视。

性能测试类型

经常以错误的原因进行性能测试,或者测试做得很差。造成这种情况的原因各不相同,但通常根源于未能理解性能分析的本质以及认为“做点什么总比什么都不做好”的信念。正如我们将在本书中多次看到的那样,这种信念通常最多只能算是半个真理,却往往是危险的。

一个更常见的错误是泛泛地谈论“性能测试”,而不深入讨论具体问题。事实上,可以对系统进行许多不同类型的大规模性能测试。

注意

良好的性能测试是定量的。它们提出能够产生数值答案的问题,并且可以作为实验输出进行统计分析。

本书将讨论的性能测试类型通常具有独立的(但有些重叠的)目标。因此,在决定应进行哪种类型的测试之前,了解您试图回答的定量问题非常重要。

这并不一定那么复杂——仅仅写下测试意图要回答的问题可能就足够了。然而,通常考虑为何这些测试对应用程序很重要,并通过应用程序所有者(或关键客户)确认这一点是常见的。

一些最常见的测试类型及其每种类型的示例问题如下:

延迟测试

系统的端到端事务时间是多少?

吞吐量测试

当前系统容量能够处理多少并发事务?

负载测试

系统能够处理特定负载吗?

压力测试

系统的破坏点在哪里?

耐久性测试

系统长时间运行时会发现什么性能异常?

容量规划测试

当增加额外资源时,系统是否按预期扩展?

退化

当系统部分失败时会发生什么?

让我们依次更详细地查看每种测试类型。

延迟测试

延迟通常是性能测试中最常见的类型之一,因为它通常是一个系统可观察的重点,对管理层(和用户)而言尤为关注:我们的客户等待交易(或页面加载)多长时间?

这可能是一个两面刃,因为延迟测试试图回答的问题的简单性可能会导致团队过分关注延迟。这反过来可能导致团队忽视识别其他类型性能测试的定量问题的必要性。

注意

延迟调优的目标通常是直接改善用户体验或满足服务级别协议。

然而,即使在最简单的情况下,延迟测试也有一些必须谨慎处理的微妙之处。其中最显著的之一是简单均值(平均值)并不是衡量应用程序对请求反应的非常有用的度量。我们将在 “JVM 性能统计” 中更全面地讨论这个主题,并探索额外的度量方法。

吞吐量测试

在性能测试中,吞吐量可能是第二常见的测试量。从某些角度来看,它甚至可以被视为延迟的对偶。

例如,当我们进行延迟测试时,重要的是在生成延迟结果分布时说明(和控制)并发事务计数。同样地,当我们进行吞吐量测试时,我们必须确保关注延迟,并检查在逐渐增加时它是否会增加到无法接受的值。

注意

系统的观察到的延迟应在已知和受控的吞吐量水平下进行说明,反之亦然。

我们通过注意到延迟分布突然变化的时刻来确定“最大吞吐量” —— 这实际上是系统的“突破点”(也称为拐点)。正如我们将在接下来的部分看到的,压力测试的目标是找到这些点及其发生的负载水平。

另一方面,吞吐量测试是关于在系统开始恶化之前测量观察到的最大吞吐量。再次强调,这些测试类型虽然单独讨论,但在实践中很少真正独立。

压力测试

将压力测试视为确定系统剩余空间的一种方法。测试通常通过将系统置于交易稳定状态来进行,即指定的吞吐量水平(通常是当前峰值)。然后,测试会逐渐增加并发事务,直到系统的观测值开始恶化。

在可观察到的开始恶化之前的数值决定了压力测试中达到的最大吞吐量。

负载测试

负载测试与吞吐量测试(或压力测试)不同,通常以二进制测试的形式出现:“系统能否处理预期的负载?”负载测试有时会在预期的业务事件之前进行,例如预计会大幅增加应用程序流量的新客户或市场上线。

可能需要执行这种类型测试的其他示例事件包括广告活动、社交媒体事件和“病毒式内容”。

耐久性测试

有些问题只在更长时间段(通常以天计)内显现。这些问题包括慢速内存泄漏、缓存污染和内存碎片化(特别是对于可能最终遭受 GC 并发模式失败的应用程序;有关更多细节,请参阅第五章)。

要检测这些问题类型,耐久性测试(也称为浸泡测试)是通常的方法。这些测试在系统的观察到的实际负载范围内以平均(或高)利用率运行。在测试期间,会密切监控资源水平,以发现任何资源耗尽或耗尽的情况。

这种测试在低延迟系统中更为常见,因为这些系统很常见地无法容忍因全 GC 周期(见第四章及后续章节了解有关全停顿事件和相关 GC 概念的更多信息)引起的停顿事件的长度。

耐久性测试并不像它们应有的频率那样经常进行,因为它们运行时间长且非常昂贵——但是没有捷径。另外,长时间内使用真实数据或使用模式进行测试也存在困难。这可能是团队最终“在生产环境中测试”的主要原因之一。

这种类型的测试在微服务或其他架构中并不总是适用,因为在短时间内可能会部署许多代码更改。

容量规划测试

容量规划测试与压力测试有很多相似之处,但它们是不同类型的测试。压力测试的作用是找出当前系统能够承受什么,而容量规划测试更具前瞻性,旨在找出升级系统能够处理的负载。

出于这个原因,容量规划测试通常作为计划性规划活动的一部分进行,而不是对特定事件或威胁的响应。

降级测试

曾经,严格的故障转移和恢复测试实际上只在最高度规范和审查的环境中(包括银行和金融机构)进行。然而,随着应用程序迁移到云端,基于 Kubernetes 等的集群部署变得越来越普遍。这带来的一个主要后果是,越来越多的开发人员现在需要意识到集群应用可能的故障模式。

注意

本书不包括所有弹性和故障转移测试的全面讨论。在第十五章中,我们将讨论云系统中出现部分集群故障或需要恢复时可能见到的一些较简单的影响。

在本节中,我们将仅讨论一种弹性测试类型,即降级测试——这种测试也被称为部分失效测试。

这种测试的基本方法是观察系统在模拟负载下运行时,当组件或整个子系统突然失去容量时的行为。例如,可能是应用服务器集群突然失去成员,或者网络带宽突然下降。

降级测试期间的关键观测点包括事务延迟分布和吞吐量。

部分故障测试中一个特别有趣的子类型被称为Chaos Monkey。这是 Netflix 为验证其基础设施的稳健性而开展的一个项目的名字。

Chaos Monkey 的想法是,在一个真正具有弹性的架构中,单个组件的故障不应该导致级联故障或对整体系统产生有意义的影响。

Chaos Monkey 通过在生产环境中随机终止活跃进程,迫使系统操作员面对这种可能性。

要成功实施 Chaos Monkey 类型的系统,组织必须拥有非常高的系统卫生、服务设计和运营卓越水平。尽管如此,它仍然是越来越多公司和团队感兴趣和渴望的领域。

最佳实践指南

在决定在性能调优中集中精力的地方时,有三个黄金规则可以提供有用的指导:

  • 确定你关心的内容,并找出如何衡量它。

  • 优化重要的,而不是容易优化的。

  • 先处理主要问题。

第二点的反面是提醒自己不要陷入过于重视任何容易测量的量的陷阱。不所有可观察的指标对业务都重要,但有时报告容易测量的指标,而不是正确的指标是很有诱惑的。

关于第三点,优化小事情的陷阱也很容易让人陷入,只是为了优化而优化。

自上而下的性能

Java 性能的一个方面,许多工程师初看时会忽略的是,大规模基准测试 Java 应用程序通常比尝试获得小代码段的准确数据要容易得多。

这是一个广泛误解的观点,为了刻意弱化它,我们在主要书文本中根本没有讨论微基准测试。相反,在附录 A 中讨论,这更准确地反映了该技术对大多数应用的实用性。

注意

从整个应用程序的性能行为开始的方法通常称为自上而下性能。

为了充分利用自上而下的方法,测试团队需要一个测试环境,清楚了解需要测量和优化的内容,并了解性能测试如何融入整体软件开发生命周期。

创建测试环境

设置测试 设置测试环境是大多数性能测试团队需要首先完成的任务。尽可能地,这应该是生产环境的准确复制,所有方面都要一致。

注意

一些团队可能会被迫放弃测试环境,仅使用现代部署和可观察性技术在生产中进行测量。这是第十章的主题,但除非必要,不建议采用这种方法。

这不仅包括应用服务器(应该具有相同数量的 CPU、相同版本的操作系统和 Java 运行时环境等),还包括 Web 服务器、数据库、消息队列等。任何服务(例如,难以复制或没有足够 QA 能力处理与生产等效负载的第三方网络服务)都需要为代表性性能测试环境进行模拟。

注意

通常无法有效运行的性能测试环境与它们所代表的生产部署明显不同——它们未能产生在实际环境中有用或具有预测力的结果。

对于传统(即非基于云的)环境,理论上实现类似生产环境的性能测试环境相对较为简单——团队只需购买与生产环境中使用的机器数量相同的机器,然后以与生产配置完全相同的方式配置它们。

管理层有时会对这种额外基础设施成本表示抵触。这几乎总是一种错误的经济理念,但遗憾的是,许多组织未能正确考虑停机的成本。这可能导致一种信念,即不拥有准确的性能测试环境的节省是有意义的,因为它未能正确计算由于 QA 环境与生产环境不匹配而引入的风险。

云技术的出现改变了这一局面。现在普遍采用更动态的基础设施管理方法。这包括按需和自动扩展基础设施,以及不可变基础设施的方法,也称为将服务器基础设施视为“牲畜而非宠物”。

理论上,这些趋势使得构建类似生产环境的性能测试环境变得更加容易。然而,这里存在一些微妙之处。例如:

  • 有一个允许首先在测试环境中进行更改,然后再迁移到生产环境的过程

  • 确保测试环境不具有依赖于生产环境的某些被忽视的依赖关系

  • 确保测试环境具有逼真的身份验证和授权系统,而不是虚拟组件

尽管存在这些担忧,能够在不使用时关闭测试环境的可能性是基于云的部署的一个关键优势。这可以为项目带来显著的成本节省,但需要一个适当的启动和按计划关闭环境的过程。

确定性能需求

系统的整体性能不仅仅由您的应用程序代码决定。正如我们将在本书的其余部分中发现的那样,容器、操作系统和硬件都起着重要作用。

因此,我们用来评估性能的度量标准不应仅仅考虑代码方面。相反,我们必须将系统作为一个整体和对客户和管理层重要的可观察量考虑进去。这些通常被称为性能 非功能性需求(NFRs),是我们想要优化的关键指标。

注意

在第七章中,我们将会遇到一个简单的系统模型,更详细地描述了操作系统、硬件、JVM 和代码之间的交互如何影响性能。

一些性能目标显而易见:

  • 减少 95% 百分位事务时间,减少 100 毫秒。

  • 改进系统,使得在现有硬件上的吞吐量提高 5 倍成为可能。

  • 改善平均响应时间 30%。

其他可能不太明显:

  • 减少为服务平均客户的资源成本 50%。

  • 确保系统即使在应用程序集群降级了 50% 的情况下,仍保持在响应目标的 25% 范围内。

  • 通过减少 10 毫秒的延迟来将客户“流失”率减少 25%。

与利益相关者进行开放讨论,确切地确定应该测量什么以及应该达到什么目标是至关重要的。理想情况下,这种讨论应该成为任何性能练习的第一次启动会议的一部分。

作为 SDLC 的一部分的性能测试

一些公司和团队更倾向于将性能测试视为偶发的、一次性的活动。然而,更复杂的团队往往会将持续性能测试,特别是性能回归测试,作为他们软件开发生命周期(SDLC)的一个组成部分。

这需要开发人员和基础设施团队之间的合作,以控制任何给定时间在性能测试环境中存在的代码版本。这也几乎不可能在没有专用测试环境的情况下实施。

Java 特定问题

性能分析的许多科学方法适用于任何现代软件系统。然而,JVM 的性质使得性能工程师必须注意并仔细考虑某些额外的复杂性。这些主要源于 JVM 的动态自管理能力,如内存区域的动态调整和 JIT 编译等。

例如,现代 JVM 分析正在运行的方法,以识别适合进行 JIT 编译以优化机器代码的候选方法。这意味着如果一个方法没有被 JIT 编译,那么该方法可能存在以下两种情况之一:

  • 它没有被频繁运行,因此不值得进行编译。

  • 这种方法过于庞大或复杂,无法进行编译分析。

第二个情况比第一个情况要少得多。在第六章中,我们将详细讨论 JIT 编译,并展示一些简单的技术,确保应用程序的重要方法被 JVM 选中进行 JIT 编译。

在讨论了一些性能的最佳实践之后,现在让我们转向团队可能会陷入的陷阱和反模式。

性能反模式的原因

反模式是指软件项目或团队的一种不良行为,在许多项目中都能观察到。¹ 发生频率导致结论(或怀疑)认为某些潜在因素导致了不良行为的产生。有些反模式乍一看似乎是合理的,其非理想的方面不会立即显现出来。其他反模式则是由负面项目实践随着时间的推移逐渐累积而成。

反模式的部分目录可以在附录 B 中找到—​第一类的一个例子是Distracted By Shiny,而Tuning By Folklore是第二类的例子。

在某些情况下,行为可能受到社会或团队的限制,或受到常见的错误管理技术的驱动,或者仅仅是人类(和开发者)的自然属性。通过对这些不良特征进行分类和归类,我们形成了一个用于讨论它们的模式语言,并希望能从我们的项目中消除它们。

性能调优应始终被视为一个非常客观的过程,在规划阶段早期设定明确的目标。这说起来容易做起来难:当团队承受压力或者不在合理的情况下运作时,这通常会被忽视。

许多读者可能会遇到这样的情况:一个新客户即将上线或者正在推出一个新功能,突然出现了故障—如果你运气好的话,是在用户验收测试(UAT)中,但通常是在生产环境中。然后团队只能抓瞎地寻找和修复导致瓶颈的原因。这通常意味着性能测试未进行,或者团队的“忍者”做出了假设,然后消失了(忍者在这方面很擅长)。

采用这种工作方式的团队往往比遵循良好性能测试实践并进行开放和理性对话的团队更容易陷入反模式。与许多开发问题一样,通常是人为因素,如沟通问题,而不是任何技术因素导致应用程序出现问题。

一个有趣的分类可能性在 Carey Flichel 的博客文章中提到,文章名为“为什么开发者继续做出糟糕的技术选择”。这篇文章明确指出了导致开发者做出糟糕选择的五个主要原因。让我们依次看一看。

无聊

大多数开发人员在工作中都曾经历过无聊的时刻,对一些人来说,这在他们寻求新的挑战或在公司内部或其他地方寻找新角色之前不必持续太长时间。然而,在组织中可能不存在其他机会,而去其他地方也可能不可行。

很可能许多读者遇到过一个只是在混日子的开发人员,甚至可能在积极寻求更轻松的生活。然而,无聊的开发人员可能以多种方式损害项目。

例如,他们可能会引入不需要的代码复杂性,比如在代码中直接编写排序算法,而简单的Collections.sort()就足够了。他们还可能通过使用未知的技术构建组件,或许并不适合使用情况,只是为了利用这些技术的机会来表达他们的无聊—这导致我们进入下一节。

简历填充

有时,技术的过度使用并不是由于无聊,而是开发人员利用机会来增加他们在简历(或 CV)上对特定技术的经验。

在这种情况下,开发人员正在积极尝试提高他们重新进入职场时的潜在薪水和市场竞争力。在一个运作良好的团队内,很少有人能逃脱这样做,但这仍然可能是导致项目走上不必要道路的根源选择。

由于开发人员无聊或简历填充而添加不必要的技术,其后果可能是深远的,而且持续时间很长,甚至在原始开发人员离开多年后仍然存在。

社会压力

在技术决策的时候,如果担忧没有被表达或讨论,通常会出现最糟糕的情况。这可能以几种方式表现出来;例如,也许一位初级开发人员不想在他们团队的更高级成员面前犯错,或者一位开发人员担心在某个特定主题上显得无知。

另一种特别有毒的社会压力类型是对于竞争激烈的团队来说,他们希望被视为开发速度很高,从而在完全探索所有后果之前匆忙做出关键决策。

缺乏理解

开发人员可能会试图引入新的工具来帮助解决问题,因为他们不了解当前工具的全部功能。通常会诱人的是转向一个新的和令人兴奋的技术组件,因为它擅长执行一个特定的任务。然而,引入更多的技术复杂性必须与当前工具的实际能力取得平衡。

例如,Hibernate 有时被视为简化领域对象和数据库之间转换的答案。如果团队对 Hibernate 的理解有限,开发人员可能会根据在另一个项目中看到它的使用情况做出适用性的假设。

这种缺乏理解可能导致对 Hibernate 的过度复杂使用和无法恢复的生产停机。相比之下,使用简单的 JDBC 调用重写整个数据层允许开发人员保持在熟悉的领域。

其中一位作者曾教授过一门包含一个与这种情况完全相同的参与者的 Hibernate 课程;他们试图学习足够的 Hibernate 以查看应用程序是否可以恢复,但最终在周末期间不得不将 Hibernate 剔除—绝对不是令人羡慕的处境。

被误解/不存在的问题。

开发人员经常使用技术来解决特定问题,其中问题空间本身尚未充分调查。如果没有测量性能值,几乎无法理解特定解决方案的成功性。通常汇总这些性能指标可以更好地理解问题。

要避免反模式,重要的是确保技术问题的沟通对所有团队成员开放,并积极鼓励。在事情不明确的情况下,收集事实依据并进行原型工作可以帮助引导团队决策。技术可能看起来很有吸引力;然而,如果原型不符合要求,团队可以做出更明智的决定。

想了解这些潜在原因如何导致各种性能反模式的读者可以查阅附录 B。

JVM 性能统计。

如果性能分析确实是一门实验科学,那么我们将不可避免地发现自己在处理结果数据的分布。统计学家和科学家知道,来自现实世界的结果几乎永远不会被清晰地表现出来。我们必须应对我们所发现的世界,而不是我们希望找到的过度理想化状态。

我们信赖上帝;其他人必须使用数据。²

W. Edwards Deming(归因)

所有测量都包含一定程度的误差。在下一节中,我们将描述 Java 开发人员在进行性能分析时可能遇到的两种主要类型的误差。

错误类型。

工程师可能遇到的两种主要误差源是:

随机误差。

测量误差或未连接因素以不相关方式影响结果。

系统误差。

一个未经考虑的因素以相关方式影响可观察量的测量结果。

每种误差类型都有特定的词汇。例如,准确性用于描述测量中系统误差的水平;高准确性对应低系统误差。同样,精度是与随机误差对应的术语;高精度意味着低随机误差。

图 2-1 中的图形显示了这两种误差对测量的影响。最左边的图像显示了(代表我们的测量)在真实结果周围聚集的射击。这些测量具有高精度和高准确度。

第二张图片存在系统效应(可能是误校准的准星?),导致所有射击偏离目标,因此这些测量具有高精度但低准确度。第三张图片显示射击基本在目标周围松散聚集,因此精度低但准确度高。最后一张图片显示没有明显的模式,因此既精度低又准确度低。

opjv 0501

图 2-1. 不同类型的误差

让我们继续详细探讨这些类型的误差,从随机误差开始。

随机误差

随机误差对大多数人来说应该很熟悉,它们是一个非常熟悉的领域。然而,在这里仍然值得一提,因为任何观察到或实验数据的处理都需要在某种程度上处理它们。

讨论假设读者熟悉基本的正态分布测量统计处理(均值、模式、标准差等);若读者不熟悉,应参考基础教材,比如生物统计手册³。

随机误差是由环境中未知或不可预测的变化引起的。在一般科学用法中,这些变化可能发生在测量仪器或环境中的任一方,但对于软件而言,我们假设我们的测量工具是可靠的,因此随机误差的来源只能是操作环境。

随机误差通常认为服从高斯(也称正态)分布。图 2-2 展示了几个典型的高斯分布示例。

opjv 0503

图 2-2. 高斯分布(又称正态分布或钟形曲线)

这种分布是当一个误差对观测值可能产生正面或负面贡献时的良好模型。然而,正如我们将在非正态统计部分中看到的那样,JVM 测量的情况稍微复杂一些。

系统误差

举例说明系统误差,考虑针对一组后端 Java Web 服务运行的性能测试,这些服务发送和接收 JSON。当直接使用应用程序前端进行负载测试存在问题时,这种类型的测试非常常见。

图 2-3 由 Apache JMeter 负载生成工具生成。图中实际上有两种系统效应。首先是顶部线路(异常服务)中观察到的线性模式,代表某些有限服务器资源的慢耗尽。

opjv 0502

图 2-3. 系统误差

这种模式通常与内存泄漏或在请求处理期间由线程使用但未释放的其他资源相关,并且可能需要调查——看起来可能是一个真正的问题。

注意

需要进一步分析以确认受影响的资源类型;我们不能简单地得出这是内存泄漏的结论。

应该注意到的第二个效应是大多数其他服务在约 180 毫秒水平上的一致性。这是值得怀疑的,因为服务在响应请求时做的工作量差异很大。那么为什么结果如此一致呢?

答案是,虽然测试中的服务位于伦敦,但这次负载测试是从印度孟买进行的。观察到的响应时间包括从孟买到伦敦的不可避免的往返网络延迟。这个范围是 120-150 毫秒,因此占据了除异常值之外服务的绝大部分观察时间。

这种大规模的系统效应淹没了实际响应时间的差异(因为服务实际上的响应时间远远小于 120 毫秒)。这是一个例子,说明了一个系统误差,并不代表我们的应用存在问题。

相反,这个错误源于我们的测试设置存在问题,因此好消息是当从伦敦重新运行测试时,这个人为因素完全消失了(如预期的那样)。

结束本节时,让我们快速看一下经常伴随系统误差出现的一个臭名昭著的问题——虚假相关性。

虚假相关性

统计学中关于“相关性不意味着因果关系”的最著名的格言之一是“相关性不意味着因果关系”——也就是说,只因为两个变量表现出类似的行为并不意味着它们之间存在根本联系。

在最极端的例子中,如果从业者足够努力地寻找,就可以发现完全不相关的测量结果之间存在关联。例如,在图 2-4 中,我们可以看到美国的鸡肉消费与原油总进口存在很好的相关性。⁴

opjv 0504

图 2-4。一个完全虚假的相关性(Vigen)

这些数字显然没有因果关系;没有任何因素同时推动原油进口和鸡肉食用。然而,从业者需要警惕的不是荒谬和可笑的相关性。

在图 2-5 中,我们看到视频游戏机所产生的收入与授予的计算机科学博士学位的数量相关。可以想象一项社会学研究声称这些可观察到的变量之间存在联系,也许会认为“压力山大的博士生通过几个小时的视频游戏来放松”。尽管事实上并不存在这样的共同因素,但这类主张却是令人沮丧地常见的。

opjv 0505

图 2-5. 一个不那么虚假的相关性?(维根)

在 JVM 和性能分析领域,我们需要特别小心,不要仅仅基于相关性就断定因果关系“看起来合理”。

第一原则是你不能欺骗自己——你是最容易被自己欺骗的人。⁵

理查德·费曼

我们已经见过一些错误来源的例子,并提到了虚假相关性和自欺欺人的陷阱,所以现在让我们继续讨论一下 JVM 性能测量的一个方面,这需要特别注意细节。

非正态统计

基于正态分布的统计学并不需要太多数学复杂性。因此,通常在高中或大学本科阶段教授的统计学标准方法,重点放在正态分布数据的分析上。

学生们被教导计算均值和标准差(或方差),有时还包括更高阶的矩,如偏度和峰度。然而,这些技术有一个严重的缺陷,即如果分布中有一些偏离较远的点,结果很容易变得扭曲。

在 Java 性能中,异常值代表了慢速交易和不满意的客户。我们需要特别注意这些点,并避免那些削弱异常值重要性的技术。

从另一个角度考虑:除非已有大量客户在投诉,否则改善平均响应时间可能并不是一个有用的性能目标。当然,这样做会改善每个人的体验,但往往只有少数不满的客户才是延迟调优的原因。这意味着异常事件可能比那些接受满意服务的大多数人更值得关注。

在图 Figure 2-6 中,我们可以看到方法(或事务)时间可能分布的更真实的曲线。显然,这不是一个正态分布。

opjv 0506

图 2-6. 更真实地展示了交易时间分布的视角

在图 Figure 2-6 中显示的分布形状,直观上展示了我们对 JVM 的认知:它具有“热路径”,所有相关代码已经 JIT 编译,没有 GC 周期等。这代表了一个最佳情况(尽管是常见情况);因此,没有因随机效应而“稍微快一些”的调用。

这违反了高斯统计的基本假设,并迫使我们考虑非正态分布的情况。

对于非正态分布的情况,许多正态分布统计的“基本规则”都被违反了。特别是,标准差/方差以及其他更高阶的矩基本上是无用的。

处理 JVM 生成的非正常“长尾”分布非常有用的一种技术是使用百分位数的修改方案。请记住,分布是一整套点的集合——数据的形状,并且不适合用单个数字来代表。

我们可以使用百分位数的采样,而不是仅仅计算平均值,后者试图用单一结果来表达整个分布。当用于正态分布数据时,通常会在固定间隔内进行采样。然而,通过小幅调整,该技术可以更有效地用于 JVM 统计数据。

修改的方法是使用从平均值开始的采样,然后是第 90 百分位数,然后按对数方式向外移动,如下所示的方法定时结果。这意味着我们按照更符合数据形状的模式进行采样:

50.0% level was 23 ns
90.0% level was 30 ns
99.0% level was 43 ns
99.9% level was 164 ns
99.99% level was 248 ns
99.999% level was 3,458 ns
99.9999% level was 17,463 ns

样本告诉我们,虽然平均执行获取器方法的时间为 23 ns,但每 1,000 次请求中有一次的时间比平均值差一个数量级,而每 1,000,000 次请求中有一次的时间比平均值差两个数量级。

长尾分布也可以称为高动态范围分布。可观察量的动态范围通常定义为最大记录值除以最小记录值(假设非零)。

对数百分位数是理解长尾的一种有用的简单工具。然而,对于更复杂的分析,我们可以使用处理高动态范围数据集的公共领域库。该库称为 HdrHistogram,可以从Github 上获取。最初由 Gil Tene(Azul Systems)创建,Mike Barker 和其他贡献者进行了额外工作。

注意

直方图是通过使用有限的一组范围(称为)来总结数据,并显示数据落入每个桶的频率的一种方法。

HdrHistogram 也可以在 Maven 中心获取。在撰写本文时,当前版本为 2.1.12,您可以通过将此依赖项段添加到pom.xml来将其添加到您的项目中:

<dependency>
    <groupId>org.hdrhistogram</groupId>
    <artifactId>HdrHistogram</artifactId>
    <version>2.1.12</version>
</dependency>

让我们看一个使用 HdrHistogram 的简单例子。此示例接受数字文件并计算连续结果之间的 HdrHistogram:

public class BenchmarkWithHdrHistogram {
    private static final long NORMALIZER = 1_000_000;

    private static final Histogram HISTOGRAM
            = new Histogram(TimeUnit.MINUTES.toMicros(1), 2);

    public static void main(String[] args) throws Exception {
        final List<String> values = Files.readAllLines(Paths.get(args[0]));
        double last = 0;
        for (final String tVal : values) {
            double parsed = Double.parseDouble(tVal);
            double gcInterval = parsed - last;
            last = parsed;
            HISTOGRAM.recordValue((long)(gcInterval * NORMALIZER));
        }
        HISTOGRAM.outputPercentileDistribution(System.out, 1000.0);
    }
}

输出显示了连续垃圾收集之间的时间间隔。正如我们将在第四章和第五章看到的那样,GC 并不是以固定的间隔发生的,理解它发生频率的分布可能会很有用。以下是直方图绘制器为示例 GC 日志生成的内容:

       Value     Percentile TotalCount 1/(1-Percentile)

       14.02 0.000000000000          1           1.00
     1245.18 0.100000000000         37           1.11
     1949.70 0.200000000000         82           1.25
     1966.08 0.300000000000        126           1.43
     1982.46 0.400000000000        157           1.67

...

    28180.48 0.996484375000        368         284.44
    28180.48 0.996875000000        368         320.00
    28180.48 0.997265625000        368         365.71
    36438.02 0.997656250000        369         426.67
    36438.02 1.000000000000        369
#[Mean    =      2715.12, StdDeviation   =      2875.87]
#[Max     =     36438.02, Total count    =          369]
#[Buckets =           19, SubBuckets     =          256]

格式化程序的原始输出相当难以分析,但幸运的是,HdrHistogram 项目包含一个在线格式化程序,可用于从原始输出生成可视化直方图。

例如,对于此示例,它生成类似于图 2-7 所示的输出。

opjv 0507

图 2-7. 示例 HdrHistogram 可视化

对于许多我们希望在 Java 性能调优中测量的可观测量,统计数据通常是高度非正态的,而 HdrHistogram 可以是帮助理解和可视化数据形状的非常有用的工具。

统计解释

经验数据和观察结果并不孤立存在,从我们测量应用程序获得的结果来看,最困难的工作之一往往在于解释这些结果。

无论问题是什么,它始终是一个人的问题。

杰拉尔德·温伯格(署名)

在图 2-8 中,我们展示了一个真实 Java 应用程序的示例内存分配速率。此示例适用于性能良好的应用程序。

opjv 0508

图 2-8. 示例分配速率

分配数据的解释相对直观,因为存在明显的信号。在覆盖的时间段内(接近一天),分配速率基本稳定在每秒 350 到 700 MB 之间。大约在 JVM 启动后的 5 小时左右开始出现下降趋势,并在 9 到 10 小时之间达到明显的最低点,之后分配速率开始再次上升。

这类可观测量的趋势非常普遍,因为分配速率通常会反映应用程序实际执行的工作量,而这将根据一天中的时间段而大幅变化。然而,当我们解释真实的可观测量时,情况可能会迅速变得更加复杂。

这可能导致所谓的“帽子/大象”问题,源自《小王子》中安东尼·德·圣-埃克苏佩里的一段文字。在这本书中,叙述者描述自己六岁时画了一幅吞食大象的蟒蛇的图画。然而,由于视角是外部的,这幅画在故事中成人们无知的眼中只是一个略显无形的帽子。

这个隐喻作为对读者的告诫,要有想象力,要更深入地思考你所看到的东西,而不是仅仅接受表面上的浅显解释。

应用于软件的问题可以通过图 2-9 来说明。最初我们只能看到一个复杂的 HTTP 请求-响应时间直方图。然而,就像书中的讲述者一样,如果我们能够多想象或分析一些,我们就会发现这个复杂的画面实际上是由几个相当简单的部分组成的。

opjv 0509

图 2-9. 帽子,或被蟒蛇吞食的大象?

解码响应直方图的关键在于意识到“Web 应用程序响应”是一个非常一般的类别,包括成功的请求(所谓的 2xx 响应)、客户端错误(4xx,包括臭名昭著的 404 错误)和服务器错误(5xx,尤其是 500 服务器内部错误)。

每种类型的响应时间分布特征各不相同。如果客户端请求一个没有映射的 URL(即 404),那么 Web 服务器可以立即回复一个响应。这意味着仅客户端错误响应的直方图看起来更像是图 2-10。

opjv 0510

图 2-10. 客户端错误

相比之下,服务器错误通常发生在大量处理时间消耗后(例如,由于后端资源压力或超时),因此服务器错误响应的直方图可能看起来像图 2-11。

opjv 0512

图 2-11. 服务器错误

成功的请求将具有长尾分布,但实际上我们可能预期响应分布是“多模态”的,并且具有几个局部最大值。例如,图 2-12 中显示的例子,代表了应用程序可能存在两条常见执行路径,其响应时间完全不同的可能性。

opjv 0511

图 2-12. 成功请求

将这些不同类型的响应合并到单个图表中,结果如图 2-13 所示的结构。我们已经从分开的直方图中重新推导出了我们最初的“帽子”形状。

opjv 0513

图 2-13. 帽子或大象再访

将一个普通的可观察现象分解成更有意义的子群体的概念非常有用。这表明在我们试图从结果推断结论之前,我们需要确保充分理解我们的数据和领域。例如,成功的请求可能在主要是读取的请求和更新或上传的请求之间具有非常不同的分布。

PayPal 的工程团队已经广泛地写了关于他们使用统计和分析的内容;他们有一个博客,其中包含了很多优秀的资源。特别是 Mahmoud Hashemi 的文章“软件统计”,是对他们方法论的很好介绍,并包含了前面讨论过的帽子/大象问题的版本。

还值得一提的是“数据龙头十二” ——一组具有相同基本统计特征但外观迥异的数据集。⁶

认知偏差与性能测试

人类在迅速形成准确观点方面可能表现不佳——即使面对可以借鉴过去经验和类似情况的问题。

认知偏差是一种心理效应,导致人类大脑得出错误的结论。这种情况尤为棘手,因为表现出此类偏差的人通常并不自知,并可能认为自己的行为是理性的。

我们观察到的许多性能分析反模式(例如附录 B 中的反模式,您可能希望与本节一起阅读)的原因,全部或部分是由于一个或多个认知偏见造成的,这些偏见又基于无意识的假设。

例如,对于Blame Donkey反模式,如果某个组件导致了几次最近的故障,团队可能会倾向于期望同一组件导致任何新的性能问题。分析的任何数据,如果确认了 Blame Donkey 组件负责,可能更容易被视为可信。

该反模式结合了被称为确认偏见和最近性偏见的偏见的方面(即倾向于认为最近发生的事情将继续发生)。

在 Java 中,一个单一组件在运行时优化的方式可能会导致在不同应用程序中表现不同。为了消除任何已有的偏见,重要的是要整体看待应用程序。

偏见可以相互补充或对立。例如,一些开发人员可能倾向于假设问题根本不是与软件相关,而是软件运行的基础设施;这在Works for Me反模式中很常见,其特征是“在 UAT 中运行良好,所以问题一定是生产环境设备有问题。”反之则是假设每个问题都必须由软件引起,因为这是开发人员了解并直接影响的系统部分。

让我们来认识一些每个性能工程师都应该警惕的最常见偏见。

知道陷阱在哪里——这是规避它的第一步。⁷

杜克·莱托·亚特雷德一世

通过认识自己和他人中的这些偏见,我们增加了进行合理性能分析和解决系统问题的可能性。

简化思维

简化思维认知偏见基于一种分析方法,预设如果将系统分解得足够小,就可以通过理解其组成部分来理解它。理解每个部分意味着减少可能出现错误假设的机会。

这种观点的主要问题很容易解释——在复杂系统中,这并不成立。非平凡的软件(或物理)系统几乎总是展示出新兴行为,整体远大于其部分简单加总的表现。

确认偏见

当涉及性能测试或试图主观地查看应用程序时,确认偏见可能会导致重大问题。确认偏见通常是无意中引入的,当选择了不良的测试集或者测试结果没有以统计学上合理的方式进行分析时。确认偏见很难对抗,因为通常会涉及强烈的动机或情感因素(例如团队中的某人试图证明一个观点)。

考虑一个反模式,比如Distracted by Shiny,团队成员试图引入最新和最棒的 NoSQL 数据库。他们对不像生产数据的数据进行了一些测试,因为完整表示整个模式对于评估目的来说太复杂了。

他们很快证明了在测试集上,NoSQL 数据库在他们的本地机器上产生了优越的访问时间。开发者已经告诉大家这将会发生,并且在看到结果后,他们继续进行了全面实施。这里存在几种反模式,都导致了新的库堆栈中的未经验证的假设。

战争迷雾(行动偏向)

战争迷雾偏见通常在停机或系统表现不如预期并且团队处于压力下的情况下显现。一些常见的原因包括:

  • 对系统运行的基础设施进行更改,可能没有通知或意识到会产生影响

  • 更改系统依赖的库

  • 一个奇怪的 bug 或竞争条件,表现出来,但只在繁忙的日子里

在一个良好维护的应用程序中,具有足够的日志记录和监控,这些应该会生成清晰的错误消息,将引导支持团队找到问题的原因。

然而,太多的应用程序没有测试失败场景,并且缺乏适当的日志记录。在这些情况下,即使是经验丰富的工程师也会陷入需要感觉正在解决停机问题并将运动误认为速度的陷阱中——“战争迷雾”降临。

此时,如果参与者对问题的处理方法不够系统化,本章讨论的许多人类因素可能会发挥作用。

例如,Blame Donkey这样的反模式可能会捷径一次全面的调查,并引导生产团队沿着特定的调查路径进行—通常会忽视更大的局面。类似地,团队可能会诱使将系统分解为其组成部分,并在低级别查看代码,而不先确定问题真正存在于哪个子系统中。

风险偏向

人类天生对风险保守且抗拒变化。主要是因为人们见过变化如何导致问题,因此他们试图避免这种风险。当然,当小心计算的风险可以推动产品发展时,这种风险规避可能非常令人沮丧。大部分这种风险规避来自于团队不愿意进行可能修改应用程序性能配置文件的更改。

通过拥有一套强大的单元测试和生产回归测试,我们可以显著减少这种风险偏好。性能回归测试是将系统非功能性需求纳入的好地方,并确保这些 NFR 所代表的关注点在回归测试中得到反映。

然而,如果团队对其中任何一方缺乏足够的信任,改变将变得极其困难,风险因素也无法控制。这种偏见通常表现为未能从应用程序问题(包括服务停机)中吸取教训并实施适当的缓解措施。

摘要

在评估性能结果时,必须以适当的方式处理数据,并避免陷入非科学和主观思维。这包括在不适当时避免依赖高斯模型的统计陷阱。

在本章中,我们遇到了一些不同类型的性能测试、测试最佳实践以及性能分析本地化的人类问题。

在下一章中,我们将继续介绍 JVM 的概述,介绍基本子系统,“经典” Java 应用程序的生命周期,以及首次了解监控和工具。

¹ 这个术语由 William J. Brown、Raphael C. Malvo、Hays W. McCormick III 和 Thomas J. Malbray 的书《反模式:重构软件、架构和项目危机》(纽约:Wiley,1998 年)普及。

² M. Walton,《丁宁管理方法》(Mercury Books,1989 年)

³ John H. McDonald,《生物统计手册》,第 3 版(马里兰州巴尔的摩:Sparky House Publishing,2014 年)。

⁴ 本节中的伪相关性来自 Tyler Vigen 的网站,并在此处根据 CC BY 4.0 许可证重新使用。如果您喜欢,可以从他的网站上获取更多有趣的例子。

⁵ R. Feynman 和 R. Leighton,《你一定在开玩笑,费曼先生》(W.W. Norton,1985 年)

⁶ J. Matejka 和 G. Fitzmaurice,“同样的统计数据,不同的图形:通过模拟退火生成外观各异但统计数据相同的数据集”,CHI 2017,美国丹佛(2017)

⁷ F. Herbert,《沙丘》(Chilton Books,1965 年)

第三章:JVM 概述

毫无疑问,Java 是全球最大的技术平台之一——目前最佳估计是有超过 10 百万的开发者在使用 Java。

Java 系统的设计是完全管理的——像垃圾收集和执行优化等方面是由 JVM 代表开发者控制的。Java 专门面向主流开发者,加上完全管理的平台,导致许多开发者在日常工作中不需要了解平台的低级复杂性。因此,开发者可能并不经常接触到这些内部方面——只有在出现例如客户投诉性能问题的情况时才会涉及。

然而,对于对性能感兴趣的开发者来说,了解 JVM 技术栈的基础是非常重要的。理解 JVM 技术使开发者能够编写更好的软件,并提供了调查与性能相关问题所需的理论背景。

本章介绍了 JVM 如何执行 Java,为后面更深入地探讨这些主题提供了基础。特别是第六章对字节码进行了深入讨论,这与此处的讨论互补。

我们建议您先阅读本章,但在阅读完第六章后再回来复习一遍。

解释与类加载

根据定义 Java 虚拟机的规范(通常称为 VM 规范),JVM 是一个基于堆栈的解释机器。这意味着它不像物理硬件 CPU 那样有寄存器,而是使用部分结果的执行堆栈,并通过对堆栈顶部值(或值)进行操作来执行计算。

如果您对解释器的工作原理不熟悉,那么您可以将 JVM 解释器的基本行为想象为“switchwhile循环内”。解释器独立处理程序的每个操作码,并使用评估堆栈来保存计算结果和中间结果。

注意

当我们深入研究 Oracle/OpenJDK VM(HotSpot)的内部时,我们将看到真实的生产级 Java 解释器的情况更加复杂,但在此时使用堆栈解释器内部的“switch-inside-while”作为一个可接受的心理模型。

当我们使用java HelloWorld命令启动我们的应用程序时,操作系统会启动虚拟机进程(java二进制文件)。这设置了 Java 虚拟环境并初始化了实际执行HelloWorld.class文件中用户代码的解释器。

应用程序的入口点将是HelloWorld.classmain()方法。为了将控制权交给这个类,在执行之前必须由虚拟机加载它。

为此,使用了 Java 类加载机制。当初始化新的 Java 进程时,使用一系列类加载器。初始加载器称为引导类加载器(历史上也称为“原始类加载器”),它加载核心 Java 运行时中的类。引导类加载器的主要目的是加载一组最小的类(其中包括java.lang.ObjectClassClassloader等必需品),以允许其他类加载器启动系统的其余部分。

此时讨论 Java 模块系统(有时称为 JPMS)如何在某种程度上改变了应用程序启动的情况也是有益的。从 Java 9 开始,所有 JVM 都是模块化的—​不存在恢复 Java 8 单片式 JVM 运行时的“兼容性”或“经典”模式。

这意味着在启动期间始终构建模块图,即使应用本身是非模块化的。这必须是有向无环图(DAG),如果应用程序的模块元数据试图构建包含循环的模块图,则这将是致命错误。

模块图具有各种优势,包括:

  • 只加载所需的模块

  • 可以在启动时确认模块间元数据是否良好

模块图具有一个主模块,其中包含入口类。如果应用程序尚未完全模块化,则将同时具有模块路径和类路径,而应用程序代码可能位于UNNAMED模块中。

注意

本书不涉及模块系统的详细信息。可以在Java in a Nutshell (8th Edition)(O’Reilly 出版)由 Benjamin J. Evans、Jason Clark 和 David Flanagan 或更深入的参考资料,如Java 9 Modularity(O’Reilly 出版)由 Sander Mak 和 Paul Bakker 找到扩展的处理方法。

实际上,引导类加载器的工作涉及加载java.base和一些其他支持模块(包括一些可能令人惊讶的条目,如java.security.sasljava.datatransfer

Java 将类加载器视为其运行时和类型系统中的对象,因此需要一种方式来将一组初始类带入存在。否则,定义类加载器将存在循环问题。

引导类加载器不验证其加载的类(主要是为了提高启动性能),并且依赖于引导类路径的安全性。由引导类加载的任何内容都被授予完全的安全权限,因此这组模块被尽可能保持限制。

注意

Java 的旧版本,包括 8 及之前版本,使用了单片式运行时,引导类加载器加载了rt.jar的内容。

剩余的基础系统(即在版本 8 及更早版本中使用的旧 rt.jar 的相当部分)由 平台类加载器 加载,并可通过方法 ClassLoader::getPlatformClassLoader 访问。它的父加载器是引导类加载器,因为旧的扩展类加载器已被移除。

在新的 Java 模块化实现中,启动 Java 进程所需的代码大大减少,因此,尽可能多的 JDK 代码(现在表示为模块)已移出引导加载器的范围,并移到了平台加载器中。

最后,创建了应用程序类加载器;它负责从定义的类路径中加载用户类。不幸的是,有些文本将其称为“系统”类加载器。出于简单的原因,应避免使用这个术语,因为它并不加载系统类(引导类加载器和平台类加载器会这么做)。应用程序类加载器非常常见,并且它的父加载器是平台加载器。

Java 在程序执行过程中首次遇到新类时会加载其依赖项。如果类加载器无法找到类,则通常会将查找委托给父加载器。如果查找链达到引导类加载器并且未找到,则会抛出 ClassNotFoundException。开发人员使用与将在生产中使用的完全相同的类路径进行有效编译是非常重要的,因为这有助于减轻这种潜在问题。

通常情况下,Java 只会加载一个类一次,并创建一个 Class 对象来代表运行时环境中的类。然而,重要的是要意识到,在某些情况下,同一个类可以被不同的类加载器加载两次。因此,系统中的类由加载它的类加载器以及完全限定的类名(包括包名)来标识。

注意

在某些执行环境中,比如应用服务器(例如 Tomcat 或 JBoss EAP),当服务器中存在多个租户应用时会显示此行为。

还有一些工具(例如 Java 代理)可以作为字节码织入的一部分重新加载和重转换类——这些工具通常用于监控和可观察性。

执行字节码

很重要的是要理解,Java 源代码在执行之前经历了大量的转换过程。首先是使用 Java 编译器 javac 进行的编译步骤,通常作为更大构建过程的一部分而调用。

javac 的工作是将 Java 代码转换为包含字节码的 .class 文件。它通过对 Java 源代码进行相当直接的翻译来实现这一点,如 图 3-1 所示。javac 在编译过程中几乎没有进行任何优化,生成的字节码在使用反汇编工具(例如标准的 javap)查看时仍然非常清晰和可识别为 Java 代码。

ocnj2 0301

图 3-1. Java 类文件编译

字节码是一种中间表示,与特定的机器架构无关。与机器架构解耦提供了可移植性,意味着已经开发(或编译)的软件可以在 JVM 支持的任何平台上运行,并且提供了对 Java 语言的抽象。这为我们对 JVM 执行代码的方式提供了第一个重要的见解。

注意

Java 语言和 Java 虚拟机现在在一定程度上是独立的,因此 JVM 中的 J 可能有点误导性,因为 JVM 可以执行任何可以生成有效类文件的 JVM 语言。实际上,图 3-1 可以很容易地展示 Kotlin 编译器 kotlinc 生成的字节码用于在 JVM 上执行。

无论使用哪个源代码编译器,生成的类文件都具有由 VM 规范明确定义的非常清晰的结构。任何由 JVM 加载的类在允许运行之前都将被验证为符合预期格式。

表 3-1. 类文件的解剖

组件 描述
魔数 0xCAFEBABE
类文件格式的版本 类文件的次要和主要版本
常量池 类的常量池
访问标志 类是否为抽象的、静态的等等
这个类 当前类的名称
超类 超类的名称
接口 类中的任何接口
字段 类中的任何字段
方法 类中的任何方法
属性 类的任何属性(例如,源文件的名称等)

每个类文件以魔数 0xCAFEBABE 开头,这是 16 进制中的前 4 个字节,用于表示符合类文件格式。接下来的 4 个字节表示用于编译类文件的次要版本和主要版本,这些版本会被检查以确保 JVM 的版本不低于用于编译类文件的版本。类加载器会检查次要和主要版本以确保兼容性;如果不兼容,则会在运行时抛出 UnsupportedClassVersionError,表示运行时低于编译的类文件版本。

注意

魔数为 Unix 环境提供了一种识别文件类型的方式(而 Windows 通常会使用文件扩展名)。因此,一旦确定,它们很难更改。不幸的是,这意味着 Java 在可预见的将来将继续使用相当尴尬和性别歧视的0xCAFEBABE,尽管 Java 9 引入了魔数0xCAFEDADA用于模块文件。

常量池在代码中保存常量值:例如,类、接口和字段的名称。当 JVM 执行代码时,常量池表用于引用值,而不必依赖于运行时内存结构的精确布局。

访问标志用于确定应用于类的修饰符。标志块的第一部分标识一般属性,例如一个类是否为公共的,接着是是否为 final,因此不能被子类化。标志还确定类文件是否表示一个接口或抽象类。标志块的最后部分指示类文件是否表示合成类(不在源代码中出现)、注解类型或枚举。

this类、超类和接口条目是索引到常量池的,用于标识属于类的类型层次结构。字段和方法定义了类似签名的结构,包括应用于字段或方法的修饰符。然后使用一组属性来表示更复杂和非固定大小结构的结构化项目。例如,方法使用Code属性来表示与该特定方法相关的字节码。

图 3-2 提供了一个记忆结构的助记符。

ocnj2 0302

图 3-2. 类文件结构的助记符

在这个非常简单的代码示例中,可以观察运行javac的效果:

public class HelloWorld {
    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            System.out.println("Hello World");
        }
    }
}

Java 附带了一个名为javap的类文件反汇编器,允许检查.class文件。取出HelloWorld类文件并运行javap -c HelloWorld,将得到以下输出:

public class HelloWorld {
  public HelloWorld();
    Code:
       0: aload_0
       1: invokespecial #1    // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: iconst_0
       1: istore_1
       2: iload_1
       3: bipush        10
       5: if_icmpge     22
       8: getstatic     #2    // Field java/lang/System.out ...
      11: ldc           #3    // String Hello World
      13: invokevirtual #4    // Method java/io/PrintStream.println ...
      16: iinc          1, 1
      19: goto          2
      22: return
}

这个布局描述了文件HelloWorld.class的字节码。更详细的信息,javap还有一个-v选项,提供完整的类文件头信息和常量池详情。类文件包含两个方法,虽然源文件只提供了一个main()方法;这是javac自动向类中添加默认构造函数的结果。

构造函数中执行的第一条指令是aload_0,它将this引用放置在堆栈的第一个位置上。然后调用invokespecial命令,它调用具有特定处理调用超级构造函数和创建对象的实例方法。在默认构造函数中,调用与Object的默认构造函数匹配,因为未提供重写。

注意

JVM 中的操作码简洁且表示类型、操作以及本地变量、常量池和堆栈之间的交互。

继续到main()方法,iconst_0将整数常量0推送到评估堆栈。istore_1将此常量值存储到偏移量 1 处的本地变量(在循环中表示为i)。本地变量偏移从 0 开始,但对于实例方法,第 0 个条目始终是this。然后再次将偏移量 1 处的变量加载回堆栈,并且使用if_icmpge推送常量10进行比较(“如果整数比较大或等于”)。只有当前整数 >= 10 时测试才会成功。

在前 10 次迭代中,此比较测试失败,因此我们继续到指令 8。在这里,来自System.out的静态方法被解析,随后从常量池加载“Hello World”字符串。接下来的invokevirtual调用基于类调用实例方法。然后对整数进行递增并且通过goto调用回到指令 2 继续循环。

此过程将持续直到if_icmpge比较最终成功(当循环变量 >= 10 时);在循环的这一次迭代中,控制传递到指令 22 并且方法返回。

引入 HotSpot

1999 年 4 月,Sun 公司推出了对主导的 Java 实现最大的一次性性能改进。HotSpot 虚拟机是一个关键功能,经过演变使得性能可以与诸如 C 和 C++等语言相媲美(见图 3-3)。要解释这种可能性,让我们深入探讨一下设计用于应用程序开发的语言。

ocnj2 0303

图 3-3. HotSpot JVM

语言和平台设计通常涉及在所需功能之间进行决策和权衡。在这种情况下,分歧在于“接近底层”的语言和依赖于“零成本抽象”等思想,以及支持开发者生产力和“完成任务”的语言,而不是严格的低级控制。

总体而言,C++实现遵循零开销原则:你不用的部分不需要付出代价。而且进一步说,你使用的部分,你手工编码也不会更好。¹

Bjarne Stroustrup

零开销原则在理论上听起来很棒,但它要求语言的所有用户处理操作系统和计算机实际工作的低级现实。这是一个额外的重大认知负担,可能不是开发者的主要目标关注的原始性能。

不仅如此,它还需要在构建时将源代码编译为特定于平台的机器代码—通常称为提前(AOT)编译。这是因为替代执行模型,如解释器、虚拟机和可移植性层,都绝对不是零开销。

短语“你使用的东西,你无法手动编写得更好”也有一个讽刺的意味。它意味着许多事情,但对我们来说最重要的是开发人员无法比自动系统(如编译器)生成更好的代码。

Java 从来没有订阅零开销抽象理念。相反,HotSpot 虚拟机采取的方法是分析程序的运行时行为,并在最有利于性能的地方智能应用优化。HotSpot VM 的目标是让您编写惯用的 Java 并遵循良好的设计原则,而不是扭曲您的程序以适应 VM。

介绍即时编译

Java 程序在字节码解释器中开始执行,在那里指令在虚拟堆栈机上执行。这种对 CPU 的抽象提供了类文件可移植性的好处,但要获得最佳性能,你的程序必须充分利用其本地特性。

HotSpot 通过将您程序的单元从解释的字节码编译成本地代码来实现这一点,然后直接执行,而不需要解释器的抽象开销。HotSpot VM 中的编译单元是方法和循环。这就是所谓的即时(JIT)编译。

JIT 编译通过在解释模式下监视应用程序并观察最频繁执行的代码部分来工作。在这个分析过程中,捕获了程序跟踪信息,这允许进行更复杂的优化。一旦特定方法的执行超过阈值,分析器将尝试编译和优化该代码段。

即时(JIT)编译方法有许多优点,但其中一个主要优点是它基于在方法被解释时收集的跟踪信息来制定编译器优化决策。这些信息使得 HotSpot 可以在方法有资格进行编译时进行更加明智的优化。

注意

一些 JIT 编译器还具有在执行过程中更好的优化变得明显时重新 JIT 的能力。这包括一些 HotSpot 的编译器。

HotSpot 不仅如此,还有数百年(甚至更多)的工程开发归功于它,并且几乎每次新发布都会添加新的优化和好处。这意味着所有的 Java 应用都能从 VM 中最新的 HotSpot 性能优化中受益,甚至无需重新编译。

提示

将 Java 源代码转换为字节码后,经过(JIT)编译的代码实际上与原始编写的源代码有很大的变化。这是一个关键的见解,它将驱动我们处理与性能相关的调查方法。

一般情况是,像 C++(以及崭露头角的 Rust)这样的语言倾向于具有更可预测的性能,但以迫使用户承担大量低级复杂性为代价。

还要注意,“更可预测”并不一定意味着“更好”。AOT 编译器生成的代码可能需要在广泛的处理器类上运行,并且通常不能假定特定处理器功能可用。

使用基于配置文件的优化(PGO)的环境,如 Java,有可能利用运行时信息,这对大多数 AOT 平台来说是不可能的。这可以提升性能,如动态内联和优化掉虚拟调用。HotSpot 甚至可以在 VM 启动时检测正在运行的精确 CPU 类型,并且可以利用此信息启用针对特定处理器功能设计的优化。

提示

检测精确处理器能力的技术称为JVM 内置功能,不要与由synchronized关键字引入的内置锁混淆。

PGO 和 JIT 编译的全面讨论可参见第 6 和第十章。

HotSpot 采取的复杂方法对大多数普通开发者是一个巨大的好处,但这种权衡(放弃零开销抽象)意味着在高性能 Java 应用的特定情况下,开发者必须非常小心,避免“常识”推理和过于简单化的 Java 应用实际执行模型。

注意

再次强调,分析小段 Java 代码的性能(微基准测试)通常比分析整个应用程序更加困难,这是大多数开发者不应该承担的非常专业化的任务。

HotSpot 的编译子系统是虚拟机提供的两个最重要的子系统之一。另一个是自动内存管理,自 Java 早期以来一直是其主要卖点之一。

JVM 内存管理

在像 C、C++ 和 Objective-C 这样的语言中,程序员负责管理内存的分配和释放。自行管理内存和对象生命周期的好处是更确定的性能和将资源生命周期与对象的创建和删除绑定的能力。然而,这些好处是以巨大的成本为代价的——为了正确性,开发者必须能够准确地考虑内存。

不幸的是,几十年的实际经验表明,许多开发人员对内存管理的习惯用法和模式理解不足。后来的 C++和 Objective-C 版本通过在标准库中使用智能指针习惯改进了这一点。然而,在 Java 被创造时,糟糕的内存管理是应用程序错误的主要原因之一。这引起了开发人员和管理人员对处理语言特性而非为业务提供价值所花费的时间量的关注。

Java 通过引入自动管理堆内存的方式(称为垃圾回收(GC))来帮助解决这个问题。简而言之,垃圾回收是一种非确定性过程,当 JVM 需要更多内存进行分配时触发,以恢复和重用不再需要的内存。

GC 是有代价的:当它运行时,传统上会停止整个世界,这意味着应用程序会暂停。通常这些暂停时间非常短暂,但当应用程序面临压力时,这些时间可能会增加。

话虽如此,JVM 的垃圾回收是业界最佳的,远比计算机科学本科课程中常教授的初级算法要复杂得多。例如,在现代算法中,停止整个世界的必要性和侵入性大大降低,后面我们将会看到。

垃圾回收是 Java 性能优化中的一个重要主题,因此我们将在第四章和第五章详细讨论 Java GC 的细节。

线程和 Java 内存模型

Java 的第一个版本带来的一项主要进步是对多线程编程的内置支持。Java 平台允许开发人员创建新的执行线程。例如,在 Java 8 语法中:

Thread t = new Thread(() -> {System.out.println("Hello World!");});
t.start();

不仅如此,基本上所有生产环境中的 JVM 都是多线程的—​这意味着所有 Java 程序本质上都是多线程的,因为它们作为 JVM 进程的一部分执行。

这一事实增加了 Java 程序行为的额外、不可简化的复杂性,并使性能分析师的工作更加困难。然而,它允许 JVM 利用所有可用的核心,为 Java 开发人员提供各种性能优势。

Java 对线程的概念(“应用程序线程”)与操作系统对线程的视图(“平台线程”)之间有着稍微有趣的历史。在平台的最早时期,对这两个概念有着明显的区分,并且应用程序线程会被重新映射多路复用到一组平台线程中—​例如在 Solaris 的M:N或 Linux 的green threads模型中。

然而,这种方法被证明无法提供可接受的性能特性,并增加了不必要的复杂性。因此,在大多数主流 JVM 实现中,这种模型被更简单的模型所取代—​每个 Java 应用程序线程精确对应于一个专用的平台线程。

然而,这并不是故事的终点。

自从“应用线程 == 平台线程”转变以来的 20 多年间,应用程序已经大规模增长和扩展 —— 线程(或者更普遍地说,执行上下文)的数量也随之增加。这导致了“线程瓶颈”问题的出现,解决这一问题已经成为 OpenJDK 内的一个重要研究项目的焦点(项目 Loom)。

结果就是 虚拟线程,这是一种新形式的线程,只在 Java 21+ 中可用,可用于某些类型的任务 —— 特别是执行网络 I/O 的任务。

程序员必须明确选择将线程创建为虚拟线程 —— 否则它们就是平台线程,并保留与之前相同的行为(这样在具备虚拟线程功能的 JVM 上运行时,所有现有 Java 程序的语义仍然得以保留)。

注意

可以安全地假设每个平台线程(或在 Java 21 之前的任何线程)都由一个唯一的操作系统线程支持,在对应的 Thread 对象上调用 start() 方法时创建。

虚拟线程是 Java 对各种现代语言中存在的一种思想的解决方案 —— 例如,Go 程序员可能会认为 Java 虚拟线程与 goroutine 在某种程度上相似。我们将在第十四章中更详细地讨论虚拟线程。

我们还应该简要讨论 Java 在处理多线程程序中的数据时的方法。这些方法可以追溯到 20 世纪 90 年代末,并具有以下基本设计原则:

  • Java 进程中的所有线程共享单一的垃圾收集堆。

  • 任何一个线程创建的对象都可以被其他任何引用了该对象的线程访问。

  • 对象默认是可变的;也就是说,对象字段中保存的值可以被更改,除非程序员显式使用 final 关键字将它们标记为不可变。

Java 内存模型(JMM)是一个正式的内存模型,解释了不同执行线程如何看待对象中存储值的变化。也就是说,如果线程 A 和线程 B 都有对对象 obj 的引用,并且线程 A 进行了修改,那么线程 B 中观察到的值会发生什么变化?

这个看似简单的问题实际上比看起来更为复杂,因为操作系统的调度器(我们将在第七章中介绍)可以强制从 CPU 核心中驱逐平台线程。这可能导致另一个线程开始执行并访问对象,而原始线程尚未完成对对象的处理,可能会看到对象处于先前或甚至是无效状态。

Java 核心提供的唯一防御措施是互斥锁,以防止在并发代码执行期间造成对象损坏,但在实际应用中使用起来可能非常复杂。第十三章详细讨论了 JMM 的工作原理以及处理线程和锁定的实际操作。

传统 Java 应用程序的生命周期

在本章的前面,我们通过类加载和字节码解释介绍了 Java 程序的执行方式—​但让我们更深入地了解当您键入java HelloWorld时实际发生了什么。

在较低级别上,类似于标准 Unix 的进程执行顺序发生以设置 JVM 进程。Shell 定位 JVM 二进制文件(例如,可能在 $JAVA_HOME/bin/java 中),并启动对应于该二进制文件的进程,传递参数(包括入口类名)。

新启动的进程分析命令行标志并准备进行 VM 初始化,这将通过标志(用于堆大小、GC 等)进行定制。此时,进程会探测其运行的机器,并检查各种系统参数,例如机器拥有多少 CPU 核心;有多少内存;可用的 CPU 指令集是什么。

这些非常详细的信息用于定制和优化 JVM 如何配置自身。例如,JVM 将使用核心数确定垃圾收集运行时要使用多少线程,并且调整线程的通用池的大小。

一个关键的早期步骤是为 Java 堆保留一个用户空间内存区域(来自 C 堆),大小等于 Xmx(或默认值)。另一个至关重要的步骤是初始化存储 Java 类和相关元数据的存储库(在 HotSpot 中称为 Metaspace)。

然后,VM 本身将被创建,通常是通过 JNI_CreateJavaVM 函数,在 HotSpot 上为新线程。VM 的自身线程—​例如 GC 线程和执行 JIT 编译的线程—​也需要启动起来。

正如之前讨论的,引导类被准备并初始化。当类加载时(例如,在引导类的类初始化器(static {}块,也称为clinit方法)中),第一批字节码会被执行,第一个对象也会被创建。

这一点的重要性在于 JVM 的基本进程—​如 JIT 编译和 GC—​从应用程序的生命周期早期就开始运行。随着 VM 的启动,甚至在控制流达到入口类之前,可能会有一些 GC 和 JIT 活动。一旦这样做了,随着应用程序开始执行并需要从类不存在于类元数据缓存中的类中运行代码时,将会发生进一步的类加载。

因此,对于大多数典型的生产应用程序,启动阶段的特征是类加载、JIT 和 GC 活动的激增,而应用程序达到稳定状态。一旦发生这种情况,JIT 和类加载的量通常会急剧下降,因为:

  • 应用程序需要的类的“整个”世界已经加载完成

  • 经常调用的一组方法已经被 JIT 编译器转换为机器码

然而,重要的是要认识到,“稳定状态”并不意味着“零变化”。应用程序经历进一步的类加载和 JIT 活动——例如去优化重新优化是完全正常的。这可能是由于遇到了很少执行的代码路径并导致新类的加载。

另一个重要的启动-稳定状态模型的特殊情况有时被称为“两阶段类加载”。这发生在使用 Spring 和其他类似依赖注入技术的应用程序中。

在这种情况下,核心框架类首先被加载。之后,框架会检查主应用程序代码和配置,以确定需要实例化以激活应用程序的对象图。这触发了第二阶段的类加载,其中加载应用程序代码及其其他依赖项。

GC 行为的情况略有不同。在一个没有特定性能问题的应用程序中,当达到稳定状态时,GC 模式也很可能发生变化——但 GC 事件仍会发生。这是因为在任何 Java 应用程序中,对象被创建,存在一段时间,然后被自动收集——这是自动内存管理的全部意义。然而,稳定状态的 GC 模式可能看起来与启动阶段的完全不同。

从此描述中你应该得到的总体印象是一个高度动态的运行时环境。部署在其上的应用程序展示了明确定义的启动阶段的运行时特征,然后是稳定状态,在这个状态下发生的变化很少。

这是 Java 应用程序行为的标准心理模型,自 Java 具有 JIT 编译以来一直如此。然而,它确实有一些缺点——其中一个主要缺点是在应用程序过渡到稳定状态时执行时间可能会变慢(通常称为“JVM 热身”)。

这种过渡时间在应用程序启动后很容易延长到几十秒。对于长时间运行的应用程序,这通常不是问题——一个连续运行数小时(或数天或数周)的进程比启动时为其创建 JIT 编译代码所投入的一次性努力获得了更大的好处。

然而,在云原生世界中,进程可能存活时间较短。这引发了一个问题:Java 启动和 JIT 的摊销成本是否真的值得,如果不值得,可以采取哪些措施来加快 Java 应用程序的启动速度?

反过来,这引发了对 Java 新的操作和部署模式的兴趣——包括 AOT 编译(但不限于此,我们将在本书的其余部分看到)。社区已经采用了“动态 VM 模式”的术语来描述我们刚讨论过的传统生命周期。在本书的其余部分,我们将详细探讨与其相对立的新兴替代方案。

JVM 的监控和工具

JVM 是一个成熟的执行平台,它为运行应用程序的仪表化、监视和可观测性提供了许多技术选择。针对 JVM 应用程序的这些类型的工具的主要技术有:

  • Java 管理扩展(JMX)

  • Java 代理

  • JVM 工具接口(JVMTI)

  • 可服务性代理(SA)

JMX 是一种用于控制和监视 JVM 及其上运行的应用程序的通用技术。它提供了从客户端应用程序通用方式更改参数和调用方法的能力。如何实现这一点的全面讨论,不幸的是,超出了本书的范围。然而,JMX(及其相关的网络传输,远程方法调用或 RMI)是 JVM 管理能力的一个基本方面。

Java 代理是一个工具组件,用 Java 编写(因此得名),利用java.lang.instrument中的接口在加载类时修改方法的字节码。字节码修改允许添加仪表逻辑,如方法计时或分布式跟踪(有关详细信息,请参见第十章),以添加到任何应用程序中,即使该应用程序没有为这些问题提供任何支持。

这是一种非常强大的技术,安装代理会改变我们在上一节中介绍的标准应用程序生命周期。要安装代理,它必须打包为 JAR 并通过启动标志提供给 JVM:

-javaagent:<path-to-agent-jar>=<options>

代理 JAR 必须包含一个清单文件,META-INF/MANIFEST.MF,并且必须包含属性Premain-Class

此属性包含代理类的名称,该类必须实现一个公共静态的premain()方法,作为 Java 代理的注册挂钩。此方法将在应用程序的主线程之前(因此得名)运行main()方法。请注意,premain 方法必须退出,否则主应用程序将无法启动。

字节码转换是代理的通常意图,通过创建和注册字节码转换器——实现ClassFileTransformer接口的对象来实现。然而,Java 代理只是 Java 代码,因此它可以像任何其他 Java 程序一样做任何事情,即可以包含任意代码来执行。这种灵活性意味着,例如,代理可以启动额外的线程,这些线程可以持续整个应用程序的生命周期,并收集数据以发送到外部监控系统。

注意

在第十一章中,我们将进一步讨论 JMX 和代理的使用,特别是它们在云可观测性工具中的应用。

如果 Java 仪器化 API 不足够,那么可以改用 JVMTI。这是 JVM 的本地接口,因此利用它的代理必须用本地编译语言编写,基本上是 C 或 C++。它可以被看作是允许本地代理通过 JVM 监控并被其事件通知的通信接口。要安装本地代理,提供稍微不同的标志:

-agentlib:<agent-lib-name>=<options>

或:

-agentpath:<path-to-agent>=<options>

JVMTI 代理必须用本地代码编写的要求意味着这些代理可能更难编写和调试。在 JVMTI 代理中的编程错误可能会损害正在运行的应用程序,甚至导致 JVM 崩溃。

因此,如果可能的话,通常更倾向于编写 Java 代理而不是 JVMTI 代码。代理要容易得多,但是一些信息无法通过 Java API 获得,而要访问这些数据可能只能通过 JVMTI。

最后的方法是可服务性代理。这是一组可以公开 Java 对象和 HotSpot 数据结构的 API 和工具。

SA 不需要在目标 VM 中运行任何代码。相反,HotSpot SA 使用诸如符号查找和读取进程内存等基元来实现调试能力。SA 能够调试活动的 Java 进程以及核心文件(也称为崩溃转储文件)。

VisualVM

JDK 随附许多有用的附加工具,以及众所周知的二进制文件,如 javacjava

一个经常被忽视的工具是 VisualVM,这是一个基于 NetBeans 平台的图形工具。VisualVM 曾作为 JDK 的一部分发布,但已从主发行版中移出,因此开发人员需要从VisualVM 网站单独下载二进制文件。下载后,您需要确保将 visualvm 二进制文件添加到您的路径中,否则可能会获取来自旧 Java 版本的过时版本。

提示

jvisualvm 是较早 Java 版本中现已过时的 jconsole 工具的替代品。如果您仍在使用 jconsole,应迁移到 VisualVM(有兼容插件允许 jconsole 插件在 VisualVM 中运行)。

当首次启动 VisualVM 时,它将对其运行的机器进行校准,因此不应有其他可能影响性能校准的应用程序在运行。校准完成后,VisualVM 将完成启动并显示启动画面。VisualVM 最熟悉的视图是监视器视图,类似于图 3-4 中显示的视图。

ocnj2 0304

图 3-4. VisualVM 监视器视图

VisualVM 用于实时监控正在运行的进程,并使用 JVM 的附加机制。这在处理本地或远程进程时稍有不同。

本地进程相当简单。VisualVM 将它们列在屏幕左侧。双击其中一个会在右侧窗格中显示为新选项卡。

要连接到远程进程,远程端必须接受入站连接(通过 JMX)。对于标准 Java 进程,这意味着远程主机上必须运行jstatd(有关更多详情,请参阅jstatd的手册页)。

注意

许多应用服务器和执行容器直接在服务器中提供与jstatd等价的能力。这样的进程不需要单独的jstatd进程,只要能够进行 JMX 和 RMI 流量的端口转发即可。

要连接到远程进程,请输入主机名和将在选项卡上使用的显示名称。连接的默认端口是 1099,但这可以轻松更改。

VisualVM 默认呈现给用户五个选项卡:

概述

提供有关您的 Java 进程的信息摘要。这包括传递的所有标志和所有系统属性。还显示正在执行的确切 Java 版本。

监视器

这是与传统jconsole视图最相似的选项卡。它显示了 JVM 的高级遥测信息,包括 CPU 和堆使用情况。还显示了加载和卸载的类数量,以及运行的线程数量的概述。

线程

运行应用程序中的每个线程都显示在时间轴上。这包括应用程序线程和 VM 线程。可以看到每个线程的状态,还有一些历史记录。如果需要,还可以生成线程转储。

采样器和分析器

在这些视图中,可以访问对 CPU 和内存利用率的简化抽样。这将在第十一章中更详细地讨论。

VisualVM 的插件架构允许轻松添加额外的工具到核心平台以增强核心功能。这些包括允许与 JMX 控制台交互和桥接到传统 JConsole 的插件,以及非常有用的垃圾收集插件 VisualGC。

Java 的实现、分发和发布

在本节中,我们将简要讨论 Java 实现和分发的情况,以及 Java 发布周期。

这个领域随时间变化很大,所以此描述仅在撰写时正确。例如,自那时以来,供应商可能已经进入(或退出)制作 Java 分发版的业务,或者发布周期可能已经改变。读者须注意!

许多开发人员可能只熟悉由 Oracle(Oracle JDK)生产的 Java 二进制文件。然而,截至 2023 年,我们有一个非常复杂的景观,了解构成“Java”基本组件非常重要。

首先,有将要构建成二进制文件的源代码。构建 Java 实现所需的源代码分为两部分:

  • 虚拟机源代码

  • 类库源代码

OpenJDK 项目位于OpenJDK 网站,这个项目是开发 Java 开源参考实现的项目——其许可证为 GNU 公共许可证第二版,并带有类路径例外(GPLv2+CE)。² 该项目由 Oracle 领导并支持——他们为 OpenJDK 代码库提供了大多数工程师。

关于 OpenJDK 的关键点是,它只提供源代码。这对于 VM(HotSpot)和类库都是如此。

HotSpot 和 OpenJDK 类库的结合构成了今天生产环境中使用的绝大部分 Java 发行版的基础(包括 Oracle 的)。然而,还有几个其他的 Java 虚拟机我们将会遇到——并在本书中简要讨论——包括 Eclipse OpenJ9 和 GraalVM。这些虚拟机也可以与 OpenJDK 类库结合,以产生完整的 Java 实现。

然而,仅有源代码本身对开发人员来说并不十分有用——它需要构建成二进制发行版,经过测试并可选地进行认证。

这在某种程度上类似于 Linux 的情况——源代码存在并且可以自由获取,但实际上除了正在开发下一版本的人之外,几乎没有人直接使用源代码。开发人员使用的是二进制 Linux 发行版。

在 Java 世界中,有许多供应商提供发行版,就像 Linux 一样。让我们认识这些供应商,快速查看它们的各种产品。

选择一个发行版

开发人员和架构师应仔细考虑他们的 JVM 供应商选择。一些大型组织——特别是 Twitter(截至 2022 年)和阿里巴巴——甚至选择维护自己的私有(或半公开)的 OpenJDK 版本,尽管这需要超出许多公司的工程能力。

在此基础上,组织通常关心的主要因素是:

  1. 在生产环境中使用这个是否需要支付费用?

  2. 我如何让我发现的任何错误得到修复?

  3. 如何获取安全补丁?

依次处理这些问题:

从 OpenJDK 源代码构建的二进制文件(其使用 GPLv2+CE 许可证)可以在生产环境中免费使用。这包括来自 Eclipse Adoptium、Red Hat、Amazon 和 Microsoft 的所有二进制文件;以及来自 BellSoft 等较少知名供应商的二进制文件。Oracle 的某些二进制文件也属于此类,但不是全部。

接下来,要修复 OpenJDK 中的错误,发现者可以采取以下两种方式之一:购买支持合同并让供应商进行修复;或要求 OpenJDK 的作者提交一个错误报告到 OpenJDK 存储库,然后希望(或友好地要求)有人为您修复它。或者总是提供的第三种选择,即所有开源软件都提供的选择——自己修复然后提交补丁。

最后一点——关于安全更新——稍微复杂一些。首先要注意的是,几乎所有 Java 的更改都起始于 GitHub 上公开的 OpenJDK 仓库的提交。唯一的例外是某些尚未公开披露的安全修复。

当一个修复程序发布并公开时,有一个流程可以使补丁流回到各种 OpenJDK 仓库中。供应商随后将能够获取该源代码修复并构建和发布包含它的二进制文件。然而,这个过程有一些微妙之处,这也是为什么大多数 Java 商店更喜欢保持长期支持(或 LTS)版本的原因——在关于 Java 版本的部分我们将会有更多讨论。

现在我们已经讨论了选择发行版的主要标准,让我们来看看一些主要的可用选择:

Oracle

Oracle 的 Java(Oracle JDK)可能是最广为人知的实现。它本质上是 OpenJDK 代码库,以 Oracle 专有许可证重新许可,只有一些极小的差异(例如包含一些在开源许可证下不可用的附加组件)。Oracle 通过要求所有 OpenJDK 的贡献者签署许可协议来实现双重许可,允许其贡献同时采用 OpenJDK 的 GPLv2+CE 许可和 Oracle 的专有许可。³

Eclipse Adoptium

这个由社区领导的项目最初是 AdoptOpenJDK,在转变为 Eclipse 基金会时更名为 Adoptium。Adoptium 项目的成员(来自 Red Hat、Google、Microsoft 和 Azul 等公司)主要是构建和测试工程师,而不是开发工程师(负责实现新功能和修复错误)。这是有意设计的——Adoptium 的许多成员公司也为上游 OpenJDK 开发做出重大贡献,但是以各自公司的名义而不是 Adoptium 的名义进行。Adoptium 项目获取 OpenJDK 源代码并在多个平台上构建完全经过测试的二进制文件。作为一个社区项目,Adoptium 不提供付费支持,尽管成员公司可能选择这样做——例如 Red Hat 在某些操作系统上提供支持。

Red Hat

Red Hat 是 Java 二进制文件中历史最悠久的非 Oracle 生产商,也是 OpenJDK 的第二大贡献者(仅次于 Oracle)。他们为他们的操作系统(如 RHEL 和 Fedora)以及 Windows(出于历史原因)制作构建并提供支持。Red Hat 还发布基于他们的 Universal Base Image(UBI)Linux 系统的免费容器映像。

Amazon Corretto

Corretto 是亚马逊的 OpenJDK 发行版,主要用于 AWS 云基础设施。亚马逊还提供了 Mac、Windows 和 Linux 的构建,以提供一致的开发者体验,并鼓励开发者在所有环境中使用他们的构建。

Microsoft OpenJDK

自 2021 年 5 月起,Microsoft 开始为 Mac、Windows 和 Linux 生产二进制文件(OpenJDK 11.0.11)。与 AWS 类似,Microsoft 的发行版主要旨在为将在其 Azure 云基础设施上部署的开发人员提供便捷的入门途径。

Azul Systems

Zulu 是 Azul Systems 提供的免费 OpenJDK 实现,同时他们也为其 OpenJDK 二进制文件提供付费支持。Azul 还提供一款高性能的专有 JVM,名为“Azul 平台 Prime”(以前称为 Zing)。Prime 并非一个 OpenJDK 发行版。

GraalVM

GraalVM 是这个列表的一个相对较新的添加。最初是 Oracle Labs 的研究项目,现已发展为一个完全成熟的 Java 实现(以及更多其他功能)。GraalVM 可以在动态 VM 模式下运行,并包括基于 OpenJDK 的运行时——增强了用 Java 编写的 JIT 编译器。然而,GraalVM 也能够对 Java 进行本地编译,即 AOT 编译。关于这个主题,本书稍后将会详细介绍。

OpenJ9

OpenJ9 最初作为 IBM 的专有 JVM(当时称为 J9)而诞生,但在其寿命中段(就像 HotSpot 一样)于 2017 年开源。它现在建立在 Eclipse 开放运行时项目(OMR)之上,并且完全符合 Java 认证。IBM Semeru Runtimes 是使用 OpenJDK 类库和 Eclipse OpenJ9 JVM(受 Eclipse 许可证)构建的零成本运行时。

Android

Google 的 Android 项目有时被认为是“基于 Java”。但实际情况更为复杂。Android 使用交叉编译器将类文件转换为不同的(.dex)文件格式。这些.dex文件然后由 Android Runtime(ART)执行,ART 并不是一个 JVM。事实上,Google 现在推荐使用 Kotlin 语言来开发 Android 应用程序。由于这一技术栈与其他示例有较大不同,本书不再深入讨论 Android。

请注意,此列表并非全面之列——还有其他可用的发行版。

本书的绝大部分内容都集中在 HotSpot 技术上。这意味着这些材料同样适用于 Oracle 的 Java 以及 Adoptium、Red Hat、Amazon、Microsoft、Azul Zulu 等提供的发行版所使用的 JVM。

我们还包含了一些与 Eclipse Open J9 相关的内容。这旨在提供对替代选择的认识,而不是一个权威指南。一些读者可能希望深入了解这些技术,我们鼓励他们通过设定性能目标、进行测量和比较来进一步探索。

最后,在讨论 Java 发布周期之前,我们来谈谈各种 OpenJDK 发行版的性能特性。

团队偶尔会询问性能问题——有时是因为他们错误地认为某些发行版包含其他 OpenJDK 发行版中不可用的不同 JIT 或 GC 组件。

所以现在让我们澄清一下:所有 OpenJDK 发行版均来自同一源代码,并且在比较相同版本和构建标志配置时,不应存在任何系统性能相关差异。

注意

一些供应商选择非常特定的构建标志组合,这些组合非常适合它们的云环境,一些研究表明这些组合可能对某些工作负载有帮助,但情况并不明确。

偶尔社交媒体激动地报道发现一些发行版之间存在显著的性能差异。然而,在足够受控的环境中进行这类测试是非常困难的——因此除非可以独立验证具有统计显著性,否则应对任何结果持健康的怀疑态度。

Java 发布周期

现在我们可以通过简要讨论 Java 发布周期来完整地说明这个情况。

新功能开发是公开的——在一组 GitHub 存储库中进行。小到中等的功能和错误修复以拉取请求直接提交到主 OpenJDK 存储库的主分支中。⁴

每 6 个月,Java 会从主要版本中切出一个新版本。错过“列车”的功能必须等待下一个发布——自 2017 年 9 月以来,这种 6 个月的节奏和严格的时间表一直得到维持。这些发布被称为“功能发布”,由 Oracle 作为 Java 的管理者运行。

Oracle 在任何给定的功能版本发布后立即停止工作。然而,一个适当资格和能力的 OpenJDK 成员可以在 Oracle 退出后继续运行该版本。迄今为止,这仅发生在某些版本上——实际上是 Java 8、11、17 和 21,被称为更新版本

这些版本的重要性在于它们符合 Oracle 的长期支持版本概念。从技术上讲,这纯粹是 Oracle 销售流程的构建——即那些不想每 6 个月升级 Java 的 Oracle 客户有某些稳定版本可供 Oracle 支持。

在实践中,Java 生态系统已经明显拒绝了官方 Oracle 的“每 6 个月升级您的 JDK”的教条——项目团队和工程经理根本不愿意这样做。相反,团队会从一个 LTS 版本升级到下一个,更新版本项目(如 8u、11u、17u 和 21u)仍然活跃,提供安全补丁和少量错误修复和反向移植。Oracle 和社区一起努力保持所有这些维护的代码流的安全性。

这是我们回答如何选择 Java 发行版的最后一部分。如果您想要一个零成本的 Java 发行版,它能够接收安全补丁并且有可能进行安全(以及可能的错误)修复,请选择您喜欢的 OpenJDK 供应商并坚持使用 LTS 版本。包括:Adoptium、Red Hat、Amazon、Microsoft 和 Azul 在内的任何一个都是一个不错的选择——其他一些也是如此。根据您部署软件的方式和位置(例如,在 AWS 上部署的应用程序可能更喜欢 Amazon 的 Corretto 发行版),您可能有理由选择其中之一。

要了解更多关于各种选项和一些许可复杂性的详细指南,您可以查阅Java 仍然是免费的。这份文件由Java Champions编写,他们是独立的 Java 专家和领导者。

总结

本章中,我们快速浏览了 JVM 的整体解剖,包括:字节码编译、解释、JIT 编译成本机代码、内存管理、线程、Java 进程监控的生命周期,以及最后讨论了 Java 的构建和分发。

我们只能触及一些最重要的主题,几乎这里提到的每个主题都有一个丰富的、完整的故事背后,进一步的调查将会有所收获。

在第四章中,我们将开始探讨垃圾收集的旅程,从标记-清除的基本概念入手,深入到具体细节,包括 HotSpot 如何实现 GC 的一些内部细节。

¹ B. Stroustrup,“抽象和 C++机器模型”,计算机科学讲义,第 3605 卷(Springer 2005)

² https://openjdk.org/legal/gplv2+ce.xhtml

³ 后者已多次更改,因此链接到当前最新版本可能并不实用——到您阅读本文时可能已过时。

https://github.com/openjdk/jdk

posted @ 2024-06-15 12:23  绝不原创的飞龙  阅读(6)  评论(0编辑  收藏  举报