Java 高性能指南(一)
原文:
zh.annas-archive.org/md5/075370e1159888d7fd67fe4f209e6d1e译者:飞龙
前言
当 O'Reilly 首次找我写一本关于 Java 性能调优的书时,我有些犹豫。我想,Java 性能调优?这不是早就完成了吗?是的,我仍然每天都在努力改进 Java(以及其他)应用程序的性能,但我更愿意认为,我大部分时间都在处理算法效率低下和外部系统瓶颈,而不是直接与 Java 调优相关的内容。
一瞬间的反思让我确信,我又一次在自欺欺人。无疑,整体系统性能占据了我大部分时间,有时我会遇到使用O(n²) 算法而本可以使用O(logN) 性能的代码。尽管如此,事实证明,每天我都会思考垃圾收集(GC)性能,或者 JVM 编译器的性能,或者如何从 Java APIs 中获得最佳性能。
这并不是为了贬低过去 20 多年来在 Java 和 JVM 性能方面取得的巨大进展。当我在上世纪 90 年代末担任 Sun 的 Java 传道士时,唯一真正的“基准测试”是来自 Pendragon 软件的 CaffeineMark 2.0。由于各种原因,那个基准测试的设计很快就限制了其价值;然而在当时,我们喜欢告诉大家,基于那个基准测试,Java 1.1.8 的性能比 Java 1.0 的性能快了八倍。而这是真实的——Java 1.1.8 拥有一个真正的即时编译器,而 Java 1.0 基本完全是解释执行的。
然后,标准委员会开始制定更严格的基准测试,Java 性能开始围绕这些测试展开。结果是 JVM 的所有领域——垃圾收集、编译和 API 内部——都在持续改进。当然,这个过程至今仍在进行中,但关于性能工作的一个有趣事实是,它变得越来越困难。通过引入即时编译器实现性能增长八倍是一件直截了当的工程问题,尽管编译器继续改进,但我们不会再见到类似的改进了。并行化垃圾收集器是一个巨大的性能改进,但近年来的变化更多地是渐进的。
这是应用程序的典型过程(而 JVM 本身只是另一个应用程序):在项目开始阶段,很容易找到可以带来巨大性能改进的架构变更(或代码缺陷)。在成熟的应用程序中,找到这样的性能改进是很少见的。
这个原则是我最初关心的基础,很大程度上,工程界可能已经完成了关于 Java 性能的讨论。有几件事让我改变了看法。首先是我每天看到关于 JVM 在特定情况下的表现如何的问题数量。新工程师们一直在学习 Java,并且在某些领域,JVM 的行为仍然足够复杂,因此操作指南仍然有益。其次是计算环境的变化似乎改变了工程师们今天面临的性能问题。
过去几年来,性能问题已经变得复杂多样。一方面,现在很普遍使用能够运行具有非常大堆内存的 JVM 的超大机器。JVM 已经采取措施来解决这些问题,引入了新的垃圾收集器(G1),作为一种新技术,需要比传统的收集器更多的手动调优。与此同时,云计算重新强调了小型、单 CPU 机器的重要性:你可以去 Oracle、Amazon 或其他许多公司,廉价租用一个单 CPU 机器来运行小型应用服务器。(实际上你并没有获得一个单 CPU 机器:你得到的是一个虚拟操作系统镜像在一个非常大的机器上运行,但虚拟操作系统限制了使用单 CPU。从 Java 的角度来看,这与单 CPU 机器是相同的。)在这些环境中,正确管理少量内存变得非常重要。
Java 平台也在不断发展。每个新版 Java 都提供新的语言特性和新的 API,以提高开发者的生产力——尽管不总是提高应用程序的性能。合理使用这些语言特性的最佳实践可以帮助区分一个优秀的应用程序和一个平庸的应用程序。平台的演进也带来了有趣的性能问题:使用 JSON 在两个程序之间交换信息要比设计高度优化的专有协议简单得多。为开发者节省时间是一个重大的胜利——但确保这种生产力的提升伴随着性能的提升(或至少不下降)才是真正的目标。
谁应该(和不应该)阅读本书
本书旨在帮助性能工程师和开发人员了解 JVM 和 Java API 各方面对性能的影响。
如果现在是星期天深夜,你的网站将在星期一上线,而你正在寻找快速解决性能问题的方法,那这本书不适合你。
如果您是性能分析新手,并在 Java 中开始分析,本书可以帮助您。当然,我的目标是提供足够的信息和背景,使初学者工程师能够理解如何将基本的调优和性能原则应用于 Java 应用程序。然而,系统分析是一个广泛的领域。有许多关于系统分析的优秀资源(当然,这些原则也适用于 Java),从这个意义上讲,本书理想情况下将是这些文本的有用伴侣。
然而,在根本层面上,使 Java 运行速度真正快需要深入理解 JVM(和 Java API)的实际工作方式。存在数百个 Java 调优标志,并且调优 JVM 不应该只是盲目尝试它们并查看哪个有效。相反,我的目标是提供关于 JVM 和 API 正在做什么的详细知识,希望如果你理解了这些东西是如何工作的,你就能够查看应用程序的具体行为并理解为什么它表现糟糕。理解了这一点,消除不良(性能不佳)行为变得简单(或者至少更简单)起来。
Java 性能工作的一个有趣方面是,开发人员通常与性能或质量保证组的工程师背景大不相同。我知道有些开发人员可以记住成千上万个不常用的 Java API 方法签名,但却不知道 -Xmn 标志的含义。我也知道测试工程师可以通过设置垃圾收集器的各种标志来获得最后一丝性能,但是在 Java 中几乎无法编写一个合适的“Hello, World”程序。
Java 的性能涵盖了这两个方面:调优编译器和垃圾收集器等标志,以及最佳实践中 API 的使用。因此,我假设你已经很好地理解了如何在 Java 中编写程序。即使你的主要兴趣不在 Java 的编程方面,我也花了大量时间讨论程序,包括用于示例中许多数据点的示例程序。
不过,如果你主要关注的是 JVM 本身的性能——即如何在不进行任何编码的情况下改变 JVM 的行为——那么本书的大部分内容仍然对你有益。请随意跳过编码部分,专注于你感兴趣的领域。也许在这个过程中,你会对 Java 应用程序如何影响 JVM 性能有所了解,并开始建议开发人员进行更改,以便让你的性能测试工作更轻松。
第二版的新内容
自第一版以来,Java 采用了每六个月发布一次的周期,定期进行长期支持发布;这意味着与出版同时支持的当前版本是 Java 8 和 Java 11。虽然第一版覆盖了 Java 8,在当时还是非常新的。本版侧重于更加成熟的 Java 8 和 Java 11,重点更新了 G1 垃圾收集器和 Java Flight Recorder。还关注 Java 在容器化环境中行为变化的变化。
本版涵盖了 Java 平台的新功能,包括新的微基准测试工具(jmh)、新的即时编译器、应用程序类数据共享和新的性能工具,以及对 Java 11 新功能(如紧凑字符串和字符串连接)的覆盖。
本书使用的约定
本书中使用了以下排版约定:
斜体
指示新术语、URL、电子邮件地址、文件名和文件扩展名。
固定宽度
用于程序清单,以及在段落内引用程序元素,如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。
固定宽度粗体
显示用户应该按字面输入的命令或其他文本。
固定宽度斜体
显示应替换为用户提供的值或由上下文确定的值的文本。
此元素表示主要点的摘要。
使用代码示例
补充材料(代码示例、练习等)可从https://github.com/ScottOaks/JavaPerformanceTuning下载。
本书旨在帮助您完成工作。通常情况下,如果本书提供示例代码,您可以在您的程序和文档中使用它。除非您复制了代码的大部分,否则无需联系我们以获得许可。例如,编写使用本书中几个代码片段的程序不需要许可。出售或分发 O’Reilly 书籍的示例代码需要许可。通过引用本书并引用示例代码回答问题不需要许可。将本书大量示例代码整合到产品文档中需要许可。
我们感谢,但不要求归属。归属通常包括标题、作者、出版商和 ISBN。例如:“Java 性能 by Scott Oaks (O’Reilly)。版权 2020 Scott Oaks,978-1-492-05611-9。”
如果您觉得您使用的代码示例超出了合理使用范围或上述许可,请随时通过http://oreilly.com与我们联系。
致谢
我要感谢在我写作这本书的过程中帮助过我的每一个人。在很多方面,这本书积累了我在 Java 性能组及其他 Sun Microsystems 和 Oracle 工程组工作的 20 年知识,因此提供积极反馈的人员名单相当广泛。感谢在这段时间内与我合作的所有工程师,特别是那些耐心回答我随机问题的人,谢谢你们!
我特别要感谢 Stanley Guan、Azeem Jiva、Kim LiChong、Deep Singh、Martijn Verburg 和 Edward Yue Shung Wong,他们花时间审阅草稿并提供宝贵的反馈。尽管这里的材料得到了他们的帮助而显著改进,但我确信他们无法找出所有我的错误。第二版得到了 Ben Evans、Rod Hilton 和 Michael Hunger 全面和周到的帮助,极大地改进了这本书。我的同事 Eric Caspole、Charlie Hunt 和 Robert Strout 在 Oracle HotSpot 性能组中耐心地帮助我解决了各种问题。
在 O’Reilly 的制作人员一如既往地非常乐于助人。我很荣幸能与编辑 Meg Blanchette 共同完成第一版,而 Amelia Blevins 则细致而仔细地指导了第二版。在整个过程中,感谢你们的鼓励!最后,我必须感谢我的丈夫 James,忍受了漫长的夜晚以及那些周末晚餐,而我总是心不在焉。
第一章:介绍
本书讨论了 Java 性能的艺术和科学。
这个说法的科学部分并不令人惊讶;关于性能的讨论涉及大量的数字、测量和分析。大多数性能工程师背景都是科学,应用科学严谨性是实现最大性能的关键部分之一。
艺术部分是什么呢?性能调优既是一部分艺术,又是一部分科学的概念并不新鲜,但在性能讨论中很少得到明确的承认。部分原因是“艺术”的概念与我们的训练相抵触。但对一些人来说看起来像艺术的东西,实质上基于深厚的知识和经验。据说魔术与高度先进的技术是无法区分的,确实,对于圆桌骑士来说,手机看起来就像是魔法一样。同样地,一名优秀的性能工程师所产生的工作可能看起来像艺术,但这种艺术实际上是深知识、经验和直觉的应用。
这本书无法帮助你在经验和直觉方面,但它可以提供深刻的知识——通过长期应用知识来帮助你发展成为一名优秀的 Java 性能工程师所需的技能。目标是让你深入了解 Java 平台的性能方面。
这些知识分为两大类。第一类是 Java 虚拟机(JVM)本身的性能:JVM 的配置方式影响程序性能的许多方面。其他语言有经验的开发者可能会觉得需要进行调优有些烦人,尽管事实上调优 JVM 完全类似于编译 C++程序时的测试和选择编译器标志,或者为 PHP 编码者在php.ini文件中设置适当的变量等等。
第二个方面是理解 Java 平台的特性如何影响性能。在这里,“平台”一词的使用很重要:一些特性(例如线程和同步)是语言的一部分,而一些特性(例如字符串处理)则是标准 Java API 的一部分。尽管 Java 语言和 Java API 之间有重要的区别,在这种情况下它们将被类似地对待。本书涵盖了平台的这两个方面。
JVM 的性能在很大程度上取决于调优标志,而平台的性能更多地取决于在应用程序代码中使用最佳实践。长期以来,这些被认为是不同的专业领域:开发者编写代码,性能组测试并推荐性能问题的修复。这从未是一个特别有用的区分 —— 任何与 Java 工作的人都应该同样擅长理解代码在 JVM 中的行为以及哪些调优可能有助于其性能。随着项目转向 DevOps 模型,这种区分开始变得不那么严格。对整个领域的了解才能使你的工作具有艺术的氛围。
简要概述
但首先:第二章讨论了测试 Java 应用程序的一般方法论,包括 Java 基准测试的陷阱。由于性能分析需要了解应用程序的操作,第三章概述了一些可用于监视 Java 应用程序的工具。
现在是深入探讨性能的时候了,首先专注于常见的调优方面:即时编译(第四章)和垃圾回收(第五章和第六章)。其余章节则侧重于 Java 平台各部分的最佳实践使用:Java 堆内存使用(第七章)、本地内存使用(第八章)、线程性能(第九章)、Java 服务器技术(第十章)、数据库访问(第十一章)以及 Java SE API 的通用技巧(第十二章)。
附录 A 列出了本书讨论的所有调优标志,以及它们在哪些章节中进行了交叉引用。
平台与约定
虽然本书关注于 Java 的性能,但该性能将受到几个因素的影响:Java 本身的版本,当然,以及它运行在的硬件和软件平台。
Java 平台
本书涵盖了 Oracle HotSpot Java 虚拟机(JVM)和 Java 开发工具包(JDK)的性能,分别针对版本 8 和 11。这也被称为 Java 标准版(SE)。Java 运行环境(JRE)是 JDK 的子集,仅包含 JVM,但由于 JDK 中的工具对性能分析至关重要,本书将重点介绍 JDK。实际上,这意味着它还涵盖了从该技术的 OpenJDK 代码库衍生出的平台,包括由 AdoptOpenJDK 项目 发布的 JVM。严格来说,Oracle 二进制文件需要许可证才能用于生产,而 AdoptOpenJDK 二进制文件则带有开源许可证。对于我们的目的,我们将认为这两个版本是同一件事情,我们将称之为 JDK 或 Java 平台。¹
这些版本已经经历了各种错误修复版本。在我撰写本文时,Java 8 的当前版本是 jdk8u222(版本 222),Java 11 的当前版本是 11.0.5。重要的是至少使用这些版本(如果不是更高版本),特别是在 Java 8 的情况下。Java 8 的早期版本(大约到 jdk8u60)不包含本书中讨论的许多重要性能增强和功能(特别是垃圾收集和 G1 垃圾收集器方面)。
选择这些 JDK 版本是因为它们来自 Oracle 的长期支持(LTS)。Java 社区可以自由发展自己的支持模型,但到目前为止,他们一直在遵循 Oracle 的模式。因此,这些发布版本将会得到支持,并且将在相当长的时间内可用:通过 AdoptOpenJDK 至少支持到 2023 年的 Java 8(稍后通过扩展的 Oracle 支持合同),以及至少支持到 2022 年的 Java 11。预计下一个长期支持版本将在 2021 年底发布。
对于临时发布版本,显然 Java 11 的讨论包括最初在 Java 9 或 Java 10 中首次提供的功能,尽管这些版本都不受 Oracle 和整个社区的支持。事实上,当讨论这些功能时,我的描述可能有些不够准确;可能会让人觉得我在说功能 X 和 Y 最初是在 Java 11 中包含的,但实际上它们可能在 Java 9 或 10 中就已经存在了。Java 11 是第一个包含这些功能的 LTS 版本,这才是重要的部分:由于 Java 9 和 10 并未被使用,功能首次出现的时间并不重要。同样,尽管在本书发布时 Java 13 将会发布,但对 Java 12 或 Java 13 的涵盖范围不是很广。您可以在生产中使用这些版本,但仅限于六个月,之后您将需要升级到新版本(所以当您阅读本书时,Java 12 已不再受支持,如果 Java 13 受支持,它将很快被 Java 14 替代)。我们将简要介绍一些这些临时发布版本的功能,但由于这些版本不太可能在大多数环境中投入生产,因此重点仍然放在 Java 8 和 11 上。
还有其他可用的 Java 语言规范实现,包括开源实现的分支。AdoptOpenJDK 提供了其中一个(Eclipse OpenJ9),其他供应商也提供了其他实现。尽管所有这些平台都必须通过兼容性测试才能使用 Java 名称,但这种兼容性并不总是延伸到本书讨论的主题。调整标志尤其如此。所有 JVM 实现都有一个或多个垃圾收集器,但调整每个供应商的 GC 实现的标志是产品特定的。因此,虽然本书的概念适用于任何 Java 实现,但具体的标志和建议仅适用于 HotSpot JVM。
上述警告适用于 HotSpot JVM 的早期版本 —— 从一个版本到另一个版本,标志及其默认值可能会发生变化。本文讨论的标志适用于 Java 8(具体来说是版本 222)和 11(具体来说是 11.0.5)。稍后的版本可能会轻微更改部分信息。请始终查阅发布说明以获取重要更改信息。
在 API 级别上,不同的 JVM 实现要兼容得多,尽管即便如此,在 Oracle HotSpot Java 平台和其他平台中实现特定类的方式之间可能仍存在细微差异。这些类必须在功能上等效,但实际实现可能会有所变化。幸运的是,这种情况并不经常发生,而且不太可能对性能造成重大影响。
在本书的剩余部分中,术语Java和JVM应理解为特指 Oracle HotSpot 实现。严格来说,说“JVM 在首次执行时不会编译代码”是错误的;一些 Java 实现在首次执行时确实会编译代码。但使用这种简写比继续写(和阅读)“Oracle HotSpot JVM…”要简单得多。
JVM 调整标志
除了一些例外,JVM 接受两种类型的标志:布尔标志和需要参数的标志。
布尔标志使用以下语法:-XX:+FlagName 启用标志,-XX:-FlagName 禁用标志。
需要参数的标志使用以下语法:-XX:FlagName=something,表示将 FlagName 的值设置为 something。在文本中,标志的值通常用表示任意值的 something 表示。例如,-XX:NewRatio=N 意味着 NewRatio 标志可以设置为任意值 N(N 的含义是讨论的重点)。
每个标志的默认值在引入标志时讨论。该默认值通常基于 JVM 运行的平台以及 JVM 的其他命令行参数的组合。如果有疑问,“基本 VM 信息”显示如何使用 -XX:+PrintFlagsFinal 标志(默认为 false)来确定在特定环境中特定命令行下特定标志的默认值。根据环境自动调整标志的过程称为人体工程学。
从 Oracle 和 AdoptOpenJDK 网站下载的 JVM 称为 JVM 的产品构建。当 JVM 从源代码构建时,可以产生许多构建:调试构建,开发者构建等。这些构建通常具有附加功能。特别是,开发者构建包含了更大量的调整标志集,使开发者可以实验 JVM 使用的各种算法的最微小操作。这些标志通常不在本书中考虑。
硬件平台
当本书的第一版出版时,硬件环境看起来与今天不同。多核机器很受欢迎,但 32 位平台和单 CPU 平台仍然在广泛使用。今天正在使用的其他平台——虚拟机和软件容器——正在崭露头角。以下是这些平台如何影响本书主题的概述。
多核硬件
今天几乎所有的机器都有多个执行核心,对 JVM(以及任何其他程序)而言,这些核心看起来像多个 CPU。通常,每个核心都启用了超线程。超线程是英特尔首选的术语,虽然 AMD(和其他公司)使用术语同时多线程,一些芯片制造商则称之为核心内的硬件线程。这些都是同一回事,我们将这项技术称为超线程。
从性能的角度来看,机器的重要性在于其核心数。让我们以一个基本的四核机器为例:每个核心(大部分情况下)可以独立处理,因此一个有四个核心的机器可以实现比单核心机器高四倍的吞吐量。(当然,这取决于软件的其他因素。)
在大多数情况下,每个核心将包含两个硬件线程或超线程。这些线程不是彼此独立的:核心一次只能运行其中一个。通常情况下,线程会停滞:例如,它需要从主存中加载一个值,这个过程可能需要几个周期。在单线程核心中,线程在这一点上停滞,这些 CPU 周期就浪费了。在双线程核心中,核心可以切换并执行另一个线程的指令。
因此,我们启用超线程的四核机器看起来可以同时执行来自八个线程的指令(即使在技术上,每个 CPU 周期只能执行四条指令)。对操作系统来说——因此对 Java 和其他应用程序来说——这台机器看起来有八个 CPU。但是所有这些 CPU 在性能上并不相等。如果我们运行一个 CPU 密集型任务,它将使用一个核心;第二个 CPU 密集型任务将使用第二个核心;依此类推,最多四个:我们可以运行四个独立的 CPU 密集型任务并获得四倍的吞吐量提升。
如果我们添加第五个任务,它只有在其他任务之一停滞时才能运行,平均情况下这种情况发生的概率在 20%到 40%之间。每增加一个额外的任务都面临相同的挑战。因此,添加第五个任务只会增加大约 30%的性能;最终,这八个 CPU 将给我们提供约五到六倍于单个核心(无超线程)的性能。
您将在几个部分看到这个例子。垃圾收集非常依赖 CPU,因此第五章展示了超线程如何影响垃圾收集算法的并行化。第九章总结了如何充分利用 Java 的线程设施,您也将在那里看到超线程核心扩展的例子。
软件容器
近年来 Java 部署中最大的变化是它们现在经常部署在软件容器中。当然,这种变化不仅限于 Java,它是云计算推动的行业趋势。
这里有两个重要的容器。首先是虚拟机,它在虚拟机运行的硬件子集上设置了操作系统的完全隔离副本。这是云计算的基础:你的云计算供应商有一个带有非常大机器的数据中心。这些机器可能有 128 个核心,尽管由于成本效益的原因,它们可能更小。从虚拟机的角度来看,这并不重要:虚拟机被授予对硬件子集的访问。因此,给定的虚拟机可能有两个核心(并且四个 CPU,因为它们通常是超线程的)和 16 GB 内存。
从 Java 的角度(以及其他应用程序的角度),这个虚拟机与一个具有两个核心和 16 GB 内存的常规机器是无法区分的。为了调优和性能目的,你只需以相同的方式考虑它。
第二个需要注意的容器是 Docker 容器。运行在 Docker 容器中的 Java 进程并不一定知道它在这样一个容器中(尽管可以通过检查找出),但 Docker 容器只是一个进程(可能有资源限制)在运行中的操作系统内。因此,它与其他进程在 CPU 和内存使用方面的隔离有所不同。正如你将看到的,Java 处理这一点在早期 Java 8 版本(直至更新 192)与后来的 Java 8 版本(以及所有 Java 11 版本)之间有所不同。
默认情况下,Docker 容器可以自由使用机器的所有资源:它可以使用机器上所有可用的 CPU 和所有可用的内存。如果我们只想要使用 Docker 来简化在机器上部署我们的单个应用程序(因此该机器将仅运行该 Docker 容器),那没问题。但通常我们希望在一台机器上部署多个 Docker 容器并限制每个容器的资源。实际上,考虑到我们有四核心的机器和 16 GB 内存,我们可能希望运行两个 Docker 容器,每个容器仅访问两个核心和 8 GB 内存。
配置 Docker 完成这一点相对简单,但在 Java 层面可能会出现复杂情况。根据运行 JVM 的机器的大小,许多 Java 资源会自动配置(或者根据人体工程学)。这包括默认堆大小和垃圾回收器使用的线程数,详细解释在第五章中,以及一些线程池设置,在第九章中提到。
如果你正在运行 Java 8 的最新版本(更新版本 192 或更高)或 Java 11,JVM 会如你所希望地处理这个问题:如果你将 Docker 容器限制为仅使用两个核心,基于机器 CPU 计数的人体工程学设置的值将基于 Docker 容器的限制。类似地,默认情况下基于机器上内存量的堆和其他设置将基于给定给 Docker 容器的任何内存限制。
在早期的 Java 8 版本中,JVM 对容器强制执行的任何限制都没有了解:当它检查环境以找出可用的内存量,以便计算其默认堆大小时,它将看到机器上的所有内存(而不是我们希望的 Docker 容器允许使用的内存量)。类似地,当它检查可用于调整垃圾收集器的 CPU 数量时,它将看到机器上的所有 CPU,而不是分配给 Docker 容器的 CPU 数量。因此,JVM 将运行不够优化:它会启动过多的线程,并设置过大的堆。拥有过多的线程会导致一些性能下降,但这里真正的问题是内存:堆的最大大小可能会大于分配给 Docker 容器的内存。当堆增长到该大小时,Docker 容器(以及 JVM)将被终止。
在早期的 Java 8 版本中,你可以手动设置内存和 CPU 使用的适当值。当我们遇到这些调整时,我会指出哪些需要针对这种情况进行调整,但最好的方法是直接升级到更新的 Java 8 版本(或 Java 11)。
Docker 容器对 Java 提出了一个额外的挑战:Java 配备了一套丰富的工具用于诊断性能问题。这些工具通常在 Docker 容器中不可用。我们将在第三章中更详细地讨论这个问题。
完整的性能故事
本书专注于如何最佳利用 JVM 和 Java 平台 API,以使程序运行更快,但许多外部影响会影响性能。这些影响偶尔会在讨论中出现,但因为它们不特定于 Java,所以并未详细讨论。JVM 和 Java 平台的性能只是快速性能的一小部分。
本节介绍了至少与本书涵盖的 Java 调优主题同等重要的外部影响因素。本书基于 Java 知识的方法与这些影响互补,但其中许多超出了我们讨论的范围。
编写更好的算法
Java 的许多细节会影响应用程序的性能,并讨论了许多调优标志。但并没有神奇的-XX:+RunReallyFast选项。
最终,应用程序的性能取决于编写的质量。如果程序循环遍历数组中的所有元素,JVM 将优化它执行数组边界检查的方式,使得循环运行更快,并且它可能展开循环操作以提供额外的加速。但是,如果循环的目的是查找特定项,世界上没有任何优化可以使基于数组的代码像使用哈希映射的不同版本一样快。
当涉及到快速性能时,一个良好的算法是最重要的事情。
写更少的代码
我们中的一些人为了赚钱编写程序,一些人为了乐趣,一些人为了回馈社区,但我们所有人都在编写程序(或者参与团队编写程序)。通过修剪代码来感觉自己在项目中做出贡献很难,有些经理仍然通过开发者编写的代码量来评估开发者。
我明白这一点,但这里的矛盾在于一个小而精良的程序将比一个大而精良的程序运行得更快。这对所有的计算机程序通常都是正确的,特别是适用于 Java 程序。需要编译的代码越多,程序启动运行的时间就越长。需要分配和丢弃的对象越多,垃圾收集器需要做的工作就越多。分配和保留的对象越多,垃圾收集周期就越长。需要从磁盘加载到 JVM 中的类越多,程序启动的时间就越长。执行的代码越多,它就越不可能适应机器上的硬件缓存。执行的代码越多,执行时间就越长。
我认为这是“千刀万剐”的原则。开发者会争辩说他们只是添加一个非常小的功能,并且这几乎不需要时间(特别是如果该功能没有被使用)。然后同一项目中的其他开发者也会做同样的主张,突然间性能就退步了几个百分点。这个周期在下一个版本中重复,现在程序性能已经退步了 10%。在过程中的几次,性能测试可能会达到某个资源阈值——内存使用的临界点、代码缓存溢出等等。在这些情况下,定期的性能测试将捕获到特定条件,性能团队可以修复看似重大的退化。但随着小的退化逐渐增加,修复它们将变得越来越困难。
我并不主张您永远不应该向产品添加新功能或新代码;显然增强程序会带来好处。但要意识到您正在做出的权衡,并且在可能时简化流程。
哦,继续,过早优化吧
通常认为唐纳德·克努特(Donald Knuth)创造了“过早优化”(premature optimization)一词,开发人员经常使用这个词来声称他们的代码性能并不重要,如果性能确实重要,那么我们在运行代码之前就不会知道。如果你还没有见过完整的引用,那么就是这样:“我们应该忘记小效率,大约有 97%的时间;过早优化是所有邪恶的根源。”(3)
这句格言的要点是,最终,你应该编写简洁、直接、易于阅读和理解的代码。在这种情况下,“优化”的理解是指采用复杂的算法和设计更改来复杂化程序结构,但提供更好的性能。这些类型的优化确实最好在程序的性能分析显示从中获得了巨大好处时再进行。
然而,在这种情况下,“优化”并不意味着避免已知对性能有害的代码结构。每一行代码都涉及一种选择,如果你在两种简单、直接的编程方式之间进行选择,选择更高效的一种。
在某个层面上,经验丰富的 Java 开发人员已经很好地理解了这一点(这是他们随着时间学会的艺术的一个例子)。考虑以下代码:
log.log(Level.FINE, "I am here, and the value of X is "
+ calcX() + " and Y is " + calcY());
此代码进行了字符串拼接,这可能是不必要的,因为只有在设置了非常高的日志记录级别时才会记录消息。如果消息未打印,则还将不必要地调用calcX()和calcY()方法。有经验的 Java 开发人员会本能地拒绝这种做法;一些集成开发环境甚至会标记代码并建议修改它。(不过工具并不完美: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()});
}
这避免了字符串拼接(消息格式不一定更有效,但更干净),并且除非启用了日志记录,否则不会调用方法或分配对象数组。
以这种方式编写代码仍然干净且易于阅读;它并没有比编写原始代码需要更多的工作量。好吧,好吧,它需要多输入一些按键和额外的逻辑行。但这并不是应该避免的过早优化类型;这是好程序员学会做出的选择。
不要让来自先驱英雄的脱离上下文的教条阻止你思考你正在编写的代码。本书中将在其他章节中看到类似的例子,包括第九章,在该章节中讨论了处理对象向量的看似无害的循环构造的性能问题。
不妨另寻他路:数据库总是瓶颈。
如果你正在开发没有使用外部资源的独立 Java 应用程序,那么该应用程序的性能(大多数情况下)是唯一重要的。一旦添加了外部资源(例如数据库),两个程序的性能都变得重要起来。在一个分布式环境中——例如具有 Java REST 服务器、负载均衡器、数据库和后端企业信息系统——Java 服务器的性能可能是性能问题中最不重要的部分。
这不是一本关于整体系统性能的书。在这样的环境中,必须采取有条不紊的方法来处理系统的所有方面。必须测量和分析系统各部分的 CPU 使用率、I/O 延迟和吞吐量;只有这样,我们才能确定哪个组件导致了性能瓶颈。有关该主题的优秀资源可供使用,并且这些方法和工具并不专门针对 Java。我假设你已经进行了分析,并确定了需要改进的是你环境中的 Java 组件。
另一方面,不要忽视初始分析。如果数据库是瓶颈(提示:确实是),调整访问数据库的 Java 应用程序对整体性能毫无帮助。事实上,这可能适得其反。一般而言,当负载增加到一个负载过重的系统中时,该系统的性能变得更糟。如果在 Java 应用程序中做出了使其更有效的改变——这只会增加已经超负荷的数据库的负载——总体性能实际上可能会下降。危险就在于得出错误的结论,即不应该使用特定的 JVM 改进。
这个原则——在一个性能不佳的系统组件上增加负载会使整个系统变慢——并不局限于数据库。当负载增加到一个 CPU 密集型的服务器上,或者更多线程开始访问已经有线程在等待的锁,或者任何其他情况时,都会应用这个原则。一个仅涉及 JVM 的极端示例显示在第九章中。
优化常见情况
很诱人——特别是考虑到“千刀万剐”的综合症——将所有性能方面视为同等重要。但我们应该专注于常见用例场景。这个原则以几种方式体现:
-
通过对代码进行分析并专注于在分析中占用最多时间的操作来优化代码。但是,请注意,这并不意味着只查看分析中的叶子方法(参见第三章)。
-
将奥卡姆剃刀应用于诊断性能问题。性能问题的最简单解释是最可信的原因:新代码中的性能 bug 比机器上的配置问题更有可能,后者比 JVM 或操作系统的 bug 更有可能。晦涩的操作系统或 JVM bug 确实存在,随着排除更可信的性能问题的原因,可能发现某些测试用例不知何故触发了这种潜在 bug。但不要首先考虑不太可能的情况。
-
为应用程序的最常见操作编写简单算法。例如,一个程序估算一个数学公式,用户可以选择是否在 10%误差范围内得到答案,或者 1%误差范围。如果大多数用户满意于 10%的误差范围,优化该代码路径——即使这意味着减慢提供 1%误差范围的代码。
概要
Java 具有使其可能从 Java 应用程序中获得最佳性能的特性和工具。本书将帮助您理解如何最好地利用 JVM 的所有特性,以便获得快速运行的程序。
然而,在许多情况下,请记住 JVM 只是整体性能图景中的一小部分。在 Java 环境中,数据库和其他后端系统的性能至少与 JVM 的性能一样重要。本书不关注该级别的性能分析——假定已经进行了尽职调查,以确保 Java 环境的组件是系统中重要的瓶颈。
然而,JVM 与系统其他领域的交互同样重要——无论是直接的(例如,进行最佳的数据库调用方式)还是间接的(例如,优化共享大型系统多个组件的应用程序的本地内存使用)。本书中的信息应该有助于解决沿这些线路的性能问题。
¹ 很少情况下,这两者之间存在差异;例如,AdoptOpenJDK 版本的 Java 在 JDK 11 中包含新的垃圾收集器。当发生这些差异时,我会指出这些差异。
² 在 Docker 中,可以为 CPU 限制指定分数值。Java 将所有分数值都向上舍入到下一个最高整数。
³ 谁最初说过这句话,唐纳德·克努斯还是托尼·霍尔之间存在一些争议,但它出现在克努斯的一篇名为“带有 goto 语句的结构化编程”的文章中。在上下文中,这是一个优化代码的论据,即使需要像 goto 语句这样的不优雅解决方案。
第二章:性能测试方法
本章讨论了从性能测试中获取结果的四个原则:测试真实应用;理解吞吐量、批处理和响应时间;理解变异性;以及早期和频繁地测试。这些原则构成了后续章节建议的基础。性能工程的科学就是通过这些原则来覆盖的。在应用程序上执行性能测试是可以的,但如果没有这些测试背后的科学分析,它们往往会导致不正确或不完整的分析。本章介绍了如何确保测试产生有效的分析。
后续章节中的许多示例使用了一个模拟股票价格系统的常见应用程序;该应用程序也在本章中进行了概述。
测试一个真实应用
第一个原则是测试应该在实际产品上以产品将被使用的方式进行。粗略地说,可以使用三类代码进行性能测试:微基准测试、宏基准测试和中基准测试。每种都有其自身的优缺点。包含实际应用程序的类别将提供最佳结果。
微基准测试
微基准测试 是一种设计用来测量小单位性能的测试,以便决定哪个多个备选实现是首选:创建线程的开销与使用线程池的开销,执行一个算法与替代实现的时间等等。
微基准测试可能看起来是一个不错的主意,但是 Java 的特性使其对开发人员很有吸引力 —— 即时编译和垃圾回收 —— 这使得编写正确的微基准测试变得困难。
微基准测试必须使用其结果
微基准测试与常规程序在各种方面有所不同。首先,因为 Java 代码在首次执行时是解释执行的,随着执行时间的增加,它会变得更快。因此,所有基准测试(不仅仅是微基准测试)通常包括一个预热期,期间 JVM 可以将代码编译成其最佳状态。
最佳状态可能包括许多优化。例如,这里有一个看似简单的循环来计算一个计算第 50 个斐波那契数的方法的实现:
public void doTest() {
// Main Loop
double l;
for (int i = 0; i < nWarmups; i++) {
l = fibImpl1(50);
}
long then = System.currentTimeMillis();
for (int i = 0; i < nLoops; i++) {
l = fibImpl1(50);
}
long now = System.currentTimeMillis();
System.out.println("Elapsed time: " + (now - then));
}
这段代码想要测量执行fibImpl1()方法的时间,因此它首先热身编译器,然后测量现在已编译的方法。但很可能那个时间是 0(或者更可能是运行没有主体的for循环的时间)。由于l的值没有在任何地方读取,编译器可以自由地跳过其计算。这取决于fibImpl1()方法中还发生了什么,但如果只是一个简单的算术操作,就可以全部跳过。还可能只有方法的部分会被执行,甚至可能产生错误的l值;由于该值从未被读取,因此没有人会知道。(有关如何消除循环的详细信息,请参阅第四章。)
有一种解决这个特定问题的方法:确保每个结果都被读取,而不仅仅是写入。实际上,将l的定义从局部变量更改为实例变量(用volatile关键字声明)将允许测量方法的性能。(l实例变量必须声明为volatile的原因可以在第九章中找到。)
微基准测试必须测试一系列输入。
即便如此,仍存在潜在的陷阱。这段代码只执行一项操作:计算第 50 个斐波那契数。聪明的编译器可以发现这一点,并且仅执行一次循环,或者至少丢弃循环的一些迭代,因为这些操作是多余的。
此外,fibImpl1(1000)的性能很可能与fibImpl1(1)的性能大不相同;如果目标是比较不同实现的性能,则必须考虑一系列输入值。
输入的范围可以是随机的,像这样:
for (int i = 0; i < nLoops; i++) {
l = fibImpl1(random.nextInteger());
}
这可能不是我们想要的。计算随机数的时间包括在执行循环的时间中,所以测试现在测量了计算斐波那契数列nLoops次所需的时间,加上生成nLoops个随机整数的时间。
最好预先计算输入值:
int[] input = new int[nLoops];
for (int i = 0; i < nLoops; i++) {
input[i] = random.nextInt();
}
long then = System.currentTimeMillis();
for (int i = 0; i < nLoops; i++) {
try {
l = fibImpl1(input[i]);
} catch (IllegalArgumentException iae) {
}
}
long now = System.currentTimeMillis();
微基准测试必须测量正确的输入。
你可能注意到现在测试必须检查调用fibImpl1()方法时是否会出现异常:输入范围包括负数(没有斐波那契数)和大于 1,476 的数字(其结果不能表示为double)。
当该代码用于生产时,这些可能是常见的输入值吗?在这个示例中,可能不是;在你自己的基准测试中,结果可能会有所不同。但要考虑这里的影响:假设你正在测试这个操作的两种实现。第一种能够相当快地计算斐波那契数,但不检查其输入参数范围。第二种如果输入参数超出范围就会立即抛出异常,然后执行一个缓慢的递归操作来计算斐波那契数,像这样:
public double fibImplSlow(int n) {
if (n < 0) throw new IllegalArgumentException("Must be > 0");
if (n > 1476) throw new ArithmeticException("Must be < 1476");
return recursiveFib(n);
}
将此实现与原始实现在广泛的输入值范围内进行比较将表明,这种新实现比原始实现快得多——仅仅因为方法开始时的范围检查。
如果在现实世界中,用户总是将小于 100 的值传递给该方法,那么比较将给出错误的答案。通常情况下,fibImpl1() 方法会更快,并且正如第一章所解释的,我们应该为常见情况进行优化。(这显然是一个假设的例子,而原始实现中简单添加边界测试会使其成为更好的实现。在一般情况下,这可能是不可能的。)
微基准测试代码在生产环境中可能会表现不同。
到目前为止,我们看过的问题可以通过仔细编写我们的微基准测试来克服。其他因素将影响代码最终在纳入更大程序后的结果。编译器使用代码的配置反馈来确定在编译方法时使用的最佳优化方法。配置反馈基于哪些方法频繁调用、它们被调用时的堆栈深度、实际类型(包括子类)的参数等等——它依赖于代码实际运行的环境。
因此,在微基准测试中,编译器通常会以不同的方式优化代码,而不是在较大的应用程序中使用相同的代码时优化。
微基准测试也可能在垃圾收集方面表现出非常不同的行为。考虑两种微基准测试的实现:第一个产生快速结果,但也产生许多短寿对象。第二个稍慢一些,但产生的短寿对象较少。
当我们运行一个小程序来测试这些内容时,第一个可能会更快。即使它会触发更多的垃圾收集,它们会迅速丢弃年轻代集合中的短寿对象,总体更快的时间会偏向于这种实现。当我们在具有多个线程同时执行的服务器上运行此代码时,GC(垃圾收集)的配置文件将会有所不同:多个线程将更快地填满年轻代。因此,在微基准测试情况下迅速丢弃的许多短寿对象,在多线程服务器环境中使用时可能会被提升到老年代。这反过来会导致频繁(且昂贵)的全面 GC。在这种情况下,长时间在全面 GC 中花费会使第一个实现比产生较少垃圾的第二个“较慢”实现表现更差。
最后,还有一个问题,那就是微基准实际上意味着什么。在像这里讨论的基准测试中,整体时间差可能以秒为单位测量许多循环,但每次迭代的差异通常以纳秒为单位测量。是的,纳秒是可以累加的,“千刀万剐”的问题经常成为性能问题。但特别是在回归测试中,请考虑跟踪纳秒级别的事务是否有意义。对于那些会被访问数百万次的集合来说,在每次访问时节省几个纳秒可能是重要的(例如,参见第十二章)。对于发生频率较低的操作,比如每个 REST 调用请求可能只会发生一次的操作,通过修复由微基准测试发现的纳秒回归可能会耗费时间,而这些时间本应该更有利地用于优化其他操作。
尽管微基准测试存在许多缺陷,但它们足够受欢迎,以至于 OpenJDK 有一个核心框架用于开发微基准测试:Java 微基准测试工具(jmh)。jmh被 JDK 开发人员用于构建 JDK 本身的回归测试,并为一般基准测试的开发提供框架。我们将在下一节更详细地讨论jmh。
宏基准测试
评估应用程序性能的最佳方法是使用应用程序本身,结合其使用的任何外部资源。这就是宏基准测试。例如,如果应用程序通常通过调用目录服务(例如轻量级目录访问协议,或 LDAP)检查用户的凭据,应该以该模式进行测试。对 LDAP 调用进行存根化可能对模块级测试有意义,但必须以其完整配置测试应用程序。
随着应用程序的增长,这一准则变得更加重要并且更难实现。复杂系统不仅仅是其各个部分的总和;当这些部分组装在一起时,它们的行为将会有所不同。例如,模拟数据库调用可能意味着您不再需要担心数据库的性能问题——嘿,您是 Java 开发人员;为什么还要处理 DBA 的性能问题呢?但数据库连接为其缓冲区消耗大量堆空间;当通过网络发送更多数据时,网络会饱和;与 JDBC 驱动程序中复杂代码相比,调用更简单方法集的代码将被优化得不同;CPU 更有效地在较短的代码路径上进行流水线处理和缓存等等。
另一个测试整个应用程序的原因是资源分配的问题。在理想的世界中,将有足够的时间优化应用程序中的每一行代码。然而在现实世界中,截止日期逼近,仅优化复杂环境中的一部分可能不会立即产生效益。
考虑图 2-1 中显示的数据流。数据由用户输入,进行专有业务计算,根据此计算从数据库加载数据,进行更多专有计算,将更改的数据存储回数据库,并将答案发送回用户。每个框中的数字是模块在隔离测试中可以处理的每秒请求数(RPS)。
从商业角度来看,专有计算是最重要的事情;它们是程序存在的原因,也是我们被付费的原因。然而,在这个例子中,使它们快 100%并没有任何好处。任何应用程序(包括单独的独立 JVM)都可以建模为像这样的一系列步骤,其中数据以由该框(模块、子系统等)的效率决定的速率流出。数据以前一个框的输出率决定的速率流入子系统。
图 2-1. 典型程序流程
假设对业务计算进行算法改进,使其能够处理 200 RPS;相应地增加了注入系统的负载。LDAP 系统可以处理增加的负载:到目前为止,一切顺利,200 RPS 将流入计算模块,该模块将输出 200 RPS。
但是数据加载仍然只能处理 100 RPS。即使 200 RPS 流入数据库,但只有 100 RPS 流出数据库并流入其他模块。系统的总吞吐量仍然只有 100 RPS,即使业务逻辑的效率已经提高了一倍。在花时间改善环境的其他方面之前,进一步改进业务逻辑的尝试将是徒劳的。
在这个例子中花费在优化计算上的时间并非完全浪费:一旦在系统的其他瓶颈上付出努力,性能收益最终将显现出来。而是一个优先事项:如果没有测试整个应用程序,就不可能知道在哪里花时间进行性能工作会产生回报。
中型基准测试
中型基准测试 是介于微基准测试和完整应用程序之间的测试。我与开发人员一起工作,同时关注 Java SE 和大型 Java 应用程序的性能,每个组都有一套他们认为是微基准测试的测试。对于 Java SE 工程师来说,这个术语意味着比第一部分中更小的示例:测量某些非常小的东西。应用程序开发人员倾向于将这个术语应用于其他东西:测量性能的一个方面,但仍然执行大量代码。
应用微基准的一个示例可能是测量从服务器返回简单 REST 调用响应的速度。与传统的微基准相比,这种请求的代码要复杂得多:包括大量的套接字管理代码、读取请求的代码、写入答案的代码等等。从传统的角度来看,这不是微基准。
这种测试也不是宏基准:没有安全性(例如,用户不登录应用程序)、没有会话管理,也没有使用其他应用程序功能。因为它只是实际应用程序的一个子集,它位于中间位置——这是我用来描述做一些实际工作但不是完整应用程序的基准测试的术语,称为 Mesobenchmark。
Mesobenchmark 比微基准有更少的陷阱,比宏基准更容易处理。Mesobenchmark 可能不会包含大量可以由编译器优化去掉的死代码(除非这些死代码存在于应用程序中,在这种情况下,优化掉它们是件好事)。Mesobenchmark 更容易进行线程化:它们仍然更有可能遇到更多同步瓶颈,但这些瓶颈是真实应用程序在更大的硬件系统和更大的负载下最终会遇到的问题。
然而,Mesobenchmark 并不完美。使用这样的基准来比较两个应用服务器性能的开发人员可能很容易误入歧途。考虑 表 2-1 中展示的两个 REST 服务器的假设响应时间。
表 2-1. 两个 REST 服务器的假设响应时间
| 测试 | 服务器 1 | 服务器 2 |
|---|---|---|
| 简单的 REST 调用 | 19 ± 2.1 毫秒 | 50 ± 2.3 毫秒 |
| 带授权的 REST 调用 | 75 ± 3.4 毫秒 | 50 ± 3.1 毫秒 |
只使用简单的 REST 调用来比较两台服务器性能的开发人员可能没有意识到,服务器 2 自动为每个请求执行授权。他们可能会得出服务器 1 提供最快性能的结论。然而,如果他们的应用程序总是需要授权(这是典型的情况),他们就做出了错误的选择,因为服务器 1 执行授权的时间要长得多。
即便如此,中基准测试提供了一种合理的替代方案,而不是测试完整应用程序;它们的性能特征与实际应用程序更加接近,而不是微基准测试的性能特征。当然,这里存在一个连续性。本章后面的一个部分将介绍一个常见应用程序的概要,该应用程序在后续章节的许多示例中使用。该应用程序有服务器模式(适用于 REST 和 Jakarta 企业版服务器),但这些模式不使用像身份验证这样的服务器功能,虽然它可以访问企业资源(即数据库),但在大多数示例中,它只是使用随机数据来替代数据库调用。在批处理模式下,它模拟了一些实际(但快速)的计算:例如,没有 GUI 或用户交互。
中基准测试也非常适合自动化测试,特别是在模块级别。
快速总结
-
要编写好的微基准测试,需要一个适当的框架。
-
测试整个应用程序是了解代码实际运行方式的唯一途径。
-
通过中基准测试(mesobenchmark)在模块化或操作级别上分离性能提供了一个合理的方法,但不能替代对整个应用程序的测试。
理解吞吐量、批处理和响应时间
第二个原则是理解并选择适合应用程序的适当测试指标。性能可以通过吞吐量(RPS)、经过时间(批处理时间)或响应时间来衡量,这三个指标相互关联。了解这些关系可以根据应用程序的目标选择正确的指标。
经过时间(批处理)测量
衡量性能的最简单方法是看完成某项任务需要多长时间。例如,我们可能想要检索过去 25 年间 1 万只股票的历史,并计算这些价格的标准偏差,为某公司 5 万名员工的工资福利制作报告,或执行 100 万次循环。
在静态编译语言中,这种测试非常直接:编写应用程序,然后测量其执行时间。Java 世界为此增加了一些复杂性:即时编译。该过程在第四章中有描述;基本上意味着代码需要几秒到几分钟(或更长时间)才能完全优化并在最高性能下运行。由于这个(以及其他)原因,Java 的性能研究关注热身期:通常在执行了足够长时间的代码后进行性能测量,以确保已编译并优化。
另一方面,在许多情况下,应用程序从开始到结束的性能才是重要的。一个处理一万个数据元素的报告生成器将在一定时间内完成;对于最终用户来说,如果前五千个元素的处理速度比后五千个元素慢 50%,那并不重要。即使在像 REST 服务器这样的场景中——服务器的性能肯定会随着时间的推移而提高——初始性能也很重要。服务器要达到最佳性能需要一些时间;在此期间访问应用程序的用户,确实在乎热身期间的性能。
由于这些原因,本书中的许多示例都是批处理型的(尽管这有点不太常见)。
吞吐量测量
吞吐量测量基于在一定时间内可以完成的工作量。虽然吞吐量测量的最常见例子涉及服务器处理客户端提供的数据,但这并非绝对必要:一个单独的独立应用程序可以像测量经过的时间一样容易地测量吞吐量。
在客户端/服务器测试中,吞吐量测量意味着客户端没有思考时间。如果只有一个客户端,那么该客户端将向服务器发送一个请求。当客户端收到响应后,它立即发送一个新请求。这个过程持续进行;在测试结束时,客户端报告它实现的总操作数。通常,客户端有多个线程执行相同的操作,吞吐量是所有客户端实现的操作数量的综合测量。通常,这个数字报告为每秒操作数,而不是测量期间的总操作数。这种测量通常称为每秒事务数(TPS)、每秒请求数(RPS)或每秒操作数(OPS)。
客户端/服务器测试中客户端的配置非常重要;您需要确保客户端能够快速地向服务器发送数据。这可能不会发生,因为客户端机器上没有足够的 CPU 周期来运行所需数量的客户端线程,或者因为客户端必须花费大量时间处理请求,然后才能发送新请求。在这些情况下,测试实际上是在测量客户端的性能,而不是服务器的性能,这通常不是目标。
此风险取决于每个客户端线程执行的工作量(以及客户端机器的大小和配置)。零思考时间(以吞吐量为导向)的测试更有可能遇到这种情况,因为每个客户端线程正在执行更多的请求。因此,吞吐量测试通常使用较少的客户端线程(较少的负载)执行,而不是测量响应时间的相应测试。
测量吞吐量的测试通常也报告请求的平均响应时间。这是一个有趣的信息,但是该数字的变化并不表明性能问题,除非报告的吞吐量相同。一个服务器如果可以以 0.5 秒的响应时间维持 500 OPS,那么它的性能比报告 0.3 秒响应时间但只有 400 OPS 的服务器要好。
几乎总是在适当的预热期之后进行吞吐量测量,特别是因为被测量的内容不是固定的一组工作。
响应时间测试
最后一个常见的测试是测量响应时间:即客户端发送请求和接收响应之间经过的时间。
响应时间测试和吞吐量测试(假设后者是基于客户端/服务器的)之间的区别在于响应时间测试中的客户端线程在操作之间会睡眠一段时间。这被称为思考时间。响应时间测试旨在更贴近用户的实际操作:用户在浏览器中输入 URL,花时间阅读返回的页面,点击页面中的链接,花时间阅读该页面,依此类推。
当测试引入思考时间后,吞吐量就变成了固定的:给定数量的客户端使用给定的思考时间执行请求,将始终产生相同的 TPS(稍有变化;详见下面的侧边栏)。此时,重要的测量值是请求的响应时间:服务器响应该固定负载的速度决定了其效率。
我们可以用两种方式测量响应时间。响应时间可以报告为平均值:各个时间相加后除以请求总数。响应时间也可以报告为百分位请求;例如,90%的响应时间。如果 90%的响应时间小于 1.5 秒,而 10%的响应时间大于 1.5 秒,则 1.5 秒是 90%的响应时间。
平均响应时间和百分位响应时间之间的一个区别在于异常值对平均值计算的影响方式:由于它们被包括在平均值中,大的异常值会对平均响应时间产生较大的影响。
图 2-2 显示了 20 个请求的响应时间图表,响应时间的范围相对典型,从 1 到 5 秒不等。平均响应时间(由 x 轴上的较低粗线表示)为 2.35 秒,90%的响应在 4 秒或更短的时间内完成(由 x 轴上的较高粗线表示)。
这是一个表现良好的测试的典型场景。异常值可能会扭曲分析结果,正如图 2-3 中的数据所示。
此数据集包含一个巨大的异常值:一个请求耗时 100 秒。因此,第 90%和平均响应时间的位置被颠倒了。平均响应时间高达 5.95 秒,但第 90%的响应时间是 1.0 秒。在这种情况下,应重点关注减少异常值的影响(这将降低平均响应时间)。
图 2-2. 典型响应时间集合
图 2-3. 带有异常值的响应时间集合
类似这样的异常值可能由多种原因引起,在 Java 应用程序中更容易发生,因为 GC 引入的暂停时间。¹ 在性能测试中,通常关注的是第 90%的响应时间(甚至是第 95%或第 99%的响应时间;90%没有什么神奇之处)。如果只能专注于一个数字,基于百分位数的数字是更好的选择,因为在那里实现较小的数字将使大多数用户受益。但更好的做法是同时查看平均响应时间和至少一个基于百分位数的响应时间,这样就不会错过大异常值的情况。
快速总结
-
面向批处理的测试(或任何没有预热期的测试)在 Java 性能测试中很少使用,但可以产生有价值的结果。
-
其他测试可以根据负载是否以固定速率到达(即基于在客户端模拟思考时间)来测量吞吐量或响应时间。
理解变异性
第三个原则是理解测试结果随时间变化的方式。处理完全相同数据集的程序每次运行时会产生不同的答案。机器上的后台进程会影响应用程序,程序运行时网络拥塞程度会有所不同等等。良好的基准测试也不会每次运行时处理完全相同的数据集;测试中将内置随机行为以模拟真实世界。这带来了一个问题:比较一个运行的结果与另一个运行的结果时,差异是由于回归还是由于测试的随机变化?
可以通过多次运行测试并对结果取平均值来解决此问题。因此,当对正在测试的代码进行更改时,可以多次重新运行测试,取平均值,并比较两个平均值。听起来很简单。
不幸的是,事情并非如此简单。理解何时差异是真正的回归,何时是随机变化是困难的。在这一关键领域,科学引领前行,但艺术也会发挥作用。
当比较基准结果中的平均值时,绝对确定平均值的差异是真实存在还是由于随机波动是不可能的。我们能做的最好的事情是假设“平均值相同”,然后确定这种说法成立的概率。如果这种说法以很高的概率是错误的,我们就可以相信平均值的差异(尽管我们永远不能百分之百确定)。
测试像这样的更改被称为回归测试。在回归测试中,原始代码被称为基线,新代码被称为样本。以批处理程序为例,在基线和样本各运行三次后,得到的时间如表 2-2 所示。
表 2-2. 假设执行两个测试的时间
| 基线 | 样本 | |
|---|---|---|
| 第一次迭代 | 1.0 秒 | 0.5 秒 |
| 第二次迭代 | 0.8 秒 | 1.25 秒 |
| 第三次迭代 | 1.2 秒 | 0.5 秒 |
| 平均值 | 1 秒 | 0.75 秒 |
样本的平均值表明代码改进了 25%。我们能有多大信心认为测试确实反映了 25%的改进?看起来不错:三个样本值中有两个低于基线平均值,改进的幅度也很大。然而,当对这些结果进行本节描述的分析时,结果表明样本和基线在性能上相同的概率为 43%。当观察到这类数字时,43%的时间两个测试的基础性能相同,性能仅在 57%的时间不同。顺便说一句,这并不完全等同于说 57%的时间性能提高了 25%,但稍后在本节将会更多了解。
这些概率看起来与预期不同的原因是由于结果的大变异。一般来说,结果集的变异越大,我们就越难猜测平均值差异是真实存在还是由于随机机会。²
这个数字——43%——是基于学生 t 检验的结果,这是一种基于系列及其方差的统计分析。t检验产生一个称为p 值的数字,它指的是测试的零假设成立的概率。³
回归测试中的零假设是两个测试具有相同的性能。这个例子的p值大约为 43%,这意味着我们能够确信系列收敛到相同平均值的概率为 43%。相反,我们确信系列不收敛到相同平均值的概率为 57%。
说 57%的时间系列不会收敛到相同的平均值意味着什么?严格来说,并不意味着我们有 57%的置信度,表明结果有 25%的改善——它只是意味着我们有 57%的置信度,结果是不同的。可能有 25%的改善,可能有 125%的改善;甚至可能样本比基线表现更差。最有可能的情况是测试中的差异与已测量的相似(特别是p-值下降时),但无法确保。
t-检验通常与α值结合使用,α值是一个(有些任意的)点,假设结果具有统计显著性。α-值通常设置为 0.1——这意味着如果在样本和基线相同的情况下只有 10%的时间(或者反过来说,90%的时间样本和基线不同),结果被认为具有统计显著性。其他常用的α-值有 0.05(95%)或 0.01(99%)。如果p-值大于 1 – α-值,则测试被认为具有统计显著性。
因此,在代码中搜索回归的正确方法是确定一个统计显著性水平——比如说,90%——然后使用t-检验来确定在该统计显著性水平内样本和基线是否不同。必须注意理解如果统计显著性检验失败意味着什么。在这个例子中,p-值为 0.43;我们不能说在 90%置信水平下这个结果表明平均值不同具有统计显著性。事实上,测试结果不具有统计显著性并不意味着它是无意义的;它只是表明测试结果不明确。
测试统计不明确的通常原因是样本数据不足。到目前为止,我们的例子看了一系列包含基线和样本的三个结果的情况。如果再增加三个结果,就会得到表 2-3 中的数据?
表 2-3. 假设时间的增加样本量
| 基线 | 样本 | |
|---|---|---|
| 第一次迭代 | 1.0 秒 | 0.5 秒 |
| 第二次迭代 | 0.8 秒 | 1.25 秒 |
| 第三次迭代 | 1.2 秒 | 0.5 秒 |
| 第四次迭代 | 1.1 秒 | 0.8 秒 |
| 第五次迭代 | 0.7 秒 | 0.7 秒 |
| 第六次迭代 | 1.2 秒 | 0.75 秒 |
| 平均值 | 1 秒 | 0.75 秒 |
随着额外的数据,p-值从 0.43 下降到 0.11:结果不同的概率从 57%上升到 89%。平均值没有改变;我们只是更有信心,差异不是由于随机变化造成的。
运行额外的测试直到达到统计显著性水平并非总是切实可行。严格来说,也并非必要。决定统计显著性的α值是任意的,即使通常选择是常见的。在 90%置信水平内,p值为 0.11 不具有统计显著性,但在 89%置信水平内具有统计显著性。
回归测试很重要,但它并不是一门黑白分明的科学。您不能仅仅查看一系列数字(或其平均值)并进行比较,而不进行一些统计分析以理解这些数字的含义。然而,即使进行了分析,也不能得出完全确切的答案,因为概率定律的影响。性能工程师的工作是查看数据,理解概率,并根据所有可用数据决定在哪里花费时间。
快速摘要
-
正确确定两个测试结果是否不同需要进行一定水平的统计分析,以确保所感知的差异不是由于随机机会造成的。
-
实现这一点的严格方法是使用学生的t检验来比较结果。
-
t-检验的数据告诉我们存在回归的概率,但它并没有告诉我们应该忽略哪些回归,哪些必须追究。找到平衡点是性能工程的一部分艺术。
早测,多测
最后,性能极客(包括我在内)建议性能测试成为开发周期的一个组成部分。在理想的情况下,性能测试应作为代码提交到中央仓库的过程的一部分运行;引入性能回归的代码将被阻止提交。
在本章的其他建议以及现实世界之间存在一定的张力。一次良好的性能测试将包含大量代码,至少是一个中等规模的中型基准测试。它需要多次重复以确保在旧代码和新代码之间找到的任何差异是真实的差异,而不仅仅是随机变化。在大型项目中,这可能需要几天甚至一周的时间,因此在将代码提交到代码库之前进行性能测试是不现实的。
典型的开发周期并不会使事情变得更容易。项目进度表通常会设定一个特性冻结日期:所有代码的特性更改必须在发布周期的早期阶段提交到代码库,而剩余的周期则用于解决新发布版本中的任何错误(包括性能问题)。这给早期测试带来了两个问题:
-
开发人员必须在时间限制内完成代码审核以满足计划;当计划中有时间供所有初始代码审核后解决性能问题时,他们会反对花时间解决性能问题。在周期早期提交引起 1%回归的代码的开发人员将面临修复该问题的压力;等到功能冻结的晚上,可以提交引起 20%回归的代码,以后再处理。
-
随着代码的改变,代码的性能特征也会改变。这是与测试完整应用程序的相同原则(除了可能发生的任何模块级测试):堆使用量将改变,代码编译将改变等等。
尽管存在这些挑战,开发过程中频繁进行性能测试是重要的,即使问题无法立即解决。引入导致 5%回归的代码的开发人员可能有计划在开发过程中解决该回归:也许代码依赖于尚未集成的功能,当该功能可用时,稍作调整即可使回归消失。尽管这意味着性能测试将不得不在几周内忍受这个 5%的回归(以及不幸但不可避免的问题,即该回归正在掩盖其他回归),但这是一个合理的立场。
另一方面,如果新代码引起的回归可以仅通过架构更改来修复,最好是在其他代码开始依赖新实现之前尽早捕捉回归并加以解决。这是一种权衡,需要分析和通常还需要政治技巧。
如果遵循以下准则,早期频繁测试是最有用的:
一切都要自动化
所有性能测试都应该是脚本化的(或者编程化,尽管脚本通常更容易)。脚本必须能够安装新代码,将其配置到完整环境中(创建数据库连接,设置用户帐户等),并运行一组测试。但事情并不止于此:脚本必须能够多次运行测试,对结果进行t-test 分析,并生成报告,显示结果相同的置信水平,以及如果结果不同则测得的差异。
自动化必须确保在运行测试之前机器处于已知状态:必须检查是否运行了意外进程,操作系统配置是否正确等等。只有在每次运行时环境相同,性能测试才是可重复的;自动化必须处理这一点。
测量一切
自动化必须收集每一个可能对后续分析有用的数据片段。这包括在整个运行过程中抽样的系统信息:CPU 使用率、磁盘使用率、网络使用率、内存使用率等等。它包括应用程序生成的日志以及垃圾收集器的日志。理想情况下,它可以包括 Java Flight Recorder(JFR)记录(参见第三章)或其他低影响的分析信息,定期的线程堆栈以及像直方图或完整堆转储这样的堆分析数据(尽管特别是完整堆转储占用大量空间,不能长期保留)。
监控信息还必须包括系统其他部分(如果适用)的数据:例如,如果程序使用数据库,则包括数据库机器的系统统计信息以及数据库的任何诊断输出(包括 Oracle 的自动工作负载存储库,或 AWR 报告等性能报告)。
这些数据将指导对发现的任何回归的分析。如果 CPU 使用率增加,那么是时候查看概要信息,看看是什么花费了更多时间。如果 GC 时间增加,那么是时候查看堆概要信息,看看是什么消耗了更多内存。如果 CPU 时间和 GC 时间减少,那么某处的争用可能已经减慢了性能:堆栈数据可以指向特定的同步瓶颈(参见第九章),JFR 记录可用于查找应用程序延迟,或者数据库日志可以指出增加的数据库争用情况。
当解决回归问题的源头时,就是扮演侦探的时候了,而有更多可用数据时,就有更多线索可以追踪。正如在第一章中讨论的那样,并非总是 JVM 导致回归。要全面测量,以确保能进行正确的分析。
在目标系统上运行
在单核笔记本上运行的测试与在具有 72 个核心的机器上运行的测试会表现出不同的行为。这在线程效果方面应该是显而易见的:更大的机器将同时运行更多线程,减少应用线程之间竞争 CPU 访问的情况。与此同时,大系统将显示同步瓶颈,而这在小型笔记本上可能会被忽视。
其他性能差异同样重要,即使它们并不像立即显而易见。许多重要的调整标志根据 JVM 运行的底层硬件计算其默认值。代码在不同平台上编译不同。缓存(软件和更重要的硬件)在不同系统和不同负载下的行为也会不同。等等...
因此,除非在预期的硬件上测试预期负载,否则无法完全了解特定生产环境的性能。可以从较小的硬件上运行较小的测试中进行近似和推断,但在现实世界中,为测试复制生产环境可能非常困难或昂贵。但推断只是预测,即使在最佳情况下,预测也可能是错误的。大规模系统不仅仅是其各部分的总和,对目标平台进行充分的负载测试是无法替代的。
快速总结
-
频繁的性能测试很重要,但这并不是孤立进行的;在正常的开发周期中,需要考虑一些权衡。
-
一个自动化测试系统,从所有机器和程序中收集所有可能的统计信息,将为任何性能回归提供必要的线索。
基准测试示例
本书中的一些示例使用jmh提供微基准测试。在本节中,我们将深入研究如何开发这样一个微基准测试示例,作为编写自己jmh基准测试的示例。但本书中的许多示例都是基于 mesobenchmark 的变体——这是一个复杂到可以测试各种 JVM 特性但比实际应用程序复杂度低的测试。因此,在我们探讨完jmh之后,我们将查看后续章节中使用的 mesobenchmark 的一些常见代码示例,以便这些示例有一些背景知识。
Java 微基准测试工具
jmh是一组供编写基准测试的类。jmh中的m曾代表microbenchmark,尽管现在jmh宣称适用于 nano/micro/milli/macro 基准测试。尽管jmh是与 Java 9 一同宣布的,但它实际上并未与任何特定的 Java 版本绑定,JDK 中也没有支持jmh的工具。构成jmh的类库与 JDK 8 及更高版本兼容。
jmh消除了编写良好基准测试的一些不确定性,但它并非解决所有问题的灵丹妙药;您仍然必须理解您正在进行基准测试的内容以及如何编写良好的基准测试代码。但jmh的特性旨在使这一过程更加简单。
本书中使用jmh的几个示例,包括测试影响字符串国际化的 JVM 参数的测试,该测试在第十二章中进行了介绍。我们将在此使用该示例来理解如何使用jmh编写基准测试。
从头开始编写jmh基准测试是可能的,但更容易的是从jmh提供的主类开始,并仅编写特定于基准测试的代码。虽然可以使用各种工具(甚至某些集成开发环境)获取必要的jmh类,但基本方法是使用 Maven。下面的命令将创建一个 Maven 项目,我们可以向其添加我们的基准测试代码:
$ mvn archetype:generate \
-DinteractiveMode=false \
-DarchetypeGroupId=org.openjdk.jmh \
-DarchetypeArtifactId=jmh-java-benchmark-archetype \
-DgroupId=net.sdo \
-DartifactId=string-intern-benchmark \
-Dversion=1.0
这将在 string-intern-benchmark 目录中创建 Maven 项目;在那里,它创建了一个以给定 groupId 名称命名的目录,并且一个名为 MyBenchmark 的骨架基准类。该名称并不特殊;您可以创建一个不同的(或多个不同的)类,因为 jmh 将通过查找称为 Benchmark 的注解来确定要测试的类。
我们有兴趣测试 String.intern() 方法的性能,因此我们将编写的第一个基准方法如下所示:
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.infra.Blackhole;
public class MyBenchmark {
@Benchmark
public void testIntern(Blackhole bh) {
for (int i = 0; i < 10000; i++) {
String s = new String("String to intern " + i);
String t = s.intern();
bh.consume(t);
}
}
}
testIntern() 方法的基本概述应该是有意义的:我们正在测试创建 10,000 个 interned 字符串的时间。这里使用的 Blackhole 类是 jmh 的一个特性,解决了微基准测试中的一个问题:如果不使用操作的值,编译器可以自由地优化掉该操作。因此,我们通过将它们传递给 Blackhole 的 consume() 方法来确保值被使用。
在这个例子中,Blackhole 并不是严格必需的:我们真正感兴趣的只是调用 intern() 方法的副作用,它将字符串插入全局哈希表中。即使我们不使用 intern() 方法本身的返回值,这种状态变化也无法被编译器优化掉。但是,与其费力去研究是否有必要消耗特定值,还不如养成确保操作按预期执行并消耗计算值的习惯。
编译并运行基准测试:
$ mvn package
... output from mvn...
$ java -jar target/benchmarks.jar
# Warmup: 5 iterations, 10 s each
# Measurement: 5 iterations, 10 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Throughput, ops/time
# Benchmark: net.sdo.MyBenchmark.testIntern
# Run progress: 0.00% complete, ETA 00:08:20
# Fork: 1 of 5
# Warmup Iteration 1: 189.999 ops/s
# Warmup Iteration 2: 170.331 ops/s
# Warmup Iteration 3: 195.393 ops/s
# Warmup Iteration 4: 184.782 ops/s
# Warmup Iteration 5: 158.762 ops/s
Iteration 1: 182.426 ops/s
Iteration 2: 172.446 ops/s
Iteration 3: 166.488 ops/s
Iteration 4: 182.737 ops/s
Iteration 5: 168.755 ops/s
# Run progress: 20.00% complete, ETA 00:06:44
# Fork: 2 of 5
.... similar output until ...
Result "net.sdo.MyBenchmark.testIntern":
177.219 ±(99.9%) 10.140 ops/s [Average]
(min, avg, max) = (155.286, 177.219, 207.970), stdev = 13.537
CI (99.9%): [167.078, 187.359] (assumes normal distribution)
Benchmark Mode Cnt Score Error Units
MyBenchmark.testIntern thrpt 25 177.219 ± 10.140 ops/s
从输出中可以看出,jmh 帮助我们避免了本章前面讨论过的陷阱。首先,我们执行了五次每次 10 秒的预热迭代,然后是五次测量迭代(同样每次 10 秒)。预热迭代允许编译器充分优化代码,然后测试框架将仅报告编译后代码的迭代信息。
然后看到有不同的 fork(共五个)。测试框架重复测试五次,每次在一个单独的(新 fork 的)JVM 中,以确定结果的可重复性。每个 JVM 需要预热,然后测量代码。像这样的 forked 测试(带有预热和测量间隔)称为 trial。总体来说,每个测试需要 5 次预热和 5 次测量循环,总执行时间为 8 分 20 秒。
最后,我们得到了汇总输出:平均而言,testIntern() 方法每秒执行 177 次。在 99.9% 的置信区间下,我们可以说统计平均值在每秒 167 到 187 次操作之间波动。因此,jmh 还帮助我们进行必要的统计分析,以了解特定结果是否具有可接受的变化范围。
JMH 和参数
通常,您希望测试的输入范围;在这个例子中,我们想看看内部化 1 或 10,000 个(甚至 1 百万)字符串的效果。与在testIntern()方法中硬编码该值不同,我们可以引入一个参数:
@Param({"1","10000"})
private int nStrings;
@Benchmark
public void testIntern(Blackhole bh) {
for (int i = 0; i < nStrings; i++) {
String s = new String("String to intern " + i);
String t = s.intern();
bh.consume(t);
}
}
现在,jmh将报告两个参数值的结果:
$ java -jar target/benchmarks.jar
...lots of output...
Benchmark (nStrings) Mode Cnt Score Error Units
MyBenchmark.testMethod 1 thrpt 25 2838957.158 ± 284042.905 ops/s
MyBenchmark.testMethod 10000 thrpt 25 202.829 ± 15.396 ops/s
可预见地,循环大小为 10,000 时,每秒运行的循环次数将减少 10,000 倍。实际上,10,000 个字符串的结果少于我们可能希望的约 283,这是由字符串内部化表的缩放方式引起的(这在我们在第十二章中使用这个基准时有解释)。
通常,将源代码中的参数设为单一简单值并用于测试会更容易。当您运行基准测试时,可以为每个参数提供一个值列表,覆盖 Java 代码中硬编码的值:
$ java -jar target/benchmarks.jar -p nStrings=1,1000000
比较测试
这个基准的起源在于,我们想弄清楚是否可以通过使用不同的 JVM 调优使字符串内部化速度更快。为了达到这个目的,我们将通过在命令行上指定这些参数来使用不同的 JVM 参数运行基准测试:
$ java -jar target/benchmarks.jar
... output from test 1 ...
$ java -jar target/benchmarks.jar -jvmArg -XX:StringTableSize=10000000
... output from test 2 ...
然后,我们可以手动检查和比较调优对结果产生的影响。
更常见的情况是,您希望比较两种代码实现方式。字符串内部化是不错的选择,但是如果我们使用一个简单的哈希映射并进行管理,能否更好呢?为了测试这一点,我们将在类中定义另一个方法,并使用Benchmark注解进行标注。我们第一次(也是次优的)尝试看起来像这样:
private static ConcurrentHashMap<String,String> map = new ConcurrentHashMap<>();
@Benchmark
public void testMap(Blackhole bh) {
for (int i = 0; i < nStrings; i++) {
String s = new String("String to intern " + i);
String t = map.putIfAbsent(s, s);
bh.consume(t);
}
}
jmh将通过同一系列的预热和测量迭代(始终在新分叉的 JVM 中)运行所有带注解的方法,并生成一个很好的比较:
Benchmark (nStrings) Mode Cnt Score Error Units
MyBenchmark.testIntern 10000 thrpt 25 212.301 ± 207.550 ops/s
MyBenchmark.testMap 10000 thrpt 25 352.140 ± 84.397 ops/s
在这里手动管理内部化的对象确实有了很好的改进(尽管请注意:写法上可能存在问题;这并非最终结论)。
设置代码
由于格式限制,上述输出已经被省略,但当您运行jmh测试时,您将在结果打印之前看到一个长长的警告。这个警告的要点是:“只是因为你把一些代码放进了jmh,不要假设你写了一个好的基准测试:测试你的代码,确保它测试的是你期望的内容。”
让我们再次看看基准定义。我们想测试将 10,000 个字符串内部化所需的时间,但我们正在测试的是创建(通过连接)10,000 个字符串的时间加上内部化所需的时间。这些字符串的范围也相当有限:它们是相同的初始 17 个字符,后跟一个整数。与我们为手写的斐波那契微基准测试预先创建输入的方式相同,我们也应该在这种情况下预先创建输入。
可以说,字符串范围对于这个基准测试并不重要,并且连接操作很小,因此原始测试完全准确。这可能是真的,但要证明这一点需要一些工作。最好的方法是编写一个基准测试,其中这些问题不是问题,而不是对正在发生的事情做出假设。
我们还必须深入思考测试中正在发生的事情。本质上,保存国际化字符串的表是一个缓存:国际化字符串可能在那里(在这种情况下返回),也可能不在(在这种情况下插入)。现在,当我们比较这些实现时,出现了问题:手动管理的并发哈希映射在测试期间从不清除。这意味着在第一个热身周期期间,字符串被插入到映射中,而在后续的测量周期中,字符串已经存在:测试在缓存上有 100%的命中率。
字符串国际化表不是这样工作的:字符串国际化表中的键实质上是弱引用。因此,JVM 可以在任何时间清除一些或所有条目(因为国际化字符串在插入到表中后立即超出作用域)。在这种情况下,缓存命中率是不确定的,但很可能不接近 100%。因此,按照现状,国际化测试将做更多的工作,因为它必须更频繁地更新内部字符串表(既要删除条目,又要重新添加条目)。
如果我们预先将字符串创建为静态数组,然后将它们合并(或插入到哈希映射中),那么这两个问题都将被避免。因为静态数组保持对字符串的引用,所以在测量周期内字符串表中的引用不会被清除。因此,这两个测试在测量周期内将具有 100%的命中率,并且字符串范围将更加全面。
我们需要在测量期之外进行此初始化,这可以通过Setup注解来完成:
private static ConcurrentHashMap<String,String> map;
private static String[] strings;
@Setup(Level.Iteration)
public void setup() {
strings = new String[nStrings];
for (int i = 0; i < nStrings; i++) {
strings[i] = makeRandomString();
}
map = new ConcurrentHashMap<>();
}
@Benchmark
public void testIntern(Blackhole bh) {
for (int i = 0; i < nStrings; i++) {
String t = strings[i].intern();
bh.consume(t);
}
}
Setup注解中给定的Level值控制何时执行给定方法。Level可以取三个值之一:
Level.Trial
设置是在基准代码初始化时完成的一次性操作。
Level.Iteration
在每次基准测试的迭代(每个测量周期)之前完成设置。
Level.Invocation
在每次执行测试方法之前完成设置。
在其他情况下,可以使用类似的Teardown注解来清除状态,如果需要的话。
jmh有许多额外的选项,包括测量方法的单次调用或测量平均时间而不是吞吐量,传递额外的 JVM 参数给分叉的 JVM,控制线程同步等等。我的目标不是为jmh提供完整的参考,而是这个例子理想地展示了即使编写一个简单的微基准也涉及到的复杂性。
控制执行和可重复性
一旦你有了正确的微基准,你需要以一种使结果在你所测量的方面上具有统计意义的方式来运行它。
正如您刚刚看到的,默认情况下,jmh将在 10 秒的时间内运行目标方法,根据需要执行尽可能多的次数(因此在先前的示例中,它在 10 秒内平均执行了 1,772 次)。每个 10 秒的测试是一个迭代,而默认情况下,每次 fork 新的 JVM 时都会有五次热身迭代(结果被丢弃),以及五次测量迭代。而这一切都会重复进行五次试验。
所有这些都是为了让jmh能够执行统计分析,以计算结果的置信区间。在前面提到的情况下,99.9%的置信区间约为 10%,这可能足够或不足以与其他基准进行比较。
通过变化这些参数,我们可以获得更小或更大的置信区间。例如,以下是使用较少测量迭代和试验的两个基准测试的结果:
Benchmark (nStrings) Mode Cnt Score Error Units
MyBenchmark.testIntern 10000 thrpt 4 233.359 ± 95.304 ops/s
MyBenchmark.testMap 10000 thrpt 4 354.341 ± 166.491 ops/s
那个结果使得使用intern()方法看起来比使用地图要糟糕得多,但看看范围:第一个案例的实际结果可能接近 330 ops/s,而第二个案例的实际结果可能接近 200 ops/s。即使这不太可能,这里的范围也太广泛,无法确定哪个更好。
那个结果是只有两个分叉试验,每个试验两次迭代的结果。如果我们将其增加到每次 10 次迭代,我们会得到更好的结果:
MyBenchmark.testIntern 10000 thrpt 20 172.738 ± 29.058 ops/s
MyBenchmark.testMap 10000 thrpt 20 305.618 ± 22.754 ops/s
现在范围是离散的,我们可以有信心地得出地图技术优于(至少在 100%缓存命中率和 10,000 个不变字符串的测试中)的结论。
没有硬性规定要运行多少次迭代,分叉试验的次数,或执行的长度将足以获得足够的数据以便结果像这样清晰。如果您要比较两种技术之间的差异很小,则需要更多的迭代和试验。另一方面,如果它们非常接近,也许您最好看看对性能影响更大的东西。这再次是艺术影响科学的地方;在某些时候,您必须自己决定界限在哪里。
所有这些变量——迭代次数、每个间隔的长度等——都通过标准的jmh基准的命令行参数来控制。以下是最相关的几个:
-f 5
要运行的分叉试验次数(默认值:5)。
-wi 5
每个试验的预热迭代次数(默认值:5)。
-i 5
每个试验的测量迭代次数(默认值:5)。
-r 10
每次迭代的最小执行时间(以秒为单位);迭代可能比此时间长,具体取决于目标方法的实际长度。
增加这些参数通常会降低结果的变化性,直到您获得所需的置信区间。相反,为了更稳定的测试,降低这些参数通常会减少运行测试所需的时间。
快速总结
-
jmh是一个编写微基准测试的框架和工具,它帮助正确解决此类基准测试的要求。 -
jmh并不是取代深思熟虑编写您要测量的代码的工具;它只是在其开发过程中的一个有用工具。
常见代码示例
本书中的许多示例都基于一个样本应用程序,该应用程序计算股票在一系列日期内的“历史”最高和最低价格,以及该期间的标准偏差。在这里,“历史”加了引号,因为在应用程序中,所有数据都是虚构的;价格和股票符号是随机生成的。
应用程序内的基本对象是一个StockPrice对象,该对象代表给定日期股票价格范围,以及该股票的期权价格集合:
public interface StockPrice {
String getSymbol();
Date getDate();
BigDecimal getClosingPrice();
BigDecimal getHigh();
BigDecimal getLow();
BigDecimal getOpeningPrice();
boolean isYearHigh();
boolean isYearLow();
Collection<? extends StockOptionPrice> getOptions();
}
样本应用程序通常处理这些价格的集合,代表了股票在一段时间内的历史(例如,1 年或 25 年,具体取决于示例):
public interface StockPriceHistory {
StockPrice getPrice(Date d);
Collection<StockPrice> getPrices(Date startDate, Date endDate);
Map<Date, StockPrice> getAllEntries();
Map<BigDecimal,ArrayList<Date>> getHistogram();
BigDecimal getAveragePrice();
Date getFirstDate();
BigDecimal getHighPrice();
Date getLastDate();
BigDecimal getLowPrice();
BigDecimal getStdDev();
String getSymbol();
}
这个类的基本实现从数据库加载一组价格:
public class StockPriceHistoryImpl implements StockPriceHistory {
...
public StockPriceHistoryImpl(String s, Date startDate,
Date endDate, EntityManager em) {
Date curDate = new Date(startDate.getTime());
symbol = s;
while (!curDate.after(endDate)) {
StockPriceImpl sp = em.find(StockPriceImpl.class,
new StockPricePK(s, (Date) curDate.clone()));
if (sp != null) {
Date d = (Date) curDate.clone();
if (firstDate == null) {
firstDate = d;
}
prices.put(d, sp);
lastDate = d;
}
curDate.setTime(curDate.getTime() + msPerDay);
}
}
...
}
示例的架构设计为从数据库加载,并且这种功能将在第十一章的示例中使用。然而,为了方便运行示例,大多数时候它们将使用一个生成系列随机数据的模拟实体管理器。实质上,大多数示例都是适用于展示手头性能问题的模块级中型基准测试,但只有当运行完整应用程序时(如第十一章),我们才能对应用程序的实际性能有所了解。
一个注意事项是,因此许多示例都依赖于正在使用的随机数生成器的性能。与微基准测试示例不同,这是有意设计的,因为它允许在 Java 中展示多个性能问题。 (就这一点而言,示例的目标是测量任意事物的性能,而随机数生成器的性能则符合该目标。这与微基准测试有很大不同,后者包括生成随机数的时间会影响整体计算。)
这些示例还严重依赖于BigDecimal类的性能,该类用于存储所有数据点。这是存储货币数据的标准选择;如果货币数据存储为原始的double对象,则半便士和较小金额的舍入将变得相当棘手。从编写示例的角度来看,这种选择也是有用的,因为它允许一些“业务逻辑”或长度计算的发生,特别是在计算一系列价格的标准偏差时。标准偏差依赖于知道BigDecimal数的平方根。标准 Java API 不提供这样的例程,但示例使用了这种方法:
public static BigDecimal sqrtB(BigDecimal bd) {
BigDecimal initial = bd;
BigDecimal diff;
do {
BigDecimal sDivX = bd.divide(initial, 8, RoundingMode.FLOOR);
BigDecimal sum = sDivX.add(initial);
BigDecimal div = sum.divide(TWO, 8, RoundingMode.FLOOR);
diff = div.subtract(initial).abs();
diff.setScale(8, RoundingMode.FLOOR);
initial = div;
} while (diff.compareTo(error) > 0);
return initial;
}
这是用于估算一个数的平方根的巴比伦方法的实现。这并不是最有效的实现;特别是,初始猜测可以更好,这将节省一些迭代。这是有意为之的,因为它允许计算花费一些时间(模拟业务逻辑),尽管它确实说明了第一章中提到的基本观点:通常使 Java 代码更快的方法是编写更好的算法,而不依赖于所采用的任何 Java 调整或 Java 编码实践。
标准偏差、平均价格和StockPriceHistory接口的直方图都是派生值。在不同的示例中,这些值将会被急切地计算(当数据从实体管理器加载时)或者是惰性地计算(当检索数据的方法被调用时)。类似地,StockPrice接口引用了StockOptionPrice接口,这是给定股票在特定日期的某些期权的价格。这些期权的值可以从实体管理器中急切地或惰性地检索。在两种情况下,这些接口的定义允许在不同情况下比较这些方法。
这些接口也很自然地适合于 Java REST 应用程序:用户可以使用带有参数的调用来指示他们感兴趣的股票的符号和日期范围。在标准示例中,请求将通过使用 Java RESTful Web Services(JAX-RS)的标准调用进行,该调用解析输入参数,调用嵌入的 JPA bean 以获取底层数据,并转发响应:
@GET
@Produces(MediaType.APPLICATION_JSON)
public JsonObject getStockInfo(
@DefaultValue("" + StockPriceHistory.STANDARD)
@QueryParam("impl") int impl,
@DefaultValue("true") @QueryParam("doMock") boolean doMock,
@DefaultValue("") @QueryParam("symbol") String symbol,
@DefaultValue("01/01/2019") @QueryParam("start") String start,
@DefaultValue("01/01/2034") @QueryParam("end") String end,
@DefaultValue("0") @QueryParam("save") int saveCount
) throws ParseException {
StockPriceHistory sph;
EntityManager em;
DateFormat df = localDateFormatter.get(); // Thread-local
Date startDate = df.parse(start);
Date endDate = df.parse(end);
em = // ... get the entity manager based on the test permutation
sph = em.find(...based on arguments...);
return JSON.createObjectBuilder()
.add("symbol", sph.getSymbol())
.add("high", sph.getHighPrice())
.add("low", sph.getLowPrice())
.add("average", sph.getAveragePrice())
.add("stddev", sph.getStdDev())
.build();
}
此类可以注入不同实现的历史 bean(例如急切或延迟初始化);它还可以选择性地缓存从后端数据库检索的数据(或模拟实体管理器)。这些是处理企业应用性能时的常见选项(特别是在应用服务器中缓存中间层数据有时被认为是大的性能优势)。本书中的示例也讨论了这些权衡。
概要
性能测试涉及权衡。在竞争激烈的选项中做出良好选择对成功跟踪系统性能特性至关重要。
在设置性能测试时,首先要选择测试的内容。在这方面,应用程序的经验和直觉将极大地帮助。微基准测试有助于为某些操作设定指导方针。这留下了广泛的其他测试领域,从小模块级测试到大型、多层次环境。沿着这个连续的测试范围,每个测试都有其价值,选择适当的测试是经验和直觉将发挥作用的地方之一。然而,最终,没有什么能替代在生产中部署的完整应用程序的测试;只有这样才能全面理解所有与性能相关的问题的影响。
同样地,理解代码中实际是否存在回归问题并不总是非黑即白的。程序总是表现出随机行为,一旦随机性注入其中,我们就无法百分之百确定数据的含义。对结果应用统计分析可以帮助转向更客观的路径,但即便如此,某些主观性仍然存在。理解概率背后的含义有助于减少主观性的影响。
最后,有了这些基础,可以建立一个自动化测试系统来收集测试期间发生的所有事情的完整信息。了解正在发生的事情和底层测试的含义后,性能分析师可以同时运用科学和艺术,以展示程序可能达到的最佳性能。
¹ 并不是说垃圾回收会导致百秒延迟,但特别是对于平均响应时间较短的测试,GC 暂停可能会引入显著的异常值。
² 虽然三个数据点使得理解一个例子更容易,但对于任何真实系统来说,这数量太小而不够准确。
³ 学生,顺便说一句,是首次发布该测试的科学家的笔名;它并非以此来提醒你研究生院,那里你(至少是我)曾在统计课上打瞌睡。
第三章:Java 性能工具箱
性能分析关乎于可见性 —— 知道应用程序及其环境内部发生了什么。可见性关乎于工具。因此,性能调优关乎于工具。
在 第二章 中,我们探讨了采用数据驱动方法对性能进行分析的重要性:必须测量应用程序的性能,并理解这些测量数据的含义。性能分析也必须是数据驱动的:必须有关于程序实际运行情况的数据,以便优化其性能。如何获取和理解这些数据是本章的主题。
数百种工具可以提供有关 Java 应用程序正在执行的操作的信息,查看所有这些工具将是不切实际的。许多最重要的工具都随 Java 开发工具包(JDK)提供,尽管这些工具还有其他开源和商业竞争对手,本章主要出于便利性考虑,主要关注 JDK 工具。
操作系统工具和分析
程序分析的起点与 Java 无关:它是操作系统自带的一套基本监控工具。在基于 Unix 的系统上,这些工具包括 sar(系统账户报告)及其组成部分,如 vmstat、iostat、prstat 等等。Windows 也有图形资源监视器以及像 typeperf 这样的命令行实用程序。
每次运行性能测试时,都应从操作系统收集数据。至少应收集关于 CPU、内存和磁盘使用情况的信息;如果程序使用网络,则还应收集关于网络使用情况的信息。如果性能测试是自动化的,这意味着依赖命令行工具(即使在 Windows 上也是如此)。但即使测试是交互式运行的,最好也有一个捕捉输出的命令行工具,而不是仅仅依靠 GUI 图表猜测其含义。在进行分析时,输出始终可以稍后绘制成图表。
CPU 使用率
首先让我们来监控 CPU 并了解它对 Java 程序的影响。CPU 使用率通常分为两类:用户时间和系统时间(Windows 称之为 特权时间)。用户时间 是 CPU 执行应用程序代码的时间百分比,而 系统时间 是 CPU 执行内核代码的时间百分比。系统时间与应用程序相关;例如,如果应用程序执行 I/O 操作,则内核将执行读取磁盘文件或将缓冲数据写入网络等代码。任何使用底层系统资源的操作都会导致应用程序使用更多的系统时间。
在性能方面的目标是尽可能地提高 CPU 使用率,并尽量缩短时间。这听起来可能有些反直觉;你无疑曾坐在桌面前,看着它因 CPU 使用率达到 100%而奋力运行。因此,让我们考虑一下 CPU 使用率实际上告诉我们什么。
首先要记住的是,CPU 使用率数字是一个时间间隔的平均值 —— 5 秒、30 秒,甚至可能只有 1 秒(虽然实际上不会少于这个)。假设一个程序在执行时的平均 CPU 使用率为 50%,需要 10 分钟才能完成。这意味着 CPU 有一半的时间是空闲的;如果我们重新设计程序,避免空闲段(以及其他瓶颈),我们可以将性能提升一倍,在 5 分钟内运行(CPU 百分之百忙碌)。
如果然后我们改进程序使用的算法,再次提高性能,CPU 仍然在程序完成所需的 2.5 分钟内保持 100%。CPU 使用率数字表明程序有效利用 CPU 的程度,因此数字越高,表明程序利用 CPU 的效率越高。
如果我在我的 Linux 桌面上运行 vmstat 1,我会得到一系列的行(每秒钟一行),看起来像这样:
% vmstat 1
procs -----------memory---------- ---swap-- -----io---- -system-- ----cpu----
r b swpd free buff cache si so bi bo in cs us sy id wa
2 0 0 1797836 1229068 1508276 0 0 0 9 2250 3634 41 3 55 0
2 0 0 1801772 1229076 1508284 0 0 0 8 2304 3683 43 3 54 0
1 0 0 1813552 1229084 1508284 0 0 3 22 2354 3896 42 3 55 0
1 0 0 1819628 1229092 1508292 0 0 0 84 2418 3998 43 2 55 0
这个例子来自于运行一个只有一个活动线程的应用程序,这使得示例更容易理解,但即使有多个线程,这些概念也适用。
每秒钟,CPU 忙碌 450 毫秒(42% 的时间执行用户代码,3% 的时间执行系统代码)。同样,CPU 空闲 550 毫秒。CPU 可以因为多种原因而空闲:
-
应用程序可能因为同步原语上的阻塞而无法执行,直到释放该锁。
-
应用程序可能正在等待某些事情,比如等待从数据库调用返回的响应。
-
应用程序可能什么都不需要做。
这两种情况总是表明可以解决的问题。如果可以减少对锁的争用或调整数据库以更快地发送答案回来,那么程序将运行得更快,应用程序的平均 CPU 使用率将上升(当然,前提是没有其他类似问题会继续阻塞应用程序)。
那第三点通常是混淆的根源。如果应用程序有事情要做(并且不因为等待锁或其他资源而被阻止),那么 CPU 将花费周期执行应用程序代码。这是一个通用原则,不特定于 Java。假设你编写一个包含无限循环的简单脚本。当执行该脚本时,它将消耗 CPU 的 100%。以下是在 Windows 中执行的批处理作业:
ECHO OFF
:BEGIN
ECHO LOOPING
GOTO BEGIN
REM We never get here...
ECHO DONE
考虑一下,如果此脚本不会消耗 CPU 的 100%,会意味着什么。这将意味着操作系统有其他事情可以做 —— 它可以打印另一行 LOOPING —— 但它选择保持空闲。在这种情况下,保持空闲对任何人都没有帮助,如果我们正在进行有用的(耗时的)计算,强制 CPU 定期空闲将意味着需要更长时间才能得到我们想要的答案。
如果在单 CPU 的机器或容器上运行此命令,你很少会注意到它在运行。但是如果尝试启动新程序或计时另一个应用程序的性能,那么你肯定会看到影响。操作系统擅长对竞争 CPU 周期的程序进行时间切片,但新程序将有较少的 CPU 可用,因此运行速度会变慢。有时这种经验会导致人们认为留下一些空闲的 CPU 周期以备其他程序需要时会是个好主意。
但操作系统不能猜测你接下来想要做什么;它(默认情况下)会尽可能执行所有任务,而不是让 CPU 空闲。
Java 和单 CPU 使用
让我们重新讨论 Java 应用程序的话题——在这种情况下,“周期性,空闲 CPU”是什么意思?这取决于应用程序的类型。如果所讨论的代码是批处理样式应用程序,其工作量是固定的,你不应该看到空闲的 CPU,因为那意味着没有工作要做。提高 CPU 使用率总是批处理作业的目标,因为这样可以更快地完成作业。如果 CPU 已经达到 100%,你仍然可以寻找优化方案,使工作能够更快地完成(同时也尽量保持 CPU 在 100%)。
如果测量涉及接受来自源的服务器样式应用程序的情况,空闲时间可能会发生,因为没有可用的工作:例如,当 Web 服务器处理完所有未完成的 HTTP 请求并等待下一个请求时。这就是平均时间的地方。在执行接收每秒一个请求的服务器时,取样的vmstat输出显示应用服务器处理该请求需要 450 毫秒——这意味着 CPU 在 450 毫秒内 100%繁忙,并在 550 毫秒内 0%繁忙。这被报告为 CPU 繁忙 45%。
虽然通常发生在视觉化粒度太小的情况下,但在运行基于负载的应用程序时,CPU 的预期行为是像这样短暂地进行。如果 CPU 每半秒接收一个请求,并且处理请求的平均时间为 225 毫秒,那么从报告中可以看到同样的宏观级别模式:CPU 繁忙 225 毫秒,空闲 275 毫秒,再次繁忙 225 毫秒,空闲 275 毫秒;平均而言,CPU 繁忙 45%,空闲 55%。
如果应用程序经过优化,以便每个请求仅需要 400 毫秒,那么总体 CPU 使用率也会降低(至 40%)。这是唯一的情况,其中降低 CPU 使用率是有意义的 —— 当固定数量的负载进入系统,并且应用程序没有受外部资源限制时。另一方面,该优化还为您提供了向系统添加更多负载的机会,最终增加 CPU 利用率。在微观层面上,在这种情况下进行优化仍然是使 CPU 使用率在短时间内达到 100% 的问题(即执行请求所需的 400 毫秒)——只是 CPU 尖峰的持续时间太短,以至于大多数工具无法有效地注册为 100%。
Java 和多 CPU 使用
此示例假定一个单线程在单个 CPU 上运行,但是在多个线程在多个 CPU 上运行的一般情况下,概念是相同的。多个线程可以以有趣的方式扭曲 CPU 的平均值 —— 第五章 中展示了多个 GC 线程对 CPU 使用率的影响。但总体上,在多 CPU 机器上的多线程的目标仍然是通过确保单个线程不被阻塞来提高 CPU 使用率,或者在较长时间内降低 CPU 使用率(因为线程已完成其工作并等待更多工作)。
在多线程、多 CPU 的情况下,关于 CPU 可能空闲的一个重要补充是:即使有工作要做,CPU 也可能空闲。这种情况发生在程序中没有可用的线程来处理该工作时。典型情况是一个具有固定大小线程池的应用程序运行各种任务。线程为任务放置到队列中;当线程空闲并且队列中有任务时,线程会接收并执行该任务。然而,每个线程一次只能执行一个任务,如果特定任务阻塞(例如等待来自数据库的响应),则线程在此期间无法执行新任务。因此,有时我们可能会出现有任务要执行(有工作要做),但没有可用线程来执行它们的情况;结果是 CPU 空闲时间。
在特定示例中,应增加线程池的大小。但是,请不要认为仅因为有空闲 CPU 可用,就应增加线程池的大小以完成更多工作。在确定行动方向之前,重要的是了解程序为何未获得 CPU 周期 —— 这可能是由于锁定或外部资源的瓶颈。在确定行动方向之前,了解 为什么 程序未获得 CPU 是很重要的。(有关此主题的更多详细信息,请参阅第九章。)
查看 CPU 使用情况是了解应用程序性能的第一步,但仅仅是这样:使用它来查看代码是否使用了预期的所有 CPU,或者是否指向了同步或资源问题。
CPU 运行队列
Windows 和 Unix 系统都允许您监视可以运行的线程数(这意味着它们没有被 I/O 阻塞、休眠等)。Unix 系统将其称为运行队列,几种工具包括其在输出中的运行队列长度。这包括前一节中vmstat输出中的数据:每行的第一个数字是运行队列的长度。Windows 将此数字称为处理器队列,并通过typeperf报告它(除其他方式):
C:> typeperf -si 1 "\System\Processor Queue Length"
"05/11/2019 19:09:42.678","0.000000"
"05/11/2019 19:09:43.678","0.000000"
在这个输出中有一个重要的区别:Unix 系统上的运行队列长度数字(在样本vmstat输出中可能是 1 或 2)是所有正在运行或如果有可用 CPU 则可以运行的所有线程的数量。在示例中,始终至少有一个线程想要运行:执行应用程序工作的单个线程。因此,运行队列长度始终至少为 1。请记住,运行队列代表机器上的所有内容,因此有时会有其他线程(来自完全不同的进程)想要运行,这就是为什么在样本输出中运行队列长度有时为 2 的原因。
在 Windows 中,处理器队列长度不包括当前运行的线程数。因此,在typeperf的示例输出中,即使机器在运行相同的单线程应用程序且一个线程始终在执行,处理器队列数字也是 0。
如果要运行的线程多于可用的 CPU 数量,性能会开始下降。一般来说,在 Windows 上,您希望处理器队列长度为 0,在 Unix 系统上等于(或少于)CPU 数量。这不是一个硬性规则;系统进程和其他事物会定期出现,并且会短暂地提高该值,而不会对性能造成显著影响。但是,如果运行队列长度在任何显著时间内都过高,这表明机器负载过重,您应该考虑减少机器正在执行的工作量(通过将作业移动到另一台机器或优化代码)。
快速总结
-
在查看应用程序性能时,首先要检查的是 CPU 时间。
-
优化代码的目标是使 CPU 使用率提高(并保持较短的时间),而不是降低。
-
在深入尝试调整应用程序之前,了解 CPU 使用率为何低是很重要的。
磁盘使用
监控磁盘使用有两个重要目标。第一个与应用程序本身相关:如果应用程序进行大量磁盘 I/O 操作,那么该 I/O 很容易成为瓶颈。
知道磁盘 I/O 何时成为瓶颈是棘手的,因为它取决于应用程序的行为。如果应用程序未有效地缓冲写入磁盘的数据(例如在第十二章中的一个示例中),则磁盘 I/O 统计数据将较低。但是,如果应用程序执行的 I/O 超过磁盘处理能力,磁盘 I/O 统计数据将很高。在任何情况下,都可以改善性能;要注意这两种情况。
一些系统上的基本输入/输出监视器比其他系统更好。以下是 Linux 系统上iostat的部分输出:
% iostat -xm 5
avg-cpu: %user %nice %system %iowait %steal %idle
23.45 0.00 37.89 0.10 0.00 38.56
Device: rrqm/s wrqm/s r/s w/s rMB/s
sda 0.00 11.60 0.60 24.20 0.02
wMB/s avgrq-sz avgqu-sz await r_await w_await svctm %util
0.14 13.35 0.15 6.06 5.33 6.08 0.42 1.04
此应用程序正在向/dev/sda磁盘写入数据。乍一看,磁盘统计数据看起来不错。w_await——服务每个 I/O 写入的时间——相当低(6.08 毫秒),磁盘只使用了 1.04%。 (对于物理磁盘,这些值的可接受性取决于,但在我台式机系统中的 5200 RPM 磁盘在服务时间低于 15 毫秒时表现良好。)但是有一个线索表明出现了问题:系统花费 37.89%的时间在内核中。如果系统正在进行其他 I/O(在其他程序中),那就没什么问题;但如果所有这些系统时间都来自正在测试的应用程序,那么可能发生了一些效率低下的情况。
系统每秒进行 24.2 次写入是另一个线索:当每秒只写入 0.14 MB(MBps)时,这是很多的。I/O 已经成为瓶颈,下一步将是查看应用程序如何执行其写入操作。
另一方面的问题是,如果磁盘无法跟上 I/O 请求:
% iostat -xm 5
avg-cpu: %user %nice %system %iowait %steal %idle
35.05 0.00 7.85 47.89 0.00 9.20
Device: rrqm/s wrqm/s r/s w/s rMB/s
sda 0.00 0.20 1.00 163.40 0.00
wMB/s avgrq-sz avgqu-sz await r_await w_await svctm %util
81.09 1010.19 142.74 866.47 97.60 871.17 6.08 100.00
Linux 的好处在于它立即告诉我们磁盘已经 100%利用;它还告诉我们,进程花费 47.89%的时间在iowait(即等待磁盘)上。
即使在其他系统上仅有原始数据可用的情况下,这些数据也会告诉我们某些地方出了问题:完成 I/O 的时间(w_await)为 871 毫秒,队列大小相当大,磁盘每秒写入 81 MB 的数据。这一切都指向磁盘 I/O 存在问题,并且必须减少应用程序(或可能是系统其他地方)中的 I/O 量。
监控磁盘使用的第二个原因——即使不希望应用程序执行大量 I/O——是为了帮助监视系统是否在交换。计算机有固定数量的物理内存,但它们可以运行使用更大量虚拟内存的应用程序集。应用程序倾向于保留比它们需要的内存更多,并且通常仅在其内存的一个子集上操作。在这两种情况下,操作系统可以将未使用的内存部分保留在磁盘上,并且仅在需要时将其页面化到物理内存中。
大多数情况下,这种内存管理效果良好,特别是对于交互式和 GUI 程序(这很好,否则你的笔记本电脑将需要比它具有的内存多得多)。但对于基于服务器的应用程序来说效果不佳,因为这些应用程序倾向于更多地使用其内存。对于任何类型的 Java 应用程序(包括在桌面上运行的基于 Swing 的 GUI 应用程序),它的表现尤其糟糕,这是由于 Java 堆的存在。有关更多详细信息,请参阅第五章。
系统工具还可以报告系统是否在交换;例如,vmstat输出有两列(si表示换入,so表示换出),提醒我们系统是否在交换。磁盘活动是另一个指示器,表明可能正在发生交换。请特别注意这些,因为进行交换的系统——将数据页从主内存移动到磁盘,反之亦然——性能会非常差。系统必须配置得使交换永远不会发生。
快速总结
-
监控磁盘使用对所有应用程序都很重要。对于不直接写入磁盘的应用程序,系统交换仍然会影响其性能。
-
写入磁盘的应用程序可能会成为瓶颈,因为它们写入数据的效率低(吞吐量太低)或者因为它们写入了太多的数据(吞吐量太高)。
网络使用情况
如果您正在运行使用网络的应用程序,例如 REST 服务器,您必须监视网络流量。网络使用类似于磁盘流量:应用程序可能在不高效地使用网络,以致带宽过低,或者写入到特定网络接口的数据总量可能超过接口能够处理的能力。
不幸的是,标准系统工具并不理想用于监控网络流量,因为它们通常只显示发送和接收到特定网络接口上的数据包数和字节数。这是有用的信息,但它并不能告诉我们网络是过度利用还是不足利用。
在 Unix 系统上,基本的网络监控工具是netstat(在大多数 Linux 发行版上,甚至没有包含netstat,必须单独获取)。在 Windows 上,可以在脚本中使用typeperf来监视网络使用情况,但这里有一个情况,图形用户界面具有优势:标准的 Windows 资源监视器将显示一个图表,显示网络使用情况的百分比。不幸的是,在自动化性能测试场景中,GUI 帮助不大。
幸运的是,许多开源和商业工具可以监控网络带宽。在 Unix 系统上,一个流行的命令行工具是nicstat,它显示每个接口的流量摘要,包括接口利用率的程度:
% nicstat 5
Time Int rKB/s wKB/s rPk/s wPk/s rAvs wAvs %Util Sat
17:05:17 e1000g1 225.7 176.2 905.0 922.5 255.4 195.6 0.33 0.00
e1000g1接口是一个 1,000 MB 接口;在本例中没有被充分利用(0.33%)。这类工具(以及类似工具)的用处在于计算接口的利用率。在这个输出中,正在写入 225.7 Kbps 的数据,并且正在读取 176.2 Kbps 的数据。对于 1,000 MB 网络进行除法运算得到 0.33%的利用率数字,nicstat工具能够自动确定接口的带宽。
类似typeperf或netstat的工具会报告读取和写入的数据量,但要想计算网络利用率,必须确定接口的带宽并在您自己的脚本中执行计算。请记住,带宽以每秒位数(bps)为单位测量,尽管工具通常报告每秒字节(Bps)。一个 1,000 兆比特网络每秒产生 125 兆字节(MB)。在这个例子中,读取了 0.22 MBps,写入了 0.16 MBps;将它们相加并除以 125 得到 0.33%的利用率。因此,nicstat(或类似工具)并非魔术,它们只是使用起来更加方便。
网络无法持续 100%的利用率。对于局域以太网网络,持续超过 40%的利用率表示接口已饱和。如果网络是分组交换的或者使用不同的介质,则可能的最大持续速率将不同;请咨询网络架构师确定合适的目标。这个目标与 Java 无关,Java 只会使用操作系统的网络参数和接口。
快速总结
-
对于基于网络的应用程序,请监视网络,确保它没有成为瓶颈。
-
写入网络的应用程序可能会因为数据写入效率低(吞吐量太少)或者写入数据过多(吞吐量过高)而成为瓶颈。
Java 监控工具
要深入了解 JVM 本身,需要使用 Java 监控工具。这些工具包含在 JDK 中:
jcmd
打印 Java 进程的基本类、线程和 JVM 信息。这适合在脚本中使用;它是这样执行的:
% jcmd process_id command optional_arguments
提供help命令将列出所有可能的命令,并提供help <*command*>命令将给出特定命令的语法。
jconsole
提供了 JVM 活动的图形视图,包括线程使用情况、类使用情况和 GC 活动。
jmap
提供了堆转储和有关 JVM 内存使用的其他信息。适合脚本编写,虽然堆转储必须在后处理工具中使用。
jinfo
提供了对 JVM 系统属性的可见性,并允许动态设置一些系统属性。适合脚本编写。
jstack
转储 Java 进程的堆栈。适合脚本编写。
jstat
提供 GC 和类加载活动的信息。适合脚本编写。
jvisualvm
一个 GUI 工具用于监视 JVM、分析运行中的应用程序,并分析 JVM 堆转储(尽管 jvisualvm 也可以从活动程序中获取堆转储)。
所有这些工具都可以轻松地从与 JVM 相同的机器上运行。如果 JVM 在 Docker 容器内运行,则非图形化工具(即除了 jconsole 和 jvisualvm 外的工具)可以通过 docker exec 命令运行,或者如果您使用 nsenter 进入 Docker 容器。无论哪种情况,都假定您已将这些工具安装到 Docker 镜像中,这绝对是推荐的做法。通常会将 Docker 镜像简化为应用程序的基本需求,因此仅包括 JRE,但在生产中迟早需要了解该应用程序的内部情况,因此最好在 Docker 镜像中包含必要的工具(这些工具与 JDK 捆绑在一起)。
jconsole 需要相当数量的系统资源,因此在生产系统上运行它可能会干扰该系统。您可以设置 jconsole 以便可以在本地运行并附加到远程系统,这样不会影响远程系统的性能。在生产环境中,这需要安装证书以使 jconsole 能够通过 SSL 运行,并设置安全认证系统。
这些工具适用于以下广泛领域:
-
基本 VM 信息
-
线程信息
-
类信息
-
实时 GC 分析
-
堆转储后处理
-
对 JVM 进行性能分析
正如您可能注意到的那样,在这里没有一一对应的映射;许多工具在多个领域执行功能。因此,我们不会单独探讨每个工具,而是看看对于 Java 重要的可见性功能区域,并讨论各种工具如何提供这些信息。沿途我们会讨论其他工具(一些开源的,一些商业的),它们提供相同基本功能但具有优于基本 JDK 工具的优势。
基本 VM 信息
JVM 工具可以提供有关正在运行的 JVM 进程的基本信息:它已运行多长时间,正在使用哪些 JVM 标志,JVM 系统属性等等:
运行时间
可以通过以下命令找到 JVM 已运行时间:
% jcmd process_id VM.uptime
系统属性
可以用以下任一命令显示System.getProperties()中的项目集合:
% jcmd process_id VM.system_properties
或
% jinfo -sysprops process_id
这包括通过 -D 选项在命令行上设置的所有属性,应用程序动态添加的任何属性,以及 JVM 的默认属性集。
JVM 版本
获取 JVM 版本的方法如下:
% jcmd process_id VM.version
JVM 命令行
命令行可以在 jconsole 的 VM 概要选项卡中显示,或者通过 jcmd:
% jcmd process_id VM.command_line
JVM 调优标志
可以通过以下方式获取应用程序的调优标志:
% jcmd process_id VM.flags [-all]
使用调优标志
JVM 可以提供许多调整标志,并且其中许多标志是本书的重点关注对象。跟踪这些标志及其默认值可能有些令人生畏;这些jcmd的最后两个示例对此非常有用。command_line命令显示在命令行上直接指定的标志。flags命令显示在命令行上设置的标志,以及 JVM 直接设置的一些标志(因为它们的值是按人体工程学确定的)。包括-all选项会列出 JVM 中的每个标志。
存在数百个 JVM 调整标志,其中大多数是晦涩的;建议大多数标志永远不要更改(请参阅“信息过载?”)。在诊断性能问题时,确定哪些标志生效是一个常见的任务,jcmd命令可以为正在运行的 JVM 执行此操作。通常,您更愿意了解特定 JVM 的特定于平台的默认值,在这种情况下,在命令行上使用-XX:+PrintFlagsFinal选项更有用。这样做的最简单方法是执行此命令:
% java *`other_options`* -XX:+PrintFlagsFinal -version
...Hundreds of lines of output, including...
uintx InitialHeapSize := 4169431040 {product}
intx InlineSmallCode = 2000 {pd product}
因为设置某些选项(特别是设置与 GC 相关的标志时)将影响其他选项的最终值,因此应在命令行中包含您打算使用的任何其他选项。这将打印出 JVM 标志及其值的完整列表(与为实时 JVM 使用jcmd的VM.flags -all选项打印的内容相同)。
这些命令的标志数据以两种方式之一打印出来。包含输出的第一行的冒号表示正在使用标志的非默认值。这可能是因为以下原因:
-
标志的值是在命令行上直接指定的。
-
某些其他选项间接地改变了该选项。
-
JVM 按人体工程学计算了默认值。
第二行(没有冒号)表示该值是此版本 JVM 的默认值。某些标志的默认值在不同平台上可能不同,这在输出的最后一列中显示。product表示该标志的默认设置在所有平台上是统一的;pd product表示该标志的默认设置是依赖于平台的。
最后一列的其他可能值包括manageable(标志的值可以在运行时动态更改)和C2 diagnostic(该标志为编译器工程师提供诊断输出,以了解编译器的功能)。
另一种查看正在运行的应用程序的信息的方法是使用jinfo。jinfo的优点在于它允许在程序执行过程中更改某些标志值。
这里是如何检索进程中所有标志的值:
% jinfo -flags process_id
使用 -flags 选项,jinfo 将提供关于所有标志的信息;否则,它仅打印命令行指定的标志。任一命令的输出不如 -XX:+PrintFlagsFinal 选项易读,但 jinfo 还有其他需要注意的功能。
jinfo 可以检查单个标志的值:
% jinfo -flag PrintGCDetails process_id
-XX:+PrintGCDetails
虽然 jinfo 本身不指示标志是否可管理,但是可以通过 jinfo 打开或关闭可管理的标志(在使用 PrintFlagsFinal 参数时识别):
% jinfo -flag -PrintGCDetails process_id # turns off PrintGCDetails
% jinfo -flag PrintGCDetails process_id
-XX:-PrintGCDetails
请注意,在 JDK 8 中,jinfo 可以更改任何标志的值,但这并不意味着 JVM 将响应该更改。例如,大多数影响 GC 算法行为的标志在启动时用于确定收集器行为的各种方式。稍后通过 jinfo 改变标志并不会导致 JVM 更改其行为;它将根据算法初始化时的方式继续执行。因此,此技术仅适用于在 PrintFlagsFinal 命令的输出中标记为 manageable 的那些标志。在 JDK 11 中,如果尝试更改不能更改的标志的值,jinfo 将报告错误。
快速总结
-
jcmd可用于查找正在运行应用程序的基本 JVM 信息,包括所有调整标志的值。 -
可通过在命令行中包含
-XX:+PrintFlagsFinal来查找默认标志值。这对于确定特定平台上标志的默认人体工程学设置非常有用。 -
jinfo用于检查(在某些情况下更改)单个标志非常有用。
线程信息
jconsole 和 jvisualvm 实时显示应用程序中运行的线程数量信息。查看运行线程的堆栈可以帮助确定它们是否被阻塞。可以通过 jstack 获取堆栈信息:
% jstack process_id
... Lots of output showing each thread's stack ...
可以通过 jcmd 获取堆栈信息:
% jcmd process_id Thread.print
... Lots of output showing each thread's stack ...
更多关于监控线程堆栈的细节,请参阅 第九章。
类信息
可以通过 jconsole 或 jstat 获取应用程序使用的类数量信息。jstat 还可以提供有关类编译的信息。
更多关于应用程序类使用情况的细节,请参阅 第十二章,关于监视类编译的细节,请参阅 第四章。
实时 GC 分析
几乎每个监控工具都会报告有关 GC 活动的信息。jconsole 显示堆使用情况的实时图表;jcmd 允许执行 GC 操作;jmap 可以打印堆摘要或永久代信息,或者创建堆转储;而 jstat 提供了许多关于垃圾收集器活动的视图。
查看 第五章 以获取这些程序监控 GC 活动的示例。
堆转储后处理
堆转储可以通过jvisualvm GUI 或从命令行使用jcmd或jmap来捕获。堆转储是堆的快照,可以用各种工具分析,包括jvisualvm。堆转储处理是第三方工具传统上领先于 JDK 提供的一个领域,因此第七章使用第三方工具——Eclipse Memory Analyzer Tool (mat)——提供堆转储后处理的示例。
性能分析工具
性能分析工具是性能分析师工具箱中最重要的工具。Java 有许多性能分析工具,各有优缺点。性能分析是一个通常有意义使用不同工具的领域——特别是采样分析器。采样分析器倾向于以不同方式显示问题,因此在某些应用程序上可以更好地找出性能问题,但在其他应用程序上则可能更糟糕。
许多常见的 Java 性能分析工具本身是用 Java 编写的,并通过“附加”到要分析的应用程序来工作。此附加是通过套接字或通过称为 JVM 工具接口(JVMTI)的本机 Java 接口进行的。然后目标应用程序和性能分析工具交换关于目标应用程序行为的信息。
这意味着您必须像调整任何其他 Java 应用程序一样注意调整性能分析工具。特别是,如果被分析的应用程序很大,它可能会向性能分析工具传输大量数据,因此性能分析工具必须具有足够大的堆来处理数据。同时运行性能分析工具和并发 GC 算法通常是个好主意;性能分析工具中的不适时的全 GC 暂停可能导致保存数据的缓冲区溢出。
采样分析器
分析有两种模式:采样模式或插装模式。采样模式是基本的分析模式,开销最小。这很重要,因为分析的一个陷阱是通过引入测量到应用程序中,改变了其性能特征。¹ 限制分析的影响将导致更接近应用程序在通常情况下行为的结果。
不幸的是,采样分析器可能会遭受各种错误。采样分析器在定时器定期触发时工作;然后分析器查看每个线程并确定线程正在执行的方法。该方法则被认为自上次定时器触发以来已执行。
最常见的采样错误由图 3-1 说明。这里的线程在执行methodA(显示为阴影条)和methodB(显示为清晰条)之间交替。如果计时器只在线程恰好在methodB时触发,配置文件将报告线程花费所有时间执行methodB;实际上,更多的时间实际上是花在methodA上的。
图 3-1. 替代方法执行
这是最常见的采样错误,但绝不是唯一的。减少此错误的方法是在较长的时间段内进行分析,并减少样本之间的时间间隔。减少样本间隔对减少分析对应用程序的影响目标是适得其反的;这里存在一种平衡。分析工具在解决这种平衡时的方法各不相同,这是一个分析工具可能报告的数据与另一个工具大相径庭的原因之一。
这种错误是所有采样分析器固有的,但在许多 Java 分析器中(尤其是旧版本中)更为严重。这是由于安全点偏差。在分析器的常见 Java 接口中,只有线程在安全点时分析器才能获取线程的堆栈跟踪。当线程处于以下状态时,线程会自动进入安全点:
-
阻塞在同步锁上
-
阻塞等待 I/O
-
阻塞等待监视器
-
停放
-
执行 Java Native Interface (JNI) 代码(除非它们执行 GC 锁定功能)
此外,JVM 可以设置一个标志,要求线程进入安全点。代码用于检查此标志(如有必要,在某些内存分配或在编译代码的循环或方法转换期间插入到 JVM 代码中)。没有规范指示这些安全点检查何时发生,它们在发布版本之间有所不同。
这种安全点偏差对采样分析器的影响可以是深远的:因为只有线程在安全点时才能对栈进行采样,所以采样变得更加不可靠。在图 3-1 中,如果没有安全点偏差的随机分析器可能很难仅在methodB执行时触发线程采样。但是有了安全点偏差,更容易看到methodA永远不进入安全点的情况,因此所有工作都计入methodB。
Java 8 为工具提供了一种不同的方式来收集堆栈跟踪信息(这也是较旧的工具存在安全点偏差的一个原因,而较新的工具则倾向于没有安全点偏差,尽管这需要较新的工具重写以使用新的机制)。在编程术语中,这是通过使用AsyncGetCallTrace接口来完成的。使用此接口的分析器通常被称为异步分析器。这里的异步指的是 JVM 提供堆栈信息的方式,并不是指分析工具的工作方式;它被称为异步是因为 JVM 可以在任何时间点提供堆栈信息,而不必等待线程达到(同步的)安全点。
使用此异步接口的分析器比其他采样分析器具有更少的采样伪像(尽管它们仍然会受到类似于图 3-1 中错误的影响)。异步接口在 Java 8 中被公开,但在此之前作为私有接口存在很长一段时间。
图 3-2 显示了一个基本的采样分析报告,用于测量提供来自第二章描述的应用程序的样本股票数据的 REST 服务器的性能。 REST 调用配置为返回包含股票对象的压缩序列化形式的字节流(这是我们将在第十二章中探讨的示例的一部分)。我们将在本节中的示例中使用该示例程序。
图 3-2. 采样分析报告示例
此截图来自 Oracle Developer Studio 分析器。该工具使用异步分析接口,尽管通常不被称为异步分析器(可能是由于历史原因,因为它在该接口是私有的时候开始使用该接口,因此早于异步分析器术语的流行使用)。它提供了对数据的各种视图;在这个视图中,我们看到消耗最多 CPU 周期的方法。其中一些方法与对象序列化有关(例如,ObjectOutputStream.writeObject0()方法),而许多方法与计算实际数据有关(例如,Math.pow()方法)。² 尽管如此,对象序列化在这个分析中占据主导地位;为了提高性能,我们需要改进序列化性能。
请仔细注意最后一句话:需要改进的是序列化的性能,而不是writeObject0()方法本身的性能。在查看分析报告时的常见假设是优化应从分析报告中排名靠前的方法开始。然而,这种方法通常过于局限。在这种情况下,writeObject0()方法是 JDK 的一部分;不会通过重写 JVM 来改进其性能。但是我们从分析报告中知道,序列化路径是我们性能瓶颈所在的地方。
因此,概要中的顶部方法应该指向您搜索优化区域的位置。性能工程师不会尝试使 JVM 方法更快,但他们可以找出如何加速对象序列化。
我们可以以两种额外的方式可视化抽样输出;两者都直观地显示调用堆栈。最新的方法称为火焰图,它是应用程序内调用堆栈的交互式图表。
图 3-3 展示了使用开源 async-profiler 项目 的一部分火焰图。火焰图是方法使用大量 CPU 的自下而上图表。在图的这一部分中,getStockObject() 方法占据了所有时间。大约 60% 的时间花费在 writeObject() 调用中,而 40% 的时间花费在 StockPriceHistoryImpl 对象的构造函数中。类似地,我们可以查看每个方法的堆栈并找到性能瓶颈。图本身是交互式的,因此您可以单击行并查看有关方法的信息,包括被截断的完整名称、CPU 周期等。
较旧(尽管仍然有用)的性能可视化方法是一种自上而下的方法,称为调用树。图 3-4 展示了一个示例。
图 3-3. 来自抽样分析器的火焰图
图 3-4. 来自抽样分析器的调用树
在这种情况下,我们从顶部开始具有类似的数据:100% 的时间中,Errors.process() 方法及其后继消耗了 44%。然后我们深入到父级并查看其子级在哪里花费时间。例如,在getStockObject() 方法中,总时间的 17% 中,10% 的时间花费在 writeObject0 中,7% 在构造函数中。
快速总结
-
基于抽样的分析器是最常见的一种分析器。
-
由于其相对较低的性能影响,抽样分析器引入较少的测量工件。
-
使用可以进行异步堆栈收集的抽样分析器将减少测量工件。
-
不同的抽样配置表现不同;每种对特定应用程序可能更好。
仪器化分析器
仪器化的分析器比抽样分析器更具侵入性,但它们也可以提供关于程序内部操作的更多有益信息。
插装分析器通过修改类加载时的字节码序列(插入代码来计算调用次数等)来工作。它们更有可能在应用程序中引入性能差异,而不是采样分析器。例如,JVM 会内联小方法(见第四章),这样在执行小方法代码时就不需要方法调用。编译器根据代码的大小来做出这个决定;根据代码的插装方式,可能不能再内联。这可能导致插装分析器高估某些方法的贡献。内联只是编译器根据代码布局做出的一个决定的示例;一般来说,代码插装(修改)越多,执行配置文件的概率就会更高。
由于插装引入的代码更改,最好将其用于少数几个类。这意味着最好用于二级分析:采样分析器可以指向一个包或代码段,然后如果需要,可以使用插装分析器来深入分析该代码。
图 3-5 使用一个插装分析器(不使用异步接口)来查看样本 REST 服务器。
图 3-5. 插装分析的一个示例
这个分析器与其他分析器有几点不同。首先,主要时间归因于writeObject()方法,而不是writeObject0()方法。这是因为私有方法在检测中被过滤掉了。其次,实体管理器中出现了一个新方法;在采样情况下,这个方法内联到构造函数中,所以之前没有出现。
但这种类型的分析更重要的是调用次数:我们对那个实体管理器方法进行了惊人的 3300 万次调用,以及对计算随机数进行了 1.66 亿次调用。通过减少这些方法的总调用次数,我们可以获得更大的性能提升,而不是加快它们的执行速度,但如果没有插装计数,我们可能不会知道这一点。
这种分析比采样版本更好吗?这取决于情况;在特定情况下,没有办法知道哪个分析更准确。插装分析的调用次数肯定是准确的,这些额外信息通常有助于确定代码在哪里花费了更多时间,以及哪些优化更有价值。
在这个例子中,无论是插装还是采样的分析器,都指向了代码的同一大致区域:对象序列化。实际上,不同的分析器可能指向完全不同的代码区域。分析器是良好的估算工具,但它们只是在估算:它们有时候会出错。
快速总结
-
工具化的性能分析器提供了关于应用程序更多的信息,但可能对应用程序的影响大于采样分析器。
-
工具化的性能分析器应设置为仪器化代码的小部分——几个类或包。这限制了它们对应用程序性能的影响。
阻塞方法和线程时间轴
图 3-6 展示了使用不同工具化分析器——内置于 jvisualvm 的分析器——的 REST 服务器。现在执行时间主要由 select() 方法(以及 TCPTransport 连接处理器的 run() 方法)主导。
图 3-6. 带有阻塞方法的分析
这些方法(以及类似的阻塞方法)不消耗 CPU 时间,因此它们不会增加应用程序的整体 CPU 使用率。它们的执行不能必然优化。应用程序中的线程并非在 select() 方法中执行代码 673 秒;它们在等待选择事件发生 673 秒。
因此,大多数性能分析器不会报告被阻塞的方法;这些线程被显示为空闲状态。在这个特定示例中,这是件好事。线程在 select() 方法中等待,因为没有数据流入服务器;它们并不低效。这是它们的正常状态。
在其他情况下,确实希望看到在这些阻塞调用中花费的时间。线程在 wait() 方法内等待——等待另一个线程通知它——是许多应用程序整体执行时间的重要决定因素。大多数基于 Java 的性能分析器具有可以调整以显示或隐藏这些阻塞调用的过滤集和其他选项。
或者,通常更有成效的是检查线程的执行模式,而不是分析器将时间归因于阻塞方法本身。图 3-7 展示了来自 Oracle Developer Studio 分析工具的线程显示。
图 3-7. 线程时间轴分析
这里每个水平区域都是一个不同的线程(因此图中显示了九个线程:从线程 1.14 到线程 1.22)。彩色(或不同灰度)条代表不同方法的执行;空白区域表示线程未执行的地方。从高层次来看,观察到线程 1.14 执行了代码,然后等待某些事情。
还要注意空白区域,看不到任何线程在执行。这张图片只显示了应用程序中的九个线程中的九个,因此可能是这些线程在等待其他线程做某事,或者线程可能正在执行一个阻塞的 read() (或类似的)调用。
快速总结
-
阻塞的线程可能或可能不是性能问题的源头;有必要检查它们为何被阻塞。
-
阻塞线程可以通过阻塞方法或线程的时间轴分析来识别。
本地分析器
像 async-profiler 和 Oracle Developer Studio 这样的工具除了可以分析 Java 代码外,还能够分析本地代码。这有两个优点。
首先,在本地代码中进行重要操作,包括在本地库和本地内存分配中。在 第八章 中,我们将使用本地分析器查看一个导致真实问题的本地内存分配示例。使用本地分析器快速定位了根本原因。
其次,我们通常进行分析以找出应用程序代码中的瓶颈,但有时本地代码意外地占主导地位。我们更愿意通过检查 GC 日志(正如我们将在 第六章 中所做的那样)来发现我们的代码在 GC 中花费了过多时间,但如果忘记了这条路径,一个理解本地代码的分析器将迅速显示出我们在 GC 中花费了过多的时间。同样地,我们通常会限制分析到程序热身后的时期,但如果编译线程(第四章)正在运行并且消耗了过多的 CPU,一个支持本地代码的分析器将显示给我们。
当你查看我们示例 REST 服务器的火焰图时,为了可读性只展示了一小部分。图 3-8 显示了整个图。
在图表的底部有五个组件。前两个(来自 JAX-RS 代码)是应用线程和 Java 代码。第三个是进程的 GC,第四个是编译器。³
图 3-8. 包含本地代码的火焰图
快速总结
-
本地分析器提供了对 JVM 代码和应用程序代码的可见性。
-
如果本地分析器显示 GC 占用 CPU 使用时间过多,则调整收集器是正确的做法。然而,如果显示编译线程占用了显著的时间,则通常不会影响应用程序的性能。
Java 飞行记录器
Java 飞行记录器(JFR)是 JVM 的一个特性,它在应用程序运行时执行轻量级性能分析。顾名思义,JFR 数据是 JVM 中事件的历史记录,可用于诊断 JVM 的过去性能和操作。
JFR 最初是 BEA Systems 的 JRockit JVM 的一个特性。最终它进入了 Oracle 的 HotSpot JVM;在 JDK 8 中,只有 Oracle JVM 支持 JFR(并且仅许 Oracle 客户使用)。然而,在 JDK 11 中,JFR 可在包括 AdoptOpenJDK JVM 在内的开源 JVM 中使用。由于 JDK 11 中的 JFR 是开源的,因此有可能将其移植回 JDK 8 的开源版本中,因此 AdoptOpenJDK 和其他 JDK 8 的版本可能会在未来某天包含 JFR(尽管至少在 8u232 中尚未如此)。
JFR 的基本操作是启用一组事件(例如,一个事件是线程因等待锁而阻塞),每次发生选定的事件时,都会保存关于该事件的数据(保存在内存中或文件中)。数据流保存在循环缓冲区中,因此只有最近的事件可用。然后,您可以使用工具显示这些事件——从实时 JVM 中获取或从保存的文件中读取——并对这些事件进行分析以诊断性能问题。
所有这些——事件的类型、循环缓冲区的大小、存储位置等等——都通过 JVM 的各种参数或在程序运行时通过工具(包括 jcmd 命令)来控制。默认情况下,JFR 被设置为具有非常低的开销:对程序性能影响低于 1%。随着启用更多事件、更改事件报告阈值等,此开销将发生变化。所有这些配置的详细信息将在本节后面讨论,但首先我们将研究这些事件显示看起来如何,因为这样更容易理解 JFR 的工作原理。
Java Mission Control
检查 JFR 记录的通常工具是 Java Mission Control(jmc),尽管存在其他工具,并且您可以使用工具包编写自己的分析工具。在全面转向全开源 JVM 过程中,jmc 已从 OpenJDK 源代码库中移出,并成为一个独立的项目。这使得 jmc 可以按独立的发布时间表和路径进行演化,尽管最初可能处理独立的发布有点令人困惑。
在 JDK 8 中,jmc 版本 5 随 Oracle 的 JVM 捆绑提供(这是唯一支持 JFR 的 JVM)。JDK 11 可以使用 jmc 版本 7,尽管目前必须从 OpenJDK 项目页面 获取二进制文件。计划是最终 JDK 构建将使用并捆绑适当的 jmc 二进制文件。
Java Mission Control 程序(jmc)启动一个窗口,显示机器上的 JVM 进程,并允许您选择一个或多个进程进行监视。图 3-9 显示了 Java Mission Control 监视我们示例 REST 服务器的 Java 管理扩展(JMX)控制台。
图 3-9. Java Mission Control 监视
此显示展示了 Java Mission Control 正在监控的基本信息:CPU 使用率、堆使用率和 GC 时间。不过,请注意,CPU 图表包含了机器上的总 CPU 使用情况。JVM 本身正在使用约 38% 的 CPU,尽管机器上所有进程总共占用了约 60% 的 CPU。这是监控的一个关键特性:通过 JMX 控制台,Java Mission Control 能够监控整个系统,而不仅仅是所选择的 JVM。上部的仪表板可以配置为显示 JVM 信息(关于 GC、类加载、线程使用、堆使用等各种统计信息)以及特定于操作系统的信息(总机器 CPU 和内存使用情况、交换、负载平均值等等)。
与其他监控工具一样,Java Mission Control 可以调用 Java 管理扩展(JMX)调用,访问应用程序可用的任何托管 bean。
JFR 概述
通过适当的工具,我们可以深入了解 JFR 的工作原理。此示例使用从我们的 REST 服务器获取的 JFR 记录,持续时间为 6 分钟。当记录加载到 Java Mission Control 中时,它首先显示的是基本的监控概述(图 3-10)。
图 3-10. Java Flight Recorder 概述
此显示类似于 Java Mission Control 在进行基本监控时显示的内容。在显示 CPU 和堆使用率的仪表之上是事件时间轴(由一系列垂直条表示)。时间轴允许我们放大到特定感兴趣的区域;虽然在此示例中,录制持续了 6 分钟,但我放大到了录制末尾附近的 38 秒间隔。
此 CPU 使用率图更清楚地显示了发生的情况:REST 服务器位于图表底部(平均使用率约为 20%),整机的 CPU 使用率为 38%。底部还有其他选项卡,允许我们探索有关系统属性以及 JFR 记录制作方式的信息。窗口左侧的图标更加有趣:这些图标提供了对应用程序行为的可见性。
JFR 内存视图
此处收集的信息非常广泛。图 3-11 显示了内存视图的一个面板。
图 3-11. Java Flight Recorder 内存面板
该图显示,由于年轻代被清除(同时我们看到堆在此期间总体上是增长的:从约 340 MB 开始,最终约为 2 GB),内存波动相当规律。左下角面板显示了记录期间发生的所有收集事件,包括它们的持续时间和收集类型(在此示例中始终是 ParallelScavenge)。当选择其中一个事件时,右下角面板进一步详细显示了该收集的所有特定阶段及其持续时间。
此页面上的各个标签提供了丰富的其他信息:清除引用对象的时间和数量,是否存在并发收集器的提升或疏散失败,GC 算法本身的配置(包括代的大小和幸存者空间配置),甚至是分配的特定类型对象的信息。当您阅读第五章和第六章时,请记住这个工具如何诊断讨论的问题。如果您需要理解为什么 G1 GC 收集器退出并执行了全局 GC(是由于提升失败吗?),JVM 如何调整终身阈值,或者关于 GC 行为如何以及为什么的任何其他数据,JFR 都能告诉您。
JFR 代码视图
Java Mission Control 中的 Code 页面显示了来自记录的基本分析信息(图 3-12)。
图 3-12. Java Flight Recorder Code 面板
此页面上的第一个标签显示了按包名称聚合的信息,这是许多分析工具中找不到的有趣功能。在底部,其他标签呈现了受分析代码的热点方法和调用树的传统概要视图。
与其他分析工具不同,JFR 提供了进入代码的其他可见性模式。异常标签提供了对应用程序异常处理的视图(第 12 章讨论为什么过度异常处理对性能不利)。其他标签提供了关于编译器正在执行的内容的信息,包括对代码缓存的视图(参见 第 4 章)。
另一方面,请注意,这里的包在我们之前查看的分析中并没有真正显示出来;反之,我们之前看到的热点在这里也没有出现。由于它设计为具有非常低的开销,JFR 的配置(至少在默认配置中)的性能抽样相当低,因此与更入侵性的抽样相比,这些分析结果并不那么精确。
这类似的显示还有其他,比如线程、I/O 和系统事件,但大多数情况下,这些显示只是提供了 JFR 记录中实际事件的良好视图。
JFR 事件概述
JFR 生成一系列事件作为记录保存。迄今为止看到的显示提供了这些事件的视图,但查看事件本身面板是最强大的方式,如图 3-13 所示。
图 3-13. Java Flight Recorder Event 面板
此窗口左侧面板可筛选要显示的事件;这里选择了应用级和 JVM 级事件。请注意,在录制时,仅包括特定类型的事件:在此时,我们正在进行后处理筛选(下一节将展示如何筛选录制中包含的事件)。
在这个示例中的 34 秒间隔内,应用程序从 JVM 生成了 878 个事件和从 JDK 库生成了 32 个事件,并且在此期间生成的事件类型显示在窗口底部附近。当我们使用分析器查看这个示例时,我们看到为什么该示例的线程停靠和监视器等待事件会很高;这些可以忽略(并且这里在左侧面板中过滤掉了线程停靠事件)。那么其他事件呢?
在 34 秒的时间段内,应用程序中的多个线程花费了 34 秒来读取套接字。这个数字听起来不好,特别是因为它只有在套接字读取时间超过 10 毫秒时才会在 JFR 中显示出来。我们需要进一步研究这一点,可以通过访问图 3-14 中显示的日志选项卡来完成。
图 3-14. Java 飞行记录器事件日志面板
值得注意的是要查看与这些事件相关的跟踪,但事实证明,有几个线程使用阻塞 I/O 读取预期定期到达的管理请求。在这些请求之间——长时间的周期内——线程将阻塞在 read() 方法上。所以这里的读取时间是可以接受的:就像使用分析器时一样,你需要确定大量阻塞 I/O 的线程是预期的还是表示性能问题。
这留下了监视器阻塞事件。正如第 9 章中讨论的那样,对于锁的争用经历了两个级别:首先线程在等待锁时会自旋,然后它会使用(在称为锁膨胀的过程中)一些 CPU 和操作系统特定的代码来等待锁。标准的分析器可以提供关于这种情况的线索,因为自旋时间包含在方法的 CPU 时间中。本地分析器可以提供关于受膨胀影响的锁的信息,但这可能是有或无的。然而,JVM 可以直接向 JFR 提供所有这些数据。
在第 9 章中展示了使用锁可见性的示例,但关于 JFR 事件的一般结论是,由于它们直接来自 JVM,它们提供了其他任何工具都无法提供的应用程序可见性。在 Java 11 中,大约可以通过 JFR 监控 131 种事件类型。确切的事件数量和类型会因版本而略有不同,但以下列表详细说明了一些更有用的事件类型。
下面列表中的每个事件类型显示两个项目符号。事件可以收集基本信息,这些信息可以使用像jconsole和jcmd这样的其他工具收集;这类信息在第一个项目符号中描述。第二个项目符号描述事件提供的难以在 JFR 之外获得的信息。
类加载
-
装载和卸载的类的数量
-
装载类的类加载器;加载单个类所需的时间
线程统计
-
创建和销毁的线程数量;线程转储
-
哪些线程被锁定在锁上(以及它们被锁定的具体锁)
可抛出对象
-
应用程序使用的可抛出类
-
抛出的异常和错误的数量及其创建时的堆栈跟踪
TLAB 分配
-
堆中分配的次数和线程本地分配缓冲区(TLAB)的大小
-
分配在堆中的特定对象及其分配位置的堆栈跟踪
文件和套接字 I/O
-
执行 I/O 操作的时间
-
每次读取/写入调用所花费的时间,以及长时间读取或写入的特定文件或套接字
阻塞的监视器
-
等待监视器的线程
-
特定线程在特定监视器上的阻塞以及它们被阻塞的时间长度
代码缓存
-
代码缓存的大小及其所包含的内容量
-
从代码缓存中删除的方法;代码缓存配置
代码编译
-
编译的方法,栈上替换(OSR)编译(参见第四章),以及编译所需的时间
-
没有特定于 JFR 的内容,但统一了来自多个来源的信息
垃圾回收
-
GC 的时间,包括各个阶段的时间;各代的大小
-
没有特定于 JFR 的内容,但统一了来自多个工具的信息
分析
-
仪器化和采样分析配置
-
虽然不如真正的分析器那样详细,但 JFR 分析器提供了一个很好的高级概述
启用 JFR
初始情况下,JFR 是禁用的。要启用它,请在应用程序的命令行中添加标志-XX:+FlightRecorder。这将作为一个功能启用 JFR,但直到启用录制过程本身之前都不会进行录制。可以通过 GUI 或命令行执行此操作。
在 Oracle JDK 8 中,您还必须指定此标志(在FlightRecorder标志之前):-XX:+UnlockCommercialFeatures(默认值:false)。
如果忘记包含这些标志,请记住可以使用jinfo更改它们的值并启用 JFR。如果使用jmc启动录制,如果需要,它会自动在目标 JVM 中更改这些值。
通过 Java Mission Control 启用 JFR
启用本地应用程序录制的最简单方法是通过 Java Mission Control GUI(jmc)。当启动jmc时,它会显示当前系统上运行的所有 JVM 进程的列表。JVM 进程以树节点配置显示;展开 Flight Recorder 标签下的节点即可显示图 3-15 中显示的飞行记录器窗口。
图 3-15. JFR 启动飞行录制窗口
飞行记录有两种模式:固定持续时间(在本例中为 1 分钟)或连续。对于连续录制,将使用循环缓冲区;缓冲区将包含在所需持续时间和大小内的最近事件。
要执行主动分析——这意味着您将启动录制,然后生成一些工作或在 JVM 预热后的负载测试实验期间启动录制——应使用固定持续时间录制。该录制将提供 JVM 在测试期间响应的良好指示。
连续录制对于反应性分析最为合适。这使得 JVM 保留最近的事件,然后根据事件将录制内容输出。例如,WebLogic 应用服务器可以触发在应用服务器中出现异常事件(例如请求处理超过 5 分钟)时输出录制内容。您可以设置自己的监控工具,以响应任何类型的事件输出录制内容。
通过命令行启用 JFR
启用 JFR 后(使用-XX:+FlightRecorder选项),有不同的方法来控制实际录制的时间和方式。
在 JDK 8 中,可以使用-XX:+FlightRecorderOptions=*string参数在 JVM 启动时控制默认录制参数;这对于反应性录制最为有用。该string*参数是从这些选项中取出的逗号分隔的名称-值对列表:
name=*name*
用于标识录制的名称。
defaultrecording=*<true|false>*
是否最初开始录制。默认值为false;对于反应性分析,应设置为true。
settings=*path*
包含 JFR 设置的文件名(请参阅下一节)。
delay=*time*
开始录制前的时间量(例如,30s,1h)。
duration=*time*
进行录制的时间量。
filename=*path*
要将录制写入的文件名。
compress=*<true|false>*
是否(使用 gzip)压缩录制内容;默认为false。
maxage=*time*
保持循环缓冲区中记录数据的最长时间。
maxsize=*size*
录制的循环缓冲区的最大大小(例如,1024K,1M)。
-XX:+FlightRecorderOptions仅设置任何选项的默认值;个别录制可以覆盖这些设置。
在 JDK 8 和 JDK 11 中,您可以通过使用-XX:+StartFlightRecording=*string*标志在程序初始启动时开始 JFR,其中包含类似的逗号分隔选项列表。
设置默认录制可以在某些情况下非常有用,但为了更灵活,可以在运行时使用jcmd控制所有选项。
开始飞行记录:
% jcmd process_id JFR.start [options_list]
options_list 是一系列逗号分隔的名称-值对,控制录制的方式。可能的选项与可以在命令行上使用 -XX:+FlightRecorderOptions=string 标志指定的选项完全相同。
如果启用了连续录制,则可以随时通过此命令将循环缓冲区中的当前数据转储到文件:
% jcmd process_id JFR.dump [options_list]
选项列表包括以下内容:
name=*name*
启动录制时使用的名称(有关 JFR.check 的示例,请参见下一个示例)。
filename=*path*
转储文件到的位置。
可能已为给定进程启用了多个 JFR 录制。要查看可用的录制:
% jcmd 21532 JFR.check [verbose]
21532:
Recording 1: name=1 maxsize=250.0MB (running)
Recording 2: name=2 maxsize=250.0MB (running)
在此示例中,进程 ID 21532 有两个活动的 JFR 录制,分别命名为 1 和 2。该名称可用于标识它们在其他 jcmd 命令中的使用。
最后,要中止正在进行的录制:
% jcmd process_id JFR.stop [options_list]
该命令接受以下选项:
name=*name*
停止的录制名称。
discard=*boolean*
如果为 true,则丢弃数据而不是将其写入先前提供的文件名(如果有的话)。
filename=*path*
将数据写入指定路径。
在自动化性能测试系统中,运行这些命令行工具并生成录制在需要检查这些运行是否存在回归时非常有用。
选择 JFR 事件
正如前面提到的,JFR 支持许多事件。通常,这些是周期性事件:它们每隔几毫秒发生一次(例如,性能分析事件基于采样基础)。其他事件仅在事件持续时间超过某个阈值时触发(例如,仅当 read() 方法的执行时间超过指定时间时才触发读文件事件)。
收集事件自然会增加开销。事件收集的阈值——因为它增加了事件的数量——也会对启用 JFR 录制带来的开销产生影响。在默认录制中,并不收集所有事件(最昂贵的六个事件未启用),而基于时间的事件的阈值也相对较高。这使得默认录制的开销保持在低于 1%的水平。
有时候额外的开销是值得的。例如,查看 TLAB 事件可以帮助您确定对象是否直接分配到老年代,但这些事件在默认录制中未启用。类似地,性能分析事件在默认录制中启用,但只有每 20 毫秒一次——这提供了一个很好的概述,但也可能导致采样误差。⁴
JFR 捕获的事件(及事件的阈值)是在模板中定义的(通过命令行上的 settings 选项选择)。JFR 随附两个模板:默认模板(限制事件,使开销低于 1%)和配置文件模板(将大多数基于阈值的事件设置为每 10 ms 触发一次)。配置文件模板的预估开销为 2%(尽管,如常情况会有所不同,通常开销低于此值)。
模板由 jmc 模板管理器管理;您可能已经注意到了在 图 3-15 中启动模板管理器的按钮。模板存储在两个位置:在 JAVA_HOME/jre/lib/jfr 目录下(JVM 全局)。模板管理器允许您选择全局模板(模板将显示“在服务器上”),选择本地模板或定义新模板。要定义模板,请循环遍历可用事件,并根据需要启用(或禁用)它们,可选择设置事件启动的阈值。
图 3-16 显示,文件读取事件启用时设置阈值为 15 ms:超过此阈值的文件读取将触发事件。该事件还已配置生成文件读取事件的堆栈跟踪。这会增加开销,这也是为什么为事件获取堆栈跟踪是可配置选项的原因。
图 3-16. 一个示例 JFR 事件模板
事件模板是简单的 XML 文件,因此确定模板中启用的事件(及其阈值和堆栈跟踪配置)的最佳方法是阅读 XML 文件。使用 XML 文件还允许在一台计算机上定义本地模板文件,然后将其复制到团队中其他人使用的全局模板目录中。
快速总结
-
Java Flight Recorder 提供了对 JVM 最佳的可见性,因为它内置于 JVM 本身。
-
与所有工具一样,JFR 在应用程序中引入了一定级别的开销。对于日常使用,可以启用 JFR 以低开销收集大量信息。
-
JFR 在性能分析中很有用,但在生产系统上启用时,也可以检查导致故障的事件。
总结
优秀的工具是优秀性能分析的关键;在本章中,我们仅仅触及了工具能告诉我们的一部分内容。以下是需要牢记的关键点:
-
没有完美的工具,竞争工具各有其相对优势。
Profiler X可能非常适合许多应用程序,但在某些情况下,它会错过Profiler Y明确指出的一些问题。在方法上一定要保持灵活。 -
命令行监控工具可以自动收集重要数据;务必在自动化性能测试中包含收集此监控数据。
-
工具迅速演进:本章提到的一些工具可能已经过时(或至少已被新的、更优越的工具所取代)。在这个领域保持更新是很重要的。
¹ 不过,你仍需进行性能分析:否则,你怎么知道程序内部的猫是否还活着呢?
² 你会看到对像 InstanceKlass::oop_push_contents 这样的本地 C++ 代码的引用;我们将在下一节更详细地讨论它。
³ 这张特定的图表再次来自 Oracle Developer Studio 工具,尽管 async-profiler 生成了相同的一组本地调用。
⁴ 这就是为什么我们查看的 JFR 配置文件未必与前几节中更为侵入性的配置文件匹配。
第四章:与 JIT 编译器一起工作
即时编译器(JIT compiler) 是 Java 虚拟机的核心;没有什么比 JIT 编译器更能控制应用程序的性能了。
本章深入讲解编译器。它从编译器的工作原理开始讲起,探讨了使用即时编译器(JIT compiler)的优缺点。直到 JDK 8 出现之前,你必须选择两个 Java 编译器之间。如今,这两个编译器仍然存在,但它们协同工作,虽然在少数情况下需要选择其中一个。最后,我们将研究一些编译器的中级和高级调优方法。如果一个应用程序运行缓慢且没有明显原因,这些部分可以帮助您确定是否是编译器的问题。
即时编译器:概述
我们将从一些介绍性材料开始;如果您理解即时编译的基础知识,可以直接跳过。
计算机——特别是 CPU——只能执行相对较少的特定指令,称为机器码(machine code)。因此,CPU 执行的所有程序都必须转换成这些指令。
类似 C++ 和 Fortran 的语言被称为编译语言(compiled languages),因为它们的程序以二进制(编译后的)代码交付:程序编写完成后,静态编译器生成二进制代码。该二进制代码的汇编代码针对特定的 CPU。兼容的 CPU 可以执行相同的二进制代码:例如,AMD 和 Intel CPU 共享一组基本的汇编语言指令,并且后续版本的 CPU 几乎总是可以执行与前一版本相同的指令集。反之则不尽然;新版本的 CPU 通常会引入在旧版本 CPU 上无法运行的指令。
另一方面,像 PHP 和 Perl 这样的语言是解释执行的。只要机器有正确的解释器(即名为php或perl的程序),就可以在任何 CPU 上运行相同的程序源代码。解释器在执行每行程序时将该行翻译成二进制代码。
每个系统都有优缺点。用解释语言编写的程序是可移植的:你可以将相同的代码放到任何有适当解释器的机器上,它都能运行。但它可能运行得很慢。举个简单的例子,考虑循环中发生的情况:解释器在每次执行循环中的代码时都会重新翻译每一行代码。而编译代码则不需要重复进行这种翻译。
一个优秀的编译器在生成二进制代码时考虑了多个因素。一个简单的例子是二进制语句的顺序:并非所有的汇编语言指令执行时间都相同。例如,将两个寄存器中存储的值相加的语句可能在一个周期内执行,但从主存中检索所需的值可能需要多个周期。
因此,一个优秀的编译器将生成一个二进制文件,该文件在执行加载数据语句、执行其他指令,然后在数据可用时执行加法操作时进行操作。一次只看一行代码的解释器没有足够的信息来生成这种类型的代码;它会从内存中请求数据,等待数据可用,然后执行加法操作。顺便说一句,糟糕的编译器也会做同样的事情,即使是最好的编译器也不能完全防止偶尔需要等待指令执行完成的情况。
因此,解释性代码几乎总是比编译后的代码执行速度慢:编译器对程序有足够的信息,可以对二进制代码进行优化,而解释器无法执行这种优化。
解释性代码确实具有可移植性的优势。为 ARM CPU 编译的二进制文件显然不能在 Intel CPU 上运行。但是使用 Intel Sandy Bridge 处理器的最新 AVX 指令的二进制文件也不能在旧的 Intel 处理器上运行。因此,商业软件通常被编译成针对较旧版本处理器的二进制文件,不会利用可用的最新指令。有各种技巧可以解决这个问题,包括使用包含多个共享库的二进制文件,这些库执行对各种 CPU 版本敏感的性能关键代码。
Java 在这里试图找到一个折中点。Java 应用程序是被编译的,但不是编译成特定 CPU 的特定二进制文件,而是编译成中间低级语言。这种语言(称为 Java 字节码)然后由 java 二进制文件运行(就像解释性 PHP 脚本由 php 二进制文件运行一样)。这使得 Java 具有解释语言的平台独立性。因为它执行的是理想化的二进制代码,所以 java 程序能够在代码执行时将代码编译成平台二进制代码。这种编译发生在程序执行时:“即时” 发生。
这种编译仍然受平台依赖性的影响。例如,JDK 8 无法为 Intel Skylake 处理器的最新指令集生成代码,但 JDK 11 可以。我将在 “高级编译器标志” 中详细讨论这个问题。
Java 虚拟机在执行代码时编译的方式是本章的重点。
热点编译
如 第一章 中所讨论的那样,本书中讨论的 Java 实现是 Oracle 的 HotSpot JVM。这个名字(HotSpot)来自于它对编译代码的处理方式。在典型的程序中,只有很小一部分代码经常被执行,应用程序的性能主要取决于这些代码段的执行速度。这些关键部分被称为应用程序的热点;代码段被执行得越多,这部分就越热。
因此,当 JVM 执行代码时,并不立即开始编译代码。这有两个基本原因。首先,如果代码只会被执行一次,那么编译它基本上是一种浪费;解释 Java 字节码比编译它们并执行(只执行一次)编译代码更快。
但是,如果所讨论的代码是一个频繁调用的方法或运行多次迭代的循环,那么编译它是值得的:编译代码所需的周期将被多次执行更快的编译代码所节省。这种权衡是编译器首先执行解释代码的原因之一——编译器可以确定哪些方法被频繁调用以足以编译。
第二个原因是优化的一个方面:JVM 执行特定方法或循环的次数越多,它对该代码的了解越多。这使得 JVM 在编译代码时可以进行许多优化。
这些优化(以及影响它们的方法)将在本章后面讨论,但是作为一个简单的例子,考虑 equals() 方法。这个方法存在于每个 Java 对象中(因为它从 Object 类继承而来),并经常被重写。当解释器遇到语句 b = obj1.equals(obj2) 时,它必须查找 obj1 的类型(类)以知道要执行哪个 equals() 方法。这种动态查找可能需要一些时间。
随着时间的推移,JVM 注意到每次执行这个语句时,obj1 的类型是 java.lang.String。然后 JVM 可以生成直接调用 String.equals() 方法的编译代码。现在代码不仅因为被编译而更快,而且可以跳过调用哪个方法的查找。
事情并不像那么简单;下次执行代码时,obj1可能指向除了String以外的其他对象。JVM 将创建处理这种可能性的编译代码,其中涉及去优化和重新优化相关代码(你可以在“去优化”中看到一个例子)。尽管如此,总体上在这里生成的编译代码将会更快(至少在obj1继续指向String的情况下),因为它跳过了执行方法查找的步骤。这种优化只能在运行代码一段时间并观察其行为后才能进行:这是 JIT 编译器等待编译代码段的第二个原因。
快速总结
-
Java 旨在充分利用脚本语言的平台独立性和编译语言的本地性能。
-
Java 类文件被编译成一种中间语言(Java 字节码),然后由 JVM 进一步编译成汇编语言。
-
将字节码编译成汇编语言会执行优化,极大地提高了性能。
分层编译
曾几何时,JIT 编译器有两种版本,你需要根据你想要使用的编译器安装不同版本的 JDK。这些编译器被称为客户端和服务器编译器。在 1996 年,这是一个重要的区别;而在 2020 年,已经不再如此。如今,所有发布的 JVM 都包括这两种编译器(尽管在常见用法中,它们通常被称为服务器JVM)。
尽管称为服务器 JVM,但客户端和服务器编译器之间的区别仍然存在;JVM 同时可以使用这两种编译器,了解这种区别对理解编译器的工作原理很重要。
历史上,JVM 开发者(甚至一些工具)有时候会用C1(编译器 1,客户端编译器)和C2(编译器 2,服务器编译器)来称呼这些编译器。现在这些名称更加贴切,因为客户端和服务器计算机之间的区别早已不复存在,所以我们将在全文中沿用这些名称。
这两个编译器之间的主要区别在于它们在编译代码时的积极性。C1 编译器开始编译的时间比 C2 编译器早。这意味着在代码执行的初期阶段,C1 编译器会更快,因为它会编译比 C2 编译器对应更多的代码。
在这里的工程权衡在于 C2 编译器在等待过程中获得的知识:这种知识使得 C2 编译器能够在编译后的代码中做出更好的优化。最终,由 C2 编译器生成的代码将比 C1 编译器生成的代码更快。从用户的角度来看,这种权衡的好处取决于程序运行的时间长短以及程序启动时间的重要性。
当这些编译器是分开的时,显而易见的问题是为什么需要有选择的必要性:JVM 不能从 C1 编译器开始然后在代码变热时切换到 C2 编译器吗?这种技术被称为分层编译,现在所有的 JVM 都使用这种技术。可以通过 -XX:-TieredCompilation 标志显式禁用它(其默认值为 true);在 “高级编译器标志” 中,我们将讨论这样做的后果。
常见的编译器标志
两个常用标志影响 JIT 编译器;我们将在本节中讨论它们。
调整代码缓存
当 JVM 编译代码时,它将汇编语言指令集保存在代码缓存中。代码缓存的大小是固定的,一旦填满,JVM 就无法再编译额外的代码了。
如果代码缓存太小,潜在问题显而易见。一些热方法将被编译,但其他方法不会:应用程序最终将运行大量(非常慢的)解释代码。
当代码缓存填满时,JVM 会输出如下警告:
Java HotSpot(TM) 64-Bit Server VM warning: CodeCache is full.
Compiler has been disabled.
Java HotSpot(TM) 64-Bit Server VM warning: Try increasing the
code cache size using -XX:ReservedCodeCacheSize=
有时候会错过这个消息;确定编译器是否停止编译代码的另一种方法是查看稍后本节讨论的编译日志输出。
实际上没有一个很好的机制来确定特定应用程序需要多少代码缓存。因此,当您需要增加代码缓存大小时,这有点是试错操作;一个典型的选项是简单地将默认值加倍或四倍。
代码缓存的最大大小通过 -XX:ReservedCodeCacheSize=N 标志设置(其中 *N* 是前述特定编译器的默认值)。代码缓存像 JVM 中的大多数内存一样进行管理:有一个初始大小(由 -XX:InitialCodeCacheSize=N指定)。代码缓存大小的分配从初始大小开始,并在缓存填满时增加。代码缓存的初始大小为 2,496 KB,而默认的最大大小为 240 MB。缓存的调整在后台进行,不会真正影响性能,因此通常只需要设置ReservedCodeCacheSize` 大小(即设置最大代码缓存大小)。
是否指定一个非常大的最大代码缓存大小,以便永远不会耗尽空间会有什么劣势?这取决于目标机器上的资源。如果指定了 1 GB 的代码缓存大小,JVM 将保留 1 GB 的本机内存。该内存在需要时才分配,但仍然被保留,这意味着您的机器上必须有足够的虚拟内存来满足保留。
此外,如果您仍然拥有一台带有 32 位 JVM 的旧 Windows 机器,则总进程大小不能超过 4 GB。这包括 Java 堆、JVM 本身的所有代码(包括其本机库和线程堆栈)、应用程序分配的任何本机内存(直接或通过新 I/O [NIO] 库)、当然还包括代码缓存。
这些是代码缓存不是无界的原因,有时需要调整大型应用程序的设置。在具有足够内存的 64 位机器上,将值设置得过高不太可能对应用程序产生实际效果:应用程序不会耗尽进程空间内存,并且额外的内存预留通常会被操作系统接受。
在 Java 11 中,代码缓存被分为三部分:
-
非方法代码
-
概要代码
-
非概要代码
默认情况下,代码缓存的大小相同(最高可达 240 MB),您仍然可以使用 ReservedCodeCacheSize 标志调整代码缓存的总大小。在这种情况下,非方法代码段根据编译器线程的数量分配空间(参见 “编译线程”);在具有四个 CPU 的机器上,大约为 5.5 MB。然后,其他两个段等分剩余的总代码缓存——例如,在具有四个 CPU 的机器上,每个段约为 117.2 MB(总计 240 MB)。
您很少需要单独调整这些段,但如果需要,标志如下:
-
-XX:NonNMethodCodeHeapSize=*N*:用于非方法代码 -
-XX:ProfiledCodeHapSize=*N*用于概要代码 -
-XX:NonProfiledCodeHapSize=*N*用于非概要代码
代码缓存的大小(以及 JDK 11 段)可以通过使用 jconsole 实时监控,并在内存面板上选择内存池代码缓存图表来完成。您还可以按照 第八章 中描述的方式启用 Java 的本地内存跟踪功能。
快速摘要
-
代码缓存是一个具有定义的最大大小的资源,影响 JVM 可以运行的编译代码总量。
-
非常大的应用程序可以在其默认配置中使用完整的代码缓存;如果需要,监视代码缓存并增加其大小。
检查编译过程
第二个标志并非调优本身:它不会改善应用程序的性能。相反,-XX:+PrintCompilation 标志(默认为 false)使我们能够看到编译器的工作情况(尽管我们也会查看提供类似信息的工具)。
如果启用了 PrintCompilation,每次编译方法(或循环)时,JVM 都会打印一行信息,说明刚刚编译的内容。
编译日志的大多数行具有以下格式:
timestamp compilation_id attributes (tiered_level) method_name size deopt
此处的时间戳是编译完成后的时间(相对于 JVM 启动时的时间 0)。
compilation_id 是内部任务 ID。通常,这个数字会单调递增,但有时你可能会看到一个无序的 compilation_id。这种情况最常见于多个编译线程同时运行,并且表明编译线程相对于彼此的运行速度快慢不一。不过,请不要断定某个特定的编译任务在某种程度上非常慢:这通常只是线程调度的一个功能。
attributes 字段是一个由五个字符组成的序列,表示正在编译的代码的状态。如果某个特定属性适用于给定的编译,则会打印下面列表中显示的字符;否则,该属性的空间会打印。因此,五字符属性字符串可以显示为由两个或多个以空格分隔的项。各种属性如下:
%
编译是 OSR。
s
该方法已同步。
!
该方法有一个异常处理程序。
b
编译以阻塞模式发生。
n
对一个本地方法的包装进行了编译。
这些属性中的第一个是 on-stack replacement(OSR)。JIT 编译是一个异步过程:当 JVM 决定某个方法应该被编译时,该方法会被放入一个队列中。而不是等待编译,JVM 接着会继续解释该方法,下次方法被调用时,JVM 将执行方法的编译版本(假设编译已经完成)。
但是考虑一个长时间运行的循环。JVM 将注意到应该编译循环本身,并将该代码排队进行编译。但这还不够:JVM 必须能够在循环仍在运行时开始执行已编译的循环版本——等待循环和封闭方法退出将是低效的(甚至可能根本不会发生)。因此,当循环的代码编译完成时,JVM 替换代码(在栈上),循环的下一次迭代将执行代码的更快编译版本。这就是 OSR。
下面两个属性应该是不言而喻的。在当前版本的 Java 中,默认情况下不会打印阻塞标志;它表示编译未在后台进行(有关更多详细信息,请参阅 “编译线程”)。最后,本地属性表示 JVM 生成了编译代码以便调用本地方法。
如果分层编译已禁用,则下一个字段(tiered_level)将为空白。否则,它将是一个表示已完成编译的层级的数字。
接下来是正在编译的方法的名称(或正在为 OSR 编译的循环的方法),打印为 ClassName::method。
接下来是代码的size(以字节为单位)。这是 Java 字节码的大小,而不是编译后代码的大小(因此,不幸的是,这不能用来预测如何调整代码缓存的大小)。
最后,在某些情况下,编译行末尾的消息将指示发生了某种去优化;这些通常是made not entrant或made zombie短语。详细信息请参阅“去优化”。
编译日志中可能还包括类似以下的一行:
timestamp compile_id COMPILE SKIPPED: reason
此行(带有字面文本COMPILE SKIPPED)表示给定方法的编译出现了问题。在两种情况下,这是预期的,具体取决于指定的原因:
代码缓存已满
使用ReservedCodeCache标志需要增加代码缓存的大小。
并发类加载
当编译时,该类正在被修改。JVM 将稍后重新编译它;你应该期望在日志中稍后再次看到该方法被重新编译的信息。
在所有情况下(除了填充缓存),应重新尝试编译。如果没有重新尝试,则表示错误阻止了代码的编译。这通常是编译器中的一个错误,但在所有情况下的常规解决方法是将代码重构为编译器可以处理的更简单的东西。
这里是从启用PrintCompilation的股票 REST 应用程序中输出的几行:
28015 850 4 net.sdo.StockPrice::getClosingPrice (5 bytes)
28179 905 s 3 net.sdo.StockPriceHistoryImpl::process (248 bytes)
28226 25 % 3 net.sdo.StockPriceHistoryImpl::<init> @ 48 (156 bytes)
28244 935 3 net.sdo.MockStockPriceEntityManagerFactory$\
MockStockPriceEntityManager::find (507 bytes)
29929 939 3 net.sdo.StockPriceHistoryImpl::<init> (156 bytes)
106805 1568 ! 4 net.sdo.StockServlet::processRequest (197 bytes)
此输出仅包括一些与股票相关的方法(并不一定包括特定方法的所有行)。有几点有趣的地方需要注意:第一个这样的方法直到服务器启动后的 28 秒才被编译,而在此之前已编译了 849 个方法。在这种情况下,所有其他方法都是服务器或 JDK 的方法(已从此输出中过滤掉)。服务器启动大约需要 2 秒;在编译任何其他内容之前的剩余 26 秒基本上处于空闲状态,因为应用服务器在等待请求。
其余行用于指出有趣的特征。process()方法是同步的,因此属性包括一个s。内部类与任何其他类一样编译,并以通常的 Java 命名方式出现在输出中:outer-classname$inner-classname。processRequest()方法如预期般带有异常处理程序。
最后,回顾一下StockPriceHistoryImpl构造函数的实现,其中包含一个大循环:
public StockPriceHistoryImpl(String s, Date startDate, Date endDate) {
EntityManager em = emf.createEntityManager();
Date curDate = new Date(startDate.getTime());
symbol = s;
while (!curDate.after(endDate)) {
StockPrice sp = em.find(StockPrice.class, new StockPricePK(s, curDate));
if (sp != null) {
if (firstDate == null) {
firstDate = (Date) curDate.clone();
}
prices.put((Date) curDate.clone(), sp);
lastDate = (Date) curDate.clone();
}
curDate.setTime(curDate.getTime() + msPerDay);
}
}
循环执行的频率比构造函数本身高,因此循环会进行 OSR 编译。请注意,该方法的编译需要一段时间;其编译 ID 为 25,但在编译 900 范围内的其他方法后才会出现。 (很容易像这个例子中的 OSR 行一样读成 25%,并想知道其他的 75%,但请记住,数字是编译 ID,%仅表示 OSR 编译。) 这是 OSR 编译的典型情况;栈替换设置更难,但与此同时可以进行其他编译。
分层编译级别
使用分层编译的程序的编译日志打印出每个方法编译的层级。在样本输出中,代码编译到级别 3 或 4,尽管到目前为止我们只讨论了两个编译器(加上解释器)。原来有五个编译级别,因为 C1 编译器有三个级别。所以编译的级别如下所示:
0
解释性代码
1
简单的 C1 编译代码
2
有限的 C1 编译代码
3
完全的 C1 编译代码
4
C2 编译代码
典型的编译日志显示,大多数方法首先在级别 3 处进行编译:完全的 C1 编译。(当然,所有方法都从级别 0 开始,但日志中不显示。) 如果方法运行频率足够高,则会在级别 4 进行编译(并且级别 3 代码将变为非输入)。 这是最常见的路径:C1 编译器等待进行编译直到它有关于代码如何使用的信息,可以利用这些信息进行优化。
如果 C2 编译器队列已满,方法将从 C2 队列中取出,并在级别 2 处进行编译,这是 C1 编译器使用调用和反向边计数器的级别(但不需要配置文件反馈)。这样可以更快地编译该方法;在 C1 编译器收集配置文件信息后,该方法稍后将在级别 3 处进行编译,并在 C2 编译器队列较少时最终编译到级别 4。
另一方面,如果 C1 编译器队列已满,计划在级别 3 进行编译的方法可能在等待在级别 3 处编译时就变得适合在级别 4 进行编译。在这种情况下,它会快速编译到级别 2,然后过渡到级别 4。
由于其微不足道的特性,简单的方法可能从级别 2 或 3 开始,但然后因为其微不足道而进入级别 1。如果由于某种原因 C2 编译器无法编译代码,它也会进入级别 1。当代码被取消优化时,它会回到级别 0。
标志控制某些行为,但期望在此级别进行调整时获得结果是乐观的。性能的最佳情况发生在方法按预期编译的情况下:tier 0 → tier 3 → tier 4。如果方法经常编译成 tier 2 并且有额外的 CPU 周期可用,考虑增加编译器线程的数量;这将减少 C2 编译器队列的大小。如果没有额外的 CPU 周期可用,则唯一能做的就是尝试减少应用程序的大小。
取消优化
输出PrintCompilation标志的讨论提到编译器取消优化代码的两种情况。取消优化意味着编译器必须“撤销”先前的编译。这会导致应用程序的性能降低,至少直到编译器重新编译相关代码为止。
取消优化发生在两种情况下:当代码成为非入口时和当代码变成僵尸时。
非入口代码
有两件事导致代码成为非入口。一个是由于类和接口的工作方式,另一个是分层编译的实现细节。
让我们看看第一个案例。回想一下股票应用程序有一个名为StockPriceHistory的接口。在示例代码中,这个接口有两个实现:一个基本实现(StockPriceHistoryImpl)和一个添加日志记录(StockPriceHistoryLogger)的实现。在 REST 代码中,所使用的实现基于 URL 的log参数:
StockPriceHistory sph;
String log = request.getParameter("log");
if (log != null && log.equals("true")) {
sph = new StockPriceHistoryLogger(...);
}
else {
sph = new StockPriceHistoryImpl(...);
}
// Then the JSP makes calls to:
sph.getHighPrice();
sph.getStdDev();
// and so on
如果调用一堆http://localhost:8080/StockServlet(即没有log参数),编译器将会发现sph对象的实际类型是StockPriceHistoryImpl。然后会内联代码并根据此知识执行其他优化。
后来,假设调用了http://localhost:8080/StockServlet?log=true。现在编译器对sph对象类型的假设是错误的;先前的优化不再有效。这会生成一个取消优化陷阱,并且之前的优化将被丢弃。如果启用了大量带日志的附加调用,JVM 将快速重新编译该代码并进行新的优化。
针对该场景的编译日志将包含以下行:
841113 25 % net.sdo.StockPriceHistoryImpl::<init> @ -2 (156 bytes)
made not entrant
841113 937 s net.sdo.StockPriceHistoryImpl::process (248 bytes)
made not entrant
1322722 25 % net.sdo.StockPriceHistoryImpl::<init> @ -2 (156 bytes)
made zombie
1322722 937 s net.sdo.StockPriceHistoryImpl::process (248 bytes)
made zombie
请注意,OSR 编译的构造函数和标准编译的方法都已被标记为非入口,并且在稍后的某个时候它们将变成僵尸。
取消优化听起来像是一件坏事,至少在性能方面是这样,但并不一定如此。表 4-1 显示了 REST 服务器在取消优化场景下达到的每秒操作数。
表 4-1. 在取消优化的服务器吞吐量
| 场景 | OPS |
|---|---|
| 标准实现 | 24.4 |
| 取消优化后的标准实现 | 24.4 |
| 日志实现 | 24.1 |
| 混合实现 | 24.3 |
标准实现将给出 24.4 次/秒。假设在那个测试之后立即运行一个测试,触发了 StockPriceHistoryLogger 路径——这是产生刚刚列出的去优化示例的场景。PrintCompilation 的完整输出显示,当开始请求日志记录实现时,StockPriceHistoryImpl 类的所有方法都被去优化了。但是在去优化之后,如果重新运行使用 StockPriceHistoryImpl 实现的路径,那段代码将会被重新编译(带有稍微不同的假设),我们仍然会看到约 24.4 次/秒(在另一个预热期之后)。
当然,这是最好的情况。如果调用交错,以至于编译器永远无法真正假设代码将采取哪条路径呢?由于额外的日志记录,包含日志记录的路径通过服务器获得约 24.1 次/秒。如果操作混合,我们会得到约 24.3 次/秒:这几乎符合平均值的预期。因此,除了瞬间处理陷阱的时间点外,去优化没有对性能产生任何显著影响。
可能导致代码变为非入口的第二个因素是分层编译的工作方式。当代码由 C2 编译器编译时,JVM 必须替换已由 C1 编译器编译的代码。它通过将旧代码标记为非入口,并使用相同的去优化机制来替换新编译的(更有效的)代码来实现这一点。因此,当使用分层编译运行程序时,编译日志将显示大量被标记为非入口的方法。不要惊慌:实际上,这种“去优化”使得代码运行更快。
检测这一点的方法是注意编译日志中的层级等级:
40915 84 % 3 net.sdo.StockPriceHistoryImpl::<init> @ 48 (156 bytes)
40923 3697 3 net.sdo.StockPriceHistoryImpl::<init> (156 bytes)
41418 87 % 4 net.sdo.StockPriceHistoryImpl::<init> @ 48 (156 bytes)
41434 84 % 3 net.sdo.StockPriceHistoryImpl::<init> @ -2 (156 bytes)
made not entrant
41458 3749 4 net.sdo.StockPriceHistoryImpl::<init> (156 bytes)
41469 3697 3 net.sdo.StockPriceHistoryImpl::<init> (156 bytes)
made not entrant
42772 3697 3 net.sdo.StockPriceHistoryImpl::<init> (156 bytes)
made zombie
42861 84 % 3 net.sdo.StockPriceHistoryImpl::<init> @ -2 (156 bytes)
made zombie
在这里,构造函数首先在第 3 级进行 OSR 编译,然后也在第 3 级进行完全编译。一秒钟后,OSR 代码有资格进行第 4 级编译,因此它在第 4 级进行编译,并且第 3 级的 OSR 代码被标记为非入口。然后,相同的过程发生在标准编译中,并且最终第 3 级代码变成了僵尸。
去优化僵尸代码
当编译日志报告它已经生成僵尸代码时,它是在说它已经回收了以前被标记为非入口的代码。在前面的示例中,在使用 StockPriceHistoryLogger 实现运行测试之后,StockPriceHistoryImpl 类的代码被标记为非入口。但 StockPriceHistoryImpl 类的对象仍然存在。最终,所有这些对象都被 GC 回收。当发生这种情况时,编译器注意到该类的方法现在有资格被标记为僵尸代码。
对于性能来说,这是一件好事。请记住,编译后的代码存储在固定大小的代码缓存中;当识别出僵尸方法时,相关代码可以从代码缓存中删除,为其他类编译腾出空间(或者限制 JVM 以后需要分配的内存量)。
可能的缺点是,如果类的代码变成僵尸,然后稍后重新加载并且再次大量使用,JVM 将需要重新编译和重新优化代码。不过,在先前的场景中确实发生了这种情况,在没有记录日志、有记录日志、再次没有记录日志的情况下运行测试;在这种情况下,性能并没有明显受到影响。一般来说,当僵尸代码重新编译时发生的小型重新编译不会对大多数应用程序产生可测量的影响。
快速总结
-
获得查看代码编译方式的最佳方法是启用
PrintCompilation。 -
通过启用
PrintCompilation输出的信息可以用来确保编译按预期进行。 -
分层编译可以在两个编译器中的五个不同级别上操作。
-
去优化是 JVM 替换先前编译代码的过程。这通常发生在 C2 代码替换 C1 代码的情况下,但也可以因应用程序执行配置文件的变化而发生。
高级编译器标志
本节介绍了一些影响编译器的其他标志。主要是为了更好地理解编译器的工作原理;一般情况下,不应该使用这些标志。另一方面,它们包含在此处的另一个原因是它们曾经足够常见,以至于广泛使用,因此如果你遇到它们并想知道它们的作用,本节应该能解答这些问题。
编译阈值
这一章在定义触发代码编译的内容上有些模糊。主要因素是代码执行的频率;一旦代码执行达到一定次数,其编译阈值就会达到,编译器认为有足够的信息来编译代码。
调整会影响这些阈值。然而,本节的真正目的是为了让您更好地了解编译器的工作方式(并引入一些术语);在当前的 JVM 中,调整阈值实际上从未有意义过。
编译基于 JVM 中的两个计数器:方法被调用的次数和方法中任何循环返回的次数。循环返回 实际上可以被视为循环完成执行的次数,无论是因为它到达了循环本身的结尾,还是因为它执行了像 continue 这样的分支语句。
当 JVM 执行 Java 方法时,它会检查这两个计数器的总和,并决定该方法是否有资格进行编译。如果有,该方法将被排队等待编译(详见“编译线程”了解更多排队详情)。这种编译没有官方名称,但通常被称为标准 编译。
同样地,每当一个循环完成执行时,分支计数器都会递增和检查。如果分支计数器超过了其单独的阈值,该循环(而不是整个方法)就有资格进行编译。
调整会影响这些阈值。当停用分层编译时,标准编译由-XX:CompileThreshold=N 标志的值触发。N 的默认值为 10,000。改变CompileThreshold标志的值将导致编译器选择比通常更早(或更晚)编译代码。不过,请注意,虽然这里有一个标志,但阈值是通过添加回边循环计数器的总和加上方法入口计数器来计算的。
您经常会找到建议修改CompileThreshold标志的建议,并且一些 Java 基准测试的出版物在此标志之后使用它(例如,通常在 8,000 次迭代之后)。一些应用程序仍然默认设置了该标志。
但请记住,我说这个标志在停用分层编译时有效——这意味着当分层编译启用时(通常是这样),这个标志根本不起作用。实际上,使用这个标志只是从 JDK 7 及之前的日子保留下来的一个习惯。
这个标志曾经推荐使用有两个原因:首先,将其降低可以改善使用 C2 编译器的应用程序的启动时间,因为代码会更快地被编译(通常效果相同)。其次,它可能导致一些本来不会被编译的方法被编译。
最后一点是一个有趣的特点:如果一个程序永远运行下去,我们是否期望其所有代码最终都会被编译?事实并非如此,因为编译器使用的计数器会随着方法和循环的执行而增加,但它们也会随着时间的推移而减少。定期(具体来说,当 JVM 达到安全点时),每个计数器的值都会减少。
从实际角度来看,这意味着这些计数器是方法或循环最近热度的相对度量。一个副作用是,即使是经常执行的代码也可能永远不会被 C2 编译器编译,即使是那些永远运行的程序也是如此。这些方法有时被称为温暖的(与热的相对)。在分层编译之前,这是减少编译阈值有益的一个案例。
然而,即使是温和的方法现在也将被编译,尽管如果我们能让它们由 C2 编译器而不是 C1 编译器编译,可能会稍微改进一点点。实际上没有太多实际好处,但如果你真的感兴趣,可以尝试修改标志-XX:Tier3InvocationThreshold=*N*(默认 200)以更快地让 C1 编译方法,并且-XX:Tier4InvocationThreshold=*N*(默认 5000)以更快地让 C2 编译方法。类似的标志也适用于后向边界阈值。
快速总结
-
方法(或循环)编译的阈值通过可调参数设置。
-
没有分层编译时,调整这些阈值有时是有意义的,但是有了分层编译,不再建议进行此调整。
编译线程
“编译阈值”提到,当一个方法(或循环)符合编译条件时,它将被加入编译队列。这个队列由一个或多个后台线程处理。
这些队列不是严格的先进先出;调用计数更高的方法具有优先级。因此,即使程序开始执行并有大量代码需要编译,这种优先级排序也确保最重要的代码首先被编译。(这也是为什么PrintCompilation输出中的编译 ID 可能会出现顺序混乱的另一个原因。)
C1 和 C2 编译器有不同的队列,每个队列由(可能是多个)不同的线程处理。线程数基于复杂的对数公式,但表 4-2 列出了详细信息。
表 4-2。分层编译的默认 C1 和 C2 编译器线程数
| CPU | C1 线程 | C2 线程 |
|---|---|---|
| 1 | 1 | 1 |
| 2 | 1 | 1 |
| 4 | 1 | 2 |
| 8 | 1 | 2 |
| 16 | 2 | 6 |
| 32 | 3 | 7 |
| 64 | 4 | 8 |
| 128 | 4 | 10 |
通过设置-XX:CICompilerCount=*N*标志可以调整编译器线程数。这是 JVM 用于处理队列的总线程数;对于分层编译,将使用三分之一(但至少一个)来处理 C1 编译器队列,其余的线程(但至少一个)将用于处理 C2 编译器队列。该标志的默认值是前述表格两列的总和。
如果禁用分层编译,只启动给定数量的 C2 编译器线程。
何时考虑调整此值?因为默认值基于 CPU 数量,这是一种情况,即在 Docker 容器内运行较旧版本的 JDK 8 可能会导致自动调整出现问题。在这种情况下,您需要根据 Docker 容器分配的 CPU 数量手动设置此标志到期望值(使用表 4-2 中的目标作为指导)。
同样,如果程序在单 CPU 虚拟机上运行,并且只有一个编译器线程可能会稍微有利:有限的 CPU 可用,并且较少的线程争夺该资源将在许多情况下有助于性能。然而,该优势仅限于初始热身期;之后,要编译的合格方法数量实际上不会导致对 CPU 的争用。当在单 CPU 机器上运行股票批处理应用程序并且编译器线程数限制为一个时,初始计算速度大约快 10%(因为它们不必经常竞争 CPU)。运行的迭代越多,该初始利益的整体效果越小,直到所有热方法都被编译,这种利益被消除。
另一方面,线程数量可能会轻易地压倒系统,特别是如果同时运行多个 JVM(每个 JVM 将启动许多编译线程)。在这种情况下减少线程数量可以帮助整体吞吐量(尽管可能会导致热身期持续时间更长的可能成本)。
如果有大量额外的 CPU 循环可用,那么在理论上,当编译线程的数量增加时,程序将受益——至少在其热身期间会受益。在现实生活中,这种好处几乎是难以获得的。此外,如果所有这些多余的 CPU 可用,你最好尝试利用整个应用程序执行过程中可用的 CPU 循环(而不仅仅是在开始时编译更快)。
应用于编译线程的另一个设置是 -XX:+BackgroundCompilation 标志的值,默认为 true。这意味着队列按照刚才描述的方式异步处理。但是该标志可以设置为 false,在这种情况下,当一个方法有资格进行编译时,希望执行它的代码将等待直到它实际编译为止(而不是继续在解释器中执行)。当指定 -Xbatch 时,后台编译也会被禁用。
快速总结
-
对于放置在编译队列上的方法,编译是异步进行的。
-
队列并不是严格有序的;热方法在编译日志中编译前于其他方法是另一个原因。
内联
编译器进行的最重要的优化之一是方法内联。遵循良好面向对象设计的代码通常包含通过 getter(可能还有 setter)访问的属性:
public class Point {
private int x, y;
public void getX() { return x; }
public void setX(int i) { x = i; }
}
调用方法的开销非常高,特别是与方法内代码量相比。事实上,在 Java 早期,性能优化经常反对这种封装方式,因为所有这些方法调用对性能的影响很大。幸运的是,JVM 现在常规地对这类方法执行代码内联。因此,你可以这样编写这段代码:
Point p = getPoint();
p.setX(p.getX() * 2);
编译后的代码本质上会执行以下操作:
Point p = getPoint();
p.x = p.x * 2;
内联默认启用。可以使用-XX:-Inline标志禁用它,尽管这种内联是如此重要的性能提升,你实际上永远不会这样做(例如,禁用内联会使库存批处理测试的性能降低超过 50%)。但是,由于内联如此重要,也许是因为我们有许多其他可调整的参数,建议经常关于调整 JVM 内联行为。
不幸的是,没有基本的方式可以查看 JVM 如何内联代码。如果你从源代码编译 JVM,可以生成包含-XX:+PrintInlining标志的调试版本。该标志提供有关编译器做出的所有内联决策的各种信息。最好的办法是查看代码的概要,并且如果任何简单方法位于概要的顶部,并且看起来应该被内联,则尝试使用内联标志进行实验。
是否内联方法的基本决定取决于其热度和大小。JVM 确定方法是否热(即经常调用)基于内部计算;它不直接受任何可调参数的影响。如果一个方法因为频繁调用而有资格内联,它只有在其字节码大小小于 325 字节(或者指定为-XX:MaxFreqInlineSize=*N标志)时才会被内联。否则,只有当其大小小于 35 字节(或者指定为-XX:MaxInlineSize=N*标志)时才有资格内联。
有时你会看到建议增加MaxInlineSize标志的值,以便内联更多的方法。这种关系经常被忽视的一个方面是,如果将MaxInlineSize值设置得比 35 高,那么当方法首次被调用时可能会被内联。然而,如果该方法经常被调用——在这种情况下其性能更为重要——那么它最终会被内联(假设其大小小于 325 字节)。否则,调整MaxInlineSize标志的净效果可能会减少测试所需的预热时间,但长时间运行的应用程序不太可能产生重大影响。
快速总结
-
内联是编译器可以进行的最有益的优化,特别是对于良好封装属性的面向对象代码而言。
-
调整内联标志很少需要,建议这样做的往往没有考虑到正常内联和频繁内联之间的关系。在研究内联的影响时,请确保考虑这两种情况。
逃逸分析
如果启用了逃逸分析(-XX:+DoEscapeAnalysis,默认情况下为true),C2 编译器将进行激进的优化。例如,考虑以下与阶乘相关的类:
public class Factorial {
private BigInteger factorial;
private int n;
public Factorial(int n) {
this.n = n;
}
public synchronized BigInteger getFactorial() {
if (factorial == null)
factorial = ...;
return factorial;
}
}
为了在数组中存储前 100 个阶乘值,使用以下代码:
ArrayList<BigInteger> list = new ArrayList<BigInteger>();
for (int i = 0; i < 100; i++) {
Factorial factorial = new Factorial(i);
list.add(factorial.getFactorial());
}
factorial对象只在那个循环内部引用;其他代码永远无法访问该对象。因此,JVM 可以自由地对该对象进行优化:
-
在调用
getFactorial()方法时,它不需要获取同步锁。 -
它不需要在内存中存储字段
n;它可以将该值保存在寄存器中。同样,它可以将factorial对象引用保存在寄存器中。 -
事实上,它根本不需要分配实际的阶乘对象;它只需要跟踪对象的各个字段。
这种优化很复杂:在这个例子中它足够简单,但即使是更复杂的代码,这些优化也是可能的。根据代码的使用情况,并非所有优化都一定适用。但逃逸分析可以确定哪些优化是可能的,并对已编译的代码进行必要的更改。
逃逸分析默认启用。在极少数情况下,它会出错。这通常不太可能,在当前的 JVM 中确实很少见。尽管如此,由于曾经存在一些知名的 bug,你有时会看到建议禁用逃逸分析。不过,这些建议可能已经不再适用,尽管像所有激进的编译器优化一样,禁用此功能有可能导致代码更稳定。如果你发现确实如此,简化相关代码是最佳方案:更简单的代码编译效果更好。(这确实是一个 bug,应该报告。)
快速总结
- 逃逸分析是编译器能够执行的最复杂的优化。这种优化经常导致微基准测试出现问题。
CPU 特定代码
我之前提到过,JIT 编译器的一个优点是它可以根据运行的位置为不同的处理器生成代码。当然,这假设 JVM 是基于新处理器的知识构建的。
这正是编译器为 Intel 芯片所做的。在 2011 年,Intel 为 Sandy Bridge(及之后的)芯片引入了高级向量扩展(AVX2)。这些指令的 JVM 支持很快跟进。然后在 2016 年,Intel 将其扩展到包括 AVX-512 指令;这些指令出现在 Knights Landing 和后续的芯片上。JDK 8 不支持这些指令,但 JDK 11 支持。
通常情况下,这个特性不是你需要担心的事情;JVM 将检测正在运行的 CPU,并选择适当的指令集。但是,像所有新特性一样,有时候会出现问题。
AVX-512 指令的支持首次出现在 JDK 9 中,尽管默认情况下未启用。在几次误启动之后,默认情况下启用了这些指令,然后又将其禁用。在 JDK 11 中,默认情况下启用了这些指令。然而,从 JDK 11.0.6 开始,默认情况下再次禁用了这些指令。因此,即使在 JDK 11 中,这仍然是一个正在进行中的工作。 (顺便说一句,这并不是 Java 才有的问题;许多程序都在努力正确支持 AVX-512 指令。)
因此,在某些较新的英特尔硬件上运行某些程序时,您可能会发现较早的指令集效果要好得多。那些从新指令集中受益的应用程序通常涉及比 Java 程序更多的科学计算。
这些指令集是通过 -XX:UseAVX=N 参数选择的,其中 N 如下所示:
0
不使用 AVX 指令。
1
使用 Intel AVX level 1 指令(适用于 Sandy Bridge 及更高版本处理器)。
2
使用 Intel AVX-512 指令(适用于 Knights Landing 及更高版本处理器)。
3
使用 Intel AVX level 2 指令(适用于 Haswell 及更高版本处理器)。
此标志的默认值取决于运行 JVM 的处理器;JVM 将检测 CPU 并选择支持的最高值。Java 8 不支持级别 3,因此在大多数处理器上您将看到使用的值为 2。在新的英特尔处理器上的 Java 11 中,默认情况下在 11.0.5 版本之前使用 3,在后续版本中使用 2。
这就是我在 第一章 中提到的其中一个原因,建议使用 Java 8 或 Java 11 的最新版本,因为这些最新版本中包含了重要的修复。如果必须在最新的英特尔处理器上使用较早的 Java 11 版本,请尝试设置 -XX:UseAVX=2 标志,这在许多情况下会提升性能。
谈到代码成熟度:为了完整起见,我将提到 -XX:UseSSE=*N* 标志支持 Intel 流式 SIMD 扩展(SSE)1 到 4。这些扩展适用于 Pentium 系列处理器。在 2010 年调整此标志有些合理,因为当时正在处理其所有的使用情况。今天,我们通常可以依赖该标志的稳健性。
分层编译的权衡
我已经多次提到当禁用分层编译时,JVM 的工作方式不同。考虑到它提供的性能优势,是否有理由关闭它呢?
其中一个原因可能是在内存受限的环境中运行。当然,您的 64 位机器可能有大量内存,但您可能在具有小内存限制的 Docker 容器中运行,或者在云虚拟机中运行,其内存不足。或者您可能在大型机器上运行数十个 JVM。在这些情况下,您可能希望减少应用程序的内存占用。
第 8 章 提供了关于此的一般建议,但在本节中我们将看看分层编译对代码缓存的影响。
表 4-3 显示了在我的系统上启动 NetBeans 时的结果,该系统有几十个项目将在启动时打开。
Table 4-3. 分层编译对代码缓存的影响
| 编译器模式 | 编译的类 | 已分配的代码缓存 | 启动时间 |
|---|---|---|---|
| +TieredCompilation | 22,733 | 46.5 MB | 50.1 秒 |
| -TieredCompilation | 5,609 | 10.7 MB | 68.5 秒 |
C1 编译器编译的类约为四倍,并且根据预测需要大约四倍的代码缓存内存。在本例中节省 34 MB 可能不会产生很大的差异。在编译 200,000 个类的程序中节省 300 MB 在某些平台上可能会有不同的选择。
禁用分层编译会带来什么损失?正如表格所示,我们确实需要更多时间来启动应用程序并加载所有项目类。但在长时间运行的程序中,您期望所有热点都会被编译吗?
在这种情况下,给定足够长的热身时间后,当禁用分层编译时执行速度应该是一样的。表 4-4 展示了我们的库存 REST 服务器在热身时间为 0、60 和 300 秒后的性能。
Table 4-4. 服务器应用程序的吞吐量(使用分层编译)
| 热身时间 | -XX:-TieredCompilation | -XX:+TieredCompilation |
|---|---|---|
| 0 秒 | 23.72 | 24.23 |
| 60 秒 | 23.73 | 24.26 |
| 300 秒 | 24.42 | 24.43 |
测量期为 60 秒,因此即使没有预热,编译器也有机会获得足够的信息来编译热点;因此,即使没有预热期,差异也很小。(此外,在服务器启动期间编译了大量代码。)请注意,在最后,分层编译仍能够略微领先(尽管这种优势可能不会引人注目)。我们在讨论编译阈值时已经讨论了这一点:在使用分层编译时,总会有一小部分方法是由 C1 编译器编译而不是由 C2 编译器编译的。
GraalVM
GraalVM 是一种新的虚拟机。它不仅可以运行 Java 代码,当然,还能运行许多其他语言的代码。这款通用虚拟机还能够运行 JavaScript、Python、Ruby、R 以及传统的 JVM 字节码(来自 Java 和其他编译为 JVM 字节码的语言,如 Scala、Kotlin 等)。Graal 有两个版本:完全开源的社区版(CE)和商业版(EE)。每个版本都有支持 Java 8 或 Java 11 的二进制文件。
GraalVM 对 JVM 性能有两个重要贡献。首先,一种附加技术使 GraalVM 能够生成完全的本地二进制文件;我们将在下一节中详细讨论这一点。
其次,GraalVM 可以以常规 JVM 的模式运行,但它包含了一个新的 C2 编译器的实现。这个编译器是用 Java 编写的(与传统的 C2 编译器不同,后者是用 C++ 编写的)。
传统的 JVM 包含 GraalVM JIT 的一个版本,具体取决于 JVM 构建的时间。这些 JIT 发布来自 GraalVM 的 CE 版本,比 EE 版本慢;与直接下载的 GraalVM 版本相比,它们通常也是过时的。
在 JVM 内部,使用 GraalVM 编译器被视为实验性质的,因此要启用它,您需要提供以下标志:-XX:+UnlockExperimentalVMOptions、-XX:+EnableJVMCI 和 -XX:+UseJVMCICompiler。所有这些标志的默认值都是 false。
表 4-5 显示了标准 Java 11 编译器、来自 EE 版本 19.2.1 的 Graal 编译器以及嵌入在 Java 11 和 13 中的 GraalVM 的性能。
表 4-5. Graal 编译器性能
| JVM/compiler | OPS |
|---|---|
| JDK 11/Standard C2 | 20.558 |
| JDK 11/Graal JIT | 14.733 |
| Graal 1.0.0b16 | 16.3 |
| Graal 19.2.1 | 26.7 |
| JDK 13/Standard C2 | 21.9 |
| JDK 13/Graal JIT | 26.4 |
这次我们再次测试了我们的 REST 服务器的性能(尽管硬件略有不同,所以基准 OPS 只有 20.5 OPS,而不是 24.4)。
需要注意的是这里的进展情况:JDK 11 使用的是一个相当早期的 Graal 编译器版本,因此该编译器的性能落后于 C2 编译器。虽然 Graal 编译器通过其早期访问版本有所改进,但即使是其最新的早期访问版本(1.0),速度仍然不及标准 VM。然而,2019 年末的 Graal 版本(作为生产版本 19.2.1 发布)大幅提升了性能。JDK 13 的早期访问版本采用了这些较新的构建,即使 C2 编译器自 JDK 11 以来只有轻微改进,Graal 编译器的性能也接近于相同。
预编译
我们在本章开始时讨论了即时编译器背后的哲学。尽管它有其优点,但代码在执行之前仍然需要热身时间。如果在我们的环境中,传统的编译模型更好:一个没有额外内存的嵌入式系统,或者一个在有机会热身之前就完成的程序呢?
在这一节中,我们将介绍两个解决方案来应对这种情况的实验性功能。提前编译是标准 JDK 11 的实验性功能,而生成完全本地二进制的能力是 Graal VM 的功能。
提前编译
提前(AOT)编译首次在 JDK 9 中仅适用于 Linux,但在 JDK 11 中,它适用于所有平台。从性能的角度来看,它仍然是一个正在进行中的工作,但本节将为您提供一个预览。¹
AOT 编译允许您提前(或全部)编译应用程序的一部分,然后运行它。这个编译后的代码变成了 JVM 在启动应用程序时使用的共享库。理论上,这意味着 JIT 不必参与,至少在启动应用程序时:您的代码应该最初至少与 C1 编译的代码一样运行,而无需等待该代码被编译。
在实践中,情况有所不同:应用程序的启动时间受到共享库大小的影响(因此,将该共享库加载到 JVM 中所需的时间)。这意味着像“Hello, world”这样的简单应用程序在使用 AOT 编译时不会运行得更快(实际上,根据对共享库进行预编译的选择,可能会运行得更慢)。AOT 编译的目标是针对启动时间相对较长的 REST 服务器之类的应用程序。这样,加载共享库的时间被长启动时间抵消,并且 AOT 产生了好处。但请记住,AOT 编译是一个实验性功能,随着技术的发展,较小的程序可能会从中受益。
要使用 AOT 编译,您可以使用jaotc工具来生成包含所选编译类的共享库。然后,通过运行时参数将该共享库加载到 JVM 中。
jaotc工具有几个选项,但要生成最佳的库,最好的方式是这样的:
$ jaotc --compile-commands=/tmp/methods.txt \
--output JavaBaseFilteredMethods.so \
--compile-for-tiered \
--module java.base
这个命令将使用一组编译命令在给定的输出文件中生成java.base模块的编译版本。您可以选择对模块进行 AOT 编译,就像我们这里所做的那样,或者对一组类进行编译。
加载共享库的时间取决于其大小,这取决于库中方法的数量。您还可以加载多个共享库,预先编译不同的代码部分,这可能更容易管理,但性能相同,因此我们将专注于一个单独的库。
虽然您可能会尝试预编译所有内容,但如果您仅明智地预编译代码的子集,您将获得更好的性能。这就是为什么建议仅编译java.base模块的原因。
编译命令(在此示例中的*/tmp/methods.txt*文件中)还用于限制编译到共享库中的数据。该文件包含看起来像这样的行:
compileOnly java.net.URI.getHost()Ljava/lang/String;
此行告诉jaotc在编译java.net.URI类时,应仅包括getHost()方法。我们可以有其他行引用该类的其他方法,以便包括它们的编译;最终,文件中列出的方法将作为共享库的一部分包括进去。
要创建编译命令列表,我们需要应用程序实际使用的每种方法的列表。为此,我们这样运行应用程序:
$ java -XX:+UnlockDiagnosticVMOptions -XX:+LogTouchedMethods \
-XX:+PrintTouchedMethodsAtExit <other arguments>
当程序退出时,它将以如下格式打印程序中使用的每个方法的行:
java/net/URI.getHost:()Ljava/lang/String;
要生成methods.txt文件,请保存这些行,每行前面加上compileOnly指令,并删除方法参数之前的冒号。
被jaotc预编译的类将使用 C1 编译器的一种形式,因此在长时间运行的程序中,它们将无法进行最优化编译。因此,我们最终需要的选项是--compile-for-tiered。该选项安排共享库使其方法仍然有资格由 C2 编译器编译。
如果您正在为一个短期运行的程序使用 AOT 编译,可以不带这个参数,但请记住目标是一个服务器应用程序。如果我们不允许预编译方法有资格进行 C2 编译,服务器的热性能将比最终可能的性能慢。
或许并不奇怪,如果您使用启用分层编译的库运行应用程序,并使用-XX:+PrintCompilation标志,您将看到我们之前观察到的相同代码替换技术:AOT 编译将出现在输出中的另一层,并且您将看到 AOT 方法变为非入口并在 JIT 编译它们时替换。
一旦库创建完成,您可以像这样与应用程序一起使用它:
$ java -XX:AOTLibrary=/path/to/JavaBaseFilteredMethods.so <other args>
如果您希望确保库正在使用,请在 JVM 参数中包含-XX:+PrintAOT标志;该标志默认为false。像-XX:+PrintCompilation标志一样,-XX:+PrintAOT标志将在 JVM 使用预编译方法时生成输出。典型的行如下所示:
373 105 aot[ 1] java.util.HashSet.<init>(I)V
这里的第一列是自程序启动以来的毫秒数,所以直到HashSet类的构造函数从共享库加载并开始执行为止,花费了 373 毫秒。第二列是分配给该方法的 ID,第三列告诉我们该方法从哪个库加载的。索引(本例中为 1)也由此标志打印:
18 1 loaded /path/to/JavaBaseFilteredMethods.so aot library
JavaBaseFilteredMethods.so 是此示例中加载的第一个(也是唯一的)库,因此其索引为 1(第二列),随后对具有该索引的aot的引用指的是此库。
GraalVM 本地编译
AOT 编译对于相对较大的程序是有益的,但对于小型、快速运行的程序则没有帮助(甚至可能有害)。这是因为它仍然是一个实验性功能,并且因为其架构需要 JVM 加载共享库。
另一方面,GraalVM 可以生成无需 JVM 运行的完整本地可执行文件。这些可执行文件非常适合短期程序。如果你运行了示例,你可能会注意到某些事物(如被忽略的错误)中对 GraalVM 类的引用:AOT 编译使用 GraalVM 作为其基础。这是 GraalVM 的早期采用者功能;它可以在具有适当许可证的情况下用于生产,但不受保证。
GraalVM 生成的二进制文件启动速度相当快,特别是与在 JVM 中运行的程序相比。然而,在此模式下,GraalVM 不像 C2 编译器那样积极地优化代码,因此,对于足够长时间运行的应用程序,传统的 JVM 最终会胜出。与 AOT 编译不同,GraalVM 本地二进制文件不会在执行期间使用 C2 编译器编译类。
类似地,由 GraalVM 生成的本地程序的内存占用空间开始时比传统的 JVM 显着较小。然而,当程序运行并扩展堆时,这种内存优势会逐渐消失。
也存在一些限制,限制了可以在编译成本地代码的程序中使用的 Java 特性。这些限制包括以下内容:
-
动态类加载(例如,通过调用
Class.forName())。 -
终结器。
-
Java 安全管理器。
-
JMX 和 JVMTI(包括 JVMTI 分析)。
-
使用反射通常需要特殊的编码或配置。
-
使用动态代理通常需要特殊的配置。
-
使用 JNI 需要特殊的编码或配置。
通过使用 GraalVM 项目的演示程序,我们可以看到所有这些内容在实际中的应用,该程序递归地计算目录中的文件数。随着要计数的文件数量增加,由 GraalVM 生成的本地程序非常小且速度快,但随着更多工作的完成和 JIT 的启动,传统的 JVM 编译器会生成更好的代码优化,并且速度更快,正如我们在 表 4-6 中看到的。
表 4-6. 使用本地和 JIT 编译代码计算文件所需的时间
| 文件数量 | Java 11.0.5 | 本地应用程序 |
|---|---|---|
| 7 | 217 ms (36K) | 4 ms (3K) |
| 271 | 279 ms (37K) | 20 ms (6K) |
| 169,000 | 2.3 s (171K) | 2.1 s (249K) |
| 1.3 million | 19.2 s (212K) | 25.4 s (269K) |
这里的时间是计算文件数的时间;运行完成时的总占用空间(在括号中测量)列在括号中。
当然,GraalVM 本身正在快速发展,其本地代码中的优化也有望随着时间的推移而改善。
摘要
本章包含了关于编译器工作原理的大量背景知识。这样做是为了能够理解第一章中关于小方法和简单代码的一些总体建议,以及第二章中描述的编译器对微基准测试的影响。特别是:
-
不要害怕小方法——特别是 getter 和 setter,因为它们很容易被内联。如果您觉得方法开销可能很大,理论上您是正确的(我们展示了去除内联显著降低性能)。但在实践中并非如此,因为编译器解决了这个问题。
-
需要编译的代码位于编译队列中。队列中的代码越多,程序达到最佳性能所需的时间就越长。
-
虽然您可以(而且应该)调整代码缓存的大小,但它仍然是有限资源。
-
代码越简单,可以执行的优化就越多。性能分析反馈和逃逸分析可以产生更快的代码,但复杂的循环结构和大方法限制了它们的有效性。
最后,如果您分析您的代码并发现一些意外出现在性能分析榜首的方法——这些方法您认为不应该在那里——您可以使用这里的信息来查看编译器正在执行的操作,并确保它能够处理代码编写方式。
¹ AOT 编译的一个好处是更快的启动速度,但应用程序类数据共享在启动性能方面至少目前更有利,并且是一个完全支持的特性;有关更多详情,请参阅“类数据共享”。