Java-高性能指南-全-

Java 高性能指南(全)

原文:zh.annas-archive.org/md5/075370e1159888d7fd67fe4f209e6d1e

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

当 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 二进制文件则带有开源许可证。对于我们的目的,我们将认为这两个版本是同一件事情,我们将称之为 JDKJava 平台。¹

这些版本已经经历了各种错误修复版本。在我撰写本文时,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 平台和其他平台中实现特定类的方式之间可能仍存在细微差异。这些类必须在功能上等效,但实际实现可能会有所变化。幸运的是,这种情况并不经常发生,而且不太可能对性能造成重大影响。

在本书的剩余部分中,术语JavaJVM应理解为特指 Oracle HotSpot 实现。严格来说,说“JVM 在首次执行时不会编译代码”是错误的;一些 Java 实现在首次执行时确实会编译代码。但使用这种简写比继续写(和阅读)“Oracle HotSpot JVM…”要简单得多。

JVM 调整标志

除了一些例外,JVM 接受两种类型的标志:布尔标志和需要参数的标志。

布尔标志使用以下语法:-XX:+FlagName 启用标志,-XX:-FlagName 禁用标志。

需要参数的标志使用以下语法:-XX:FlagName=something,表示将 FlagName 的值设置为 something。在文本中,标志的值通常用表示任意值的 something 表示。例如,-XX:NewRatio=N 意味着 NewRatio 标志可以设置为任意值 NN 的含义是讨论的重点)。

每个标志的默认值在引入标志时讨论。该默认值通常基于 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)都可以建模为像这样的一系列步骤,其中数据以由该框(模块、子系统等)的效率决定的速率流出。数据以前一个框的输出率决定的速率流入子系统。

jp2e 0201

图 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 的一个特性,解决了微基准测试中的一个问题:如果不使用操作的值,编译器可以自由地优化掉该操作。因此,我们通过将它们传递给 Blackholeconsume() 方法来确保值被使用。

在这个例子中,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(系统账户报告)及其组成部分,如 vmstatiostatprstat 等等。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工具能够自动确定接口的带宽。

类似typeperfnetstat的工具会报告读取和写入的数据量,但要想计算网络利用率,必须确定接口的带宽并在您自己的脚本中执行计算。请记住,带宽以每秒位数(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 容器内运行,则非图形化工具(即除了 jconsolejvisualvm 外的工具)可以通过 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 使用jcmdVM.flags -all选项打印的内容相同)。

这些命令的标志数据以两种方式之一打印出来。包含输出的第一行的冒号表示正在使用标志的非默认值。这可能是因为以下原因:

  • 标志的值是在命令行上直接指定的。

  • 某些其他选项间接地改变了该选项。

  • JVM 按人体工程学计算了默认值。

第二行(没有冒号)表示该值是此版本 JVM 的默认值。某些标志的默认值在不同平台上可能不同,这在输出的最后一列中显示。product表示该标志的默认设置在所有平台上是统一的;pd product表示该标志的默认设置是依赖于平台的。

最后一列的其他可能值包括manageable(标志的值可以在运行时动态更改)和C2 diagnostic(该标志为编译器工程师提供诊断输出,以了解编译器的功能)。

另一种查看正在运行的应用程序的信息的方法是使用jinfojinfo的优点在于它允许在程序执行过程中更改某些标志值。

这里是如何检索进程中所有标志的值:

% 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 用于检查(在某些情况下更改)单个标志非常有用。

线程信息

jconsolejvisualvm 实时显示应用程序中运行的线程数量信息。查看运行线程的堆栈可以帮助确定它们是否被阻塞。可以通过 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 ...

更多关于监控线程堆栈的细节,请参阅 第九章。

类信息

可以通过 jconsolejstat 获取应用程序使用的类数量信息。jstat 还可以提供有关类编译的信息。

更多关于应用程序类使用情况的细节,请参阅 第十二章,关于监视类编译的细节,请参阅 第四章。

实时 GC 分析

几乎每个监控工具都会报告有关 GC 活动的信息。jconsole 显示堆使用情况的实时图表;jcmd 允许执行 GC 操作;jmap 可以打印堆摘要或永久代信息,或者创建堆转储;而 jstat 提供了许多关于垃圾收集器活动的视图。

查看 第五章 以获取这些程序监控 GC 活动的示例。

堆转储后处理

堆转储可以通过jvisualvm GUI 或从命令行使用jcmdjmap来捕获。堆转储是堆的快照,可以用各种工具分析,包括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 Controljmc),尽管存在其他工具,并且您可以使用工具包编写自己的分析工具。在全面转向全开源 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)控制台。

Java Mission Control 窗口。

图 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)。

来自 JFR 的基本数据显示

图 3-10. Java Flight Recorder 概述

此显示类似于 Java Mission Control 在进行基本监控时显示的内容。在显示 CPU 和堆使用率的仪表之上是事件时间轴(由一系列垂直条表示)。时间轴允许我们放大到特定感兴趣的区域;虽然在此示例中,录制持续了 6 分钟,但我放大到了录制末尾附近的 38 秒间隔。

此 CPU 使用率图更清楚地显示了发生的情况:REST 服务器位于图表底部(平均使用率约为 20%),整机的 CPU 使用率为 38%。底部还有其他选项卡,允许我们探索有关系统属性以及 JFR 记录制作方式的信息。窗口左侧的图标更加有趣:这些图标提供了对应用程序行为的可见性。

JFR 内存视图

此处收集的信息非常广泛。图 3-11 显示了内存视图的一个面板。

Java Flight Recorder 内存面板显示

图 3-11. Java Flight Recorder 内存面板

该图显示,由于年轻代被清除(同时我们看到堆在此期间总体上是增长的:从约 340 MB 开始,最终约为 2 GB),内存波动相当规律。左下角面板显示了记录期间发生的所有收集事件,包括它们的持续时间和收集类型(在此示例中始终是 ParallelScavenge)。当选择其中一个事件时,右下角面板进一步详细显示了该收集的所有特定阶段及其持续时间。

此页面上的各个标签提供了丰富的其他信息:清除引用对象的时间和数量,是否存在并发收集器的提升或疏散失败,GC 算法本身的配置(包括代的大小和幸存者空间配置),甚至是分配的特定类型对象的信息。当您阅读第五章和第六章时,请记住这个工具如何诊断讨论的问题。如果您需要理解为什么 G1 GC 收集器退出并执行了全局 GC(是由于提升失败吗?),JVM 如何调整终身阈值,或者关于 GC 行为如何以及为什么的任何其他数据,JFR 都能告诉您。

JFR 代码视图

Java Mission Control 中的 Code 页面显示了来自记录的基本分析信息(图 3-12)。

显示应用程序执行时间的 JFR。

图 3-12. Java Flight Recorder Code 面板

此页面上的第一个标签显示了按包名称聚合的信息,这是许多分析工具中找不到的有趣功能。在底部,其他标签呈现了受分析代码的热点方法和调用树的传统概要视图。

与其他分析工具不同,JFR 提供了进入代码的其他可见性模式。异常标签提供了对应用程序异常处理的视图(第 12 章讨论为什么过度异常处理对性能不利)。其他标签提供了关于编译器正在执行的内容的信息,包括对代码缓存的视图(参见 第 4 章)。

另一方面,请注意,这里的包在我们之前查看的分析中并没有真正显示出来;反之,我们之前看到的热点在这里也没有出现。由于它设计为具有非常低的开销,JFR 的配置(至少在默认配置中)的性能抽样相当低,因此与更入侵性的抽样相比,这些分析结果并不那么精确。

这类似的显示还有其他,比如线程、I/O 和系统事件,但大多数情况下,这些显示只是提供了 JFR 记录中实际事件的良好视图。

JFR 事件概述

JFR 生成一系列事件作为记录保存。迄今为止看到的显示提供了这些事件的视图,但查看事件本身面板是最强大的方式,如图 3-13 所示。

显示来自 JFR 的事件。

图 3-13. Java Flight Recorder Event 面板

此窗口左侧面板可筛选要显示的事件;这里选择了应用级和 JVM 级事件。请注意,在录制时,仅包括特定类型的事件:在此时,我们正在进行后处理筛选(下一节将展示如何筛选录制中包含的事件)。

在这个示例中的 34 秒间隔内,应用程序从 JVM 生成了 878 个事件和从 JDK 库生成了 32 个事件,并且在此期间生成的事件类型显示在窗口底部附近。当我们使用分析器查看这个示例时,我们看到为什么该示例的线程停靠和监视器等待事件会很高;这些可以忽略(并且这里在左侧面板中过滤掉了线程停靠事件)。那么其他事件呢?

在 34 秒的时间段内,应用程序中的多个线程花费了 34 秒来读取套接字。这个数字听起来不好,特别是因为它只有在套接字读取时间超过 10 毫秒时才会在 JFR 中显示出来。我们需要进一步研究这一点,可以通过访问图 3-14 中显示的日志选项卡来完成。

来自 JFR 的事件日志。

图 3-14. Java 飞行记录器事件日志面板

值得注意的是要查看与这些事件相关的跟踪,但事实证明,有几个线程使用阻塞 I/O 读取预期定期到达的管理请求。在这些请求之间——长时间的周期内——线程将阻塞在 read() 方法上。所以这里的读取时间是可以接受的:就像使用分析器时一样,你需要确定大量阻塞 I/O 的线程是预期的还是表示性能问题。

这留下了监视器阻塞事件。正如第 9 章中讨论的那样,对于锁的争用经历了两个级别:首先线程在等待锁时会自旋,然后它会使用(在称为锁膨胀的过程中)一些 CPU 和操作系统特定的代码来等待锁。标准的分析器可以提供关于这种情况的线索,因为自旋时间包含在方法的 CPU 时间中。本地分析器可以提供关于受膨胀影响的锁的信息,但这可能是有或无的。然而,JVM 可以直接向 JFR 提供所有这些数据。

在第 9 章中展示了使用锁可见性的示例,但关于 JFR 事件的一般结论是,由于它们直接来自 JVM,它们提供了其他任何工具都无法提供的应用程序可见性。在 Java 11 中,大约可以通过 JFR 监控 131 种事件类型。确切的事件数量和类型会因版本而略有不同,但以下列表详细说明了一些更有用的事件类型。

下面列表中的每个事件类型显示两个项目符号。事件可以收集基本信息,这些信息可以使用像jconsolejcmd这样的其他工具收集;这类信息在第一个项目符号中描述。第二个项目符号描述事件提供的难以在 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*

开始录制前的时间量(例如,30s1h)。

duration=*time*

进行录制的时间量。

filename=*path*

要将录制写入的文件名。

compress=*<true|false>*

是否(使用 gzip)压缩录制内容;默认为false

maxage=*time*

保持循环缓冲区中记录数据的最长时间。

maxsize=*size*

录制的循环缓冲区的最大大小(例如,1024K1M)。

-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 中启动模板管理器的按钮。模板存储在两个位置:在 \(HOME/.jmc/<release>* 目录下(用户本地)和在 *\)JAVA_HOME/jre/lib/jfr 目录下(JVM 全局)。模板管理器允许您选择全局模板(模板将显示“在服务器上”),选择本地模板或定义新模板。要定义模板,请循环遍历可用事件,并根据需要启用(或禁用)它们,可选择设置事件启动的阈值。

图 3-16 显示,文件读取事件启用时设置阈值为 15 ms:超过此阈值的文件读取将触发事件。该事件还已配置生成文件读取事件的堆栈跟踪。这会增加开销,这也是为什么为事件获取堆栈跟踪是可配置选项的原因。

启用 JFR 事件的向导。

图 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 这样的语言是解释执行的。只要机器有正确的解释器(即名为phpperl的程序),就可以在任何 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 entrantmade 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-classnameprocessRequest()方法如预期般带有异常处理程序。

最后,回顾一下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 编译的一个好处是更快的启动速度,但应用程序类数据共享在启动性能方面至少目前更有利,并且是一个完全支持的特性;有关更多详情,请参阅“类数据共享”。

第五章:垃圾收集简介

本章介绍了 JVM 中垃圾收集的基础知识。除了重写代码外,调整垃圾收集器是提高 Java 应用程序性能最重要的事情。

因为 Java 应用程序的性能严重依赖垃圾收集技术,所以有相当多的收集器可供选择。OpenJDK 有三个适合生产环境的收集器,另一个在 JDK 11 中已经弃用但在 JDK 8 中仍然非常流行,并且一些实验性的收集器将来会(理想情况下)成为生产就绪版本的一部分。其他 Java 实现如 Open J9 或 Azul JVM 有它们自己的收集器。

所有这些收集器的性能特征都是非常不同的;我们只会关注 OpenJDK 提供的那些。每个收集器在下一章中都会进行深入讲解。然而,它们共享基本概念,因此本章提供了收集器操作的基本概述。

垃圾收集概述

在 Java 编程中最具吸引力的功能之一是开发人员无需显式管理对象的生命周期:对象在需要时创建,在对象不再使用时,JVM 会自动释放对象。如果像我一样,你花费了大量时间优化 Java 程序的内存使用,那么整个方案可能看起来像是一个弱点而不是一个功能(我花在垃圾收集上的时间似乎支持了这种观点)。当然,这可以被认为是一种两面性的祝福,但我仍然记得在其他语言中追踪空指针和悬空指针的困难。我强烈认为调整垃圾收集器比追踪指针错误更容易(也更省时)。

在基本水平上,GC 的工作包括找出正在使用的对象并释放剩余对象(即那些未被使用的对象)所关联的内存。有时候,这被描述为找出不再具有任何引用的对象(暗示引用是通过计数来跟踪的)。然而,仅仅依靠引用计数是不够的。假设有一个对象链表,列表中的每个对象(除了头部)都会被列表中的另一个对象指向,但是如果没有任何东西指向列表的头部,整个列表就不再被使用并且可以被释放。如果列表是循环的(例如,列表的尾部指向头部),列表中的每个对象都有一个引用指向它,即使列表中没有任何对象实际可用,因为没有对象引用列表本身。

所以引用不能通过计数动态跟踪;相反,JVM 必须定期搜索堆中未使用的对象。它通过从 GC 根对象开始搜索来实现这一点,GC 根对象是从堆外部可访问的对象。这主要包括线程堆栈和系统类。这些对象总是可达的,因此 GC 算法扫描所有通过一个根对象可达的对象。通过 GC 根可达的对象是活动对象;其余的不可达对象是垃圾(即使它们保持对活动对象或彼此的引用)。

当 GC 算法找到未使用的对象时,JVM 可以释放这些对象占用的内存,并用于分配其他对象。然而,简单地跟踪这些空闲内存并将其用于将来的分配通常是不够的;在某些时刻,必须压缩内存以防止内存碎片化。

考虑一个程序的情况,它分配了一个 1,000 字节的数组,然后是一个 24 字节的数组,并在循环中重复这个过程。当这个过程填满堆时,它会看起来像图 5-1 中的顶行:堆已满,并且这些数组大小的分配交错进行。

当堆满时,JVM 将释放未使用的数组。假设所有的 24 字节数组不再使用,而 1,000 字节数组仍然全部使用:这将产生图 5-1 中的第二行。堆内有空闲区域,但不能分配大于 24 字节的任何内容,除非 JVM 将所有 1,000 字节数组移动到连续位置,使所有空闲内存留在一个区域,以便根据需要进行分配(图 5-1 中的第三行)。

实现稍微详细些,但 GC 的性能主要受这些基本操作的影响:查找未使用的对象,使它们的内存可用,并压缩堆。不同的收集器对这些操作采取不同的方法,尤其是压缩:一些算法推迟压缩直到绝对必要时,一些一次性压缩堆的整个部分,还有一些通过逐步重定位少量内存来压缩堆。这些不同的方法是不同算法具有不同性能特征的原因。

jp2e 0501

图 5-1. 在收集期间理想化的 GC 堆

当垃圾收集器运行时,如果没有应用程序线程在运行,则执行这些操作会更简单。Java 程序通常是重度多线程的,而垃圾收集器本身通常也会运行多个线程。本讨论考虑了两个逻辑线程组:执行应用逻辑的线程(通常称为变异线程,因为它们正在变异对象作为应用逻辑的一部分)和执行 GC 的线程。当 GC 线程跟踪对象引用或在内存中移动对象时,它们必须确保应用程序线程不在使用这些对象。当 GC 移动对象时尤其如此:对象在该操作期间的内存位置会发生变化,因此在此期间不应用程序线程可以访问该对象。

当所有应用程序线程停止时的暂停称为停止-世界暂停。这些暂停通常对应用程序的性能产生最大影响,减少这些暂停是调整 GC 时的一个重要考虑因素。

分代垃圾收集器

尽管细节略有不同,但大多数垃圾收集器的工作方式是将堆分成代。这些被称为老(或终身)代年轻代。年轻代进一步分为称为伊甸园幸存者空间的部分(尽管有时,伊甸园被错误地用来指代整个年轻代)。

单独分代的原因是许多对象仅被使用很短的时间。例如,在股票价格计算中的循环中,该循环对平均价格的价格差的平方进行求和(标准差计算的一部分):

sum = new BigDecimal(0);
for (StockPrice sp : prices.values()) {
    BigDecimal diff = sp.getClosingPrice().subtract(averagePrice);
    diff = diff.multiply(diff);
    sum = sum.add(diff);
}

像许多 Java 类一样,BigDecimal类是不可变的:该对象表示特定数字,不能更改。对对象执行算术运算时,会创建一个新对象(通常情况下,之前的对象及其先前的值随后会被丢弃)。当这个简单循环被执行一年的股票价格(大约 250 次迭代)时,就在这个循环中创建了 750 个用于存储中间值的BigDecimal对象。这些对象在循环的下一次迭代中被丢弃。在add()和其他方法中,JDK 库代码甚至创建了更多的中间BigDecimal(和其他)对象。最终,在这段小代码中会快速创建和丢弃很多对象。

这种操作在 Java 中很常见,因此垃圾收集器设计得充分利用了许多(有时是大多数)对象仅用于临时的事实。这就是代际设计的用武之地。对象首先分配在年轻代中,这是整个堆的子集。当年轻代填满时,垃圾收集器会停止所有应用线程并清空年轻代。不再使用的对象被丢弃,仍在使用的对象则被移动到其他地方。这个操作称为minor GCyoung GC

这种设计有两个性能优势。首先,因为年轻代只是整个堆的一部分,处理速度比整个堆快得多。应用线程停止的时间要比一次性处理整个堆时短得多。尽管这意味着应用线程会更频繁地停止,而不是等到整个堆填满再执行 GC,这个权衡将在本章后面更详细地探讨。但是目前来看,即使更频繁,有更短的暂停时间几乎总是一个巨大的优势。

第二个优势来自对象在年轻代分配的方式。对象首先分配在 Eden 区(它占据了年轻代的绝大部分)。当进行垃圾收集时,年轻代被清空:Eden 区中的所有对象要么被移动,要么被丢弃:不再使用的对象可以被丢弃,而仍在使用的对象则移动到其中一个幸存区或老年代。由于所有幸存下来的对象都会被移动,因此当年轻代被收集时,它会自动压缩:在收集结束时,Eden 区和一个幸存区为空,而仍留在年轻代中的对象则在另一个幸存区内被紧凑地排列。

常见的 GC 算法在收集年轻代时会有停止应用线程的暂停。

当对象被移动到老年代时,最终老年代也会填满,JVM 需要找到老年代中不再使用的对象并丢弃它们。这是 GC 算法差异最大的地方。简单的算法会停止所有应用线程,找到未使用的对象,释放它们的内存,然后压缩堆。这个过程称为full GC,通常会导致应用线程相对较长的暂停。

另一方面,当应用程序线程正在运行时,可以找到未使用的对象,尽管这可能会更加计算复杂。因为扫描未使用对象的阶段可以在不停止应用程序线程的情况下进行,所以这些算法被称为并发收集器。它们也被称为低暂停(有时不正确地称为无暂停)收集器,因为它们最大程度地减少了停止所有应用程序线程的需求。并发收集器还采用不同的方法来压缩老年代。

使用并发收集器时,应用程序通常会经历更少(且更短)的暂停。最大的权衡是应用程序总体上将使用更多的 CPU。并发收集器也可能更难调整以获得最佳性能(尽管在 JDK 11 中,调整像 G1 GC 这样的并发收集器要比以前的版本更容易,这反映了自并发收集器首次引入以来所取得的工程进展)。

在考虑哪种垃圾收集器适合您的情况时,请考虑必须满足的总体性能目标。在每种情况下都存在权衡。在度量单个请求的响应时间的应用程序(如 REST 服务器)中,请考虑以下几点:

  • 单个请求将受暂停时间的影响,尤其是全局 GC 的长暂停时间。如果最小化暂停对响应时间的影响是目标,则并发收集器可能更合适。

  • 如果平均响应时间比异常值(即 90th%)响应时间更重要,那么非并发收集器可能会产生更好的结果。

  • 使用并发收集器避免长暂停时间的好处是需要额外的 CPU 使用。如果您的计算机缺乏并发收集器所需的空闲 CPU 循环,则非并发收集器可能是更好的选择。

同样,在批处理应用程序中选择垃圾收集器的选择受以下权衡的指导:

  • 如果有足够的 CPU 可用,使用并发收集器来避免全局 GC 暂停将有助于更快地完成任务。

  • 如果 CPU 受限,那么并发收集器的额外 CPU 消耗将导致批处理作业需要更多时间。

快速总结

  • GC 算法通常将堆分为老年代和年轻代。

  • GC 算法通常采用停止-世界方法来清除来自年轻代的对象,这通常是一个快速操作。

  • 最小化在老年代执行 GC 的影响是暂停时间和 CPU 使用之间的权衡。

GC 算法

OpenJDK 12 提供了各种 GC 算法,在早期版本中的支持程度各不相同。Table 5-1 列出了这些算法及其在 OpenJDK 和 Oracle Java 发布版中的状态。

表 5-1. 各种 GC 算法的支持级别^(a)

GC 算法 JDK 8 中的支持 JDK 11 中的支持 JDK 12 中的支持
串行 GC S S S
吞吐量(并行)GC S S S
G1 GC S S S
并发标记-清除(CMS) S D D
ZGC - E E
Shenandoah E2 E2 E2
Epsilon GC - E E
^(a) (S: 完全支持 D: 已弃用 E: 实验性 E2: 实验性;在 OpenJDK 版本中但不在 Oracle 版本中)

接下来是每种算法的简要描述;第六章提供了有关单独调优它们的更多详细信息。

串行垃圾收集器

串行垃圾收集器是所有收集器中最简单的一种。如果应用程序在客户端类机器上运行(Windows 上的 32 位 JVM)或单处理器机器上运行,则默认使用此收集器。曾经,串行收集器似乎注定要被丢弃,但容器化技术改变了这一点:具有一个核心(甚至是看起来像两个 CPU 的超线程核心)的虚拟机和 Docker 容器使得这种算法再次变得更为重要。

串行收集器使用单个线程处理堆。在处理堆时(无论是进行部分 GC 还是完全 GC),它将停止所有应用程序线程。在完全 GC 期间,它将完全压缩老年代。

使用-XX:+UseSerialGC标志可以启用串行收集器(尽管通常在可能使用它的情况下它是默认的)。请注意,与大多数 JVM 标志不同,串行收集器不会通过将加号改为减号(即指定-XX:-UseSerialGC)来禁用。在串行收集器是默认收集器的系统上,可以通过指定不同的 GC 算法来禁用它。

吞吐量收集器

在 JDK 8 中,吞吐量收集器是任何具有两个或更多 CPU 的 64 位机器的默认收集器。吞吐量收集器使用多个线程来收集年轻代,这使得部分 GC 比使用串行收集器更快。它也使用多个线程来处理老年代。由于使用多个线程,吞吐量收集器通常被称为并行收集器

吞吐量收集器在部分 GC 和完全 GC 期间停止所有应用程序线程,并在完全 GC 期间完全压缩老年代。由于在大多数使用场景中,它是默认的收集器,因此不需要显式启用。如有必要,在需要时可以使用标志-XX:+UseParallelGC来启用它。

请注意,JVM 的旧版本允许在年轻代和老年代分别启用并行收集,因此您可能会看到关于标志-XX:+UseParallelOldGC的引用。尽管这个标志已经过时(虽然它仍然有效,并且出于某种原因,如果您真的想要,您可以禁用此标志以仅并行收集年轻代),但是可以禁用它以仅在年轻代并行收集。

G1 GC 收集器

G1 GC(或垃圾优先垃圾收集器)使用并发收集策略来在尽可能小的暂停时间内收集堆。对于具有两个或更多 CPU 的 64 位 JVM,它是 JDK 11 及更高版本的默认收集器。

G1 GC 将堆划分为多个区域,但仍然将堆视为具有两个代。其中一些区域组成年轻代,年轻代仍然通过停止所有应用程序线程并将所有存活对象移动到老年代或存活区域来进行收集。(这是使用多个线程进行的。)

在 G1 GC 中,老年代由后台线程处理,这些线程不需要停止应用程序线程来执行大部分工作。因为老年代被划分为区域,所以 G1 GC 可以通过从一个区域复制到另一个区域来清理老年代的对象,这意味着它(至少部分地)在正常处理中压缩堆。这有助于保持 G1 GC 堆不会变得碎片化,尽管这仍然可能发生。

避免完全 GC 周期的折衷是 CPU 时间:G1 GC 使用的(多个)后台线程在应用程序线程运行时必须有可用的 CPU 周期来处理老年代。

通过指定标志 -XX:+UseG1GC 启用 G1 GC。在 JDK 11 中,它通常是默认的,在 JDK 8 中也可以使用——特别是在 JDK 8 的后期版本中,它包含了从较新版本中后移植的许多重要的错误修复和性能增强。但是,当我们深入探讨 G1 GC 时,您会发现 JDK 8 中缺少的一个主要性能特性,这可能使其不适合该版本。

CMS 收集器

CMS 收集器 是第一个并发收集器。与其他算法一样,CMS 在执行次要 GC 时会停止所有应用程序线程,并使用多个线程执行。

CMS 在 JDK 11 及更高版本中已正式弃用,并且在 JDK 8 中不建议使用。从实际的角度来看,CMS 的主要缺陷是它在后台处理过程中没有一种方法来压缩堆。如果堆变得碎片化(这在某些时候很可能发生),CMS 必须停止所有应用程序线程并压缩堆,这违背了并发收集器的初衷。鉴于这一点和 G1 GC 的出现,CMS 不再推荐使用。

CMS 是通过指定标志 -XX:+UseConcMarkSweepGC 启用的,默认情况下为 false。历史上,CMS 还需要设置 -XX:+UseParNewGC 标志(否则,年轻代将由单个线程收集),尽管这已经过时。

实验性收集器

垃圾收集在 JVM 工程师中仍然是一个富有成效的领域,而最新版本的 Java 提供了前面提到的三种实验性算法。在下一章中我将详细介绍它们;现在,让我们继续看看在生产环境中选择三种支持的收集器之间的差异。

快速总结

  • 支持的 GC 算法采用不同的方法来最小化 GC 对应用程序的影响。

  • 当只有一个 CPU 可用且额外的 GC 线程会干扰应用程序时,串行收集器是合理的(并且是默认的)。

  • 吞吐量收集器是 JDK 8 的默认选项;它最大化应用程序的总吞吐量,但可能会使个别操作出现长时间的暂停。

  • G1 GC 是 JDK 11 及更高版本的默认选项;它在应用程序线程运行时并发地收集老年代,有可能避免全局垃圾回收。其设计使得它比 CMS 更不太可能经历全局垃圾回收。

  • CMS(Concurrent Mark-Sweep)垃圾收集器可以在应用程序线程运行时并发地收集老年代。如果有足够的 CPU 可用于后台处理,这可以避免应用程序的全局垃圾回收周期。它已被 G1 GC 所取代。

选择 GC 算法

选择 GC 算法部分取决于可用的硬件,部分取决于应用程序的外观,部分取决于应用程序的性能目标。在 JDK 11 中,G1 GC 通常是更好的选择;在 JDK 8 中,选择将取决于你的应用程序。

我们将从一个经验法则开始,即 G1 GC 是更好的选择,但每个规则都有例外。在垃圾回收的情况下,这些例外涉及到应用程序相对于可用硬件需要的 CPU 循环数,以及后台 G1 GC 线程需要执行的处理量。如果你使用 JDK 8,G1 GC 避免全局垃圾回收的能力也将是一个关键考虑因素。当 G1 GC 不是更好的选择时,吞吐量和串行收集器之间的选择取决于机器上的 CPU 数量。

何时使用(以及何时不使用)串行收集器

在只有一个 CPU 的机器上,JVM 默认使用串行收集器。这包括只有一个 CPU 的虚拟机,以及被限制为一个 CPU 的 Docker 容器。如果你在 JDK 8 的早期版本中将 Docker 容器限制为一个 CPU,它仍将默认使用吞吐量收集器。在这种环境中,你应该考虑使用串行收集器(即使你需要手动设置)。

在这些环境中,串行收集器通常是一个不错的选择,但有时 G1 GC 会产生更好的结果。这个例子也是理解选择 GC 算法所涉及的一般权衡的一个很好的起点。

G1 GC 和其他收集器之间的权衡包括为 G1 GC 后台线程提供可用的 CPU 循环,所以我们先从一个 CPU 密集型的批处理作业开始。在批处理作业中,CPU 将长时间地处于 100% 忙碌状态,这种情况下串行收集器有明显的优势。

表 5-2 列出了一个单线程计算 10 万只股票在三年内历史记录所需的时间。

表 5-2. 不同 GC 算法在单个 CPU 上的处理时间

GC 算法 总经过时间 用于 GC 暂停的时间
串行 434 秒 79 秒
吞吐量 503 秒 144 秒
G1 GC 501 秒 97 秒

单线程垃圾收集的优势最明显的是当我们将串行收集器与吞吐量收集器进行比较时。用于实际计算的时间是总经过时间减去用于 GC 暂停的时间。在串行和吞吐量收集器中,这段时间基本相同(大约 355 秒),但串行收集器胜出的原因是它在进行垃圾收集时的暂停时间要少得多。具体来说,串行收集器进行一次完全 GC 的平均时间为 505 毫秒,而吞吐量收集器则需要 1,392 毫秒。吞吐量收集器在其算法中有相当多的开销——当两个或更多线程处理堆时,这种开销是值得的,但当只有一个线程可用时,它只会妨碍操作。

现在将串行收集器与 G1 GC 进行比较。如果我们在使用 G1 GC 时消除暂停时间,应用程序进行计算需要 404 秒,但我们知道从其他示例中,实际只需要 355 秒。其他的 49 秒来自于什么?

计算线程可以利用所有可用的 CPU 周期。同时,后台的 G1 GC 线程需要 CPU 周期来完成它们的工作。因为没有足够的 CPU 来满足两者的需求,它们最终会共享 CPU:计算线程会运行一段时间,而后台的 G1 GC 线程会运行一段时间。最终的效果是,计算线程因为一个“后台”G1 GC 线程占用 CPU 而无法运行 49 秒。

这就是我说当你选择 G1 GC 时,需要足够的 CPU 供其后台线程运行的意思。对于长时间运行的应用程序线程占用唯一可用的 CPU 的情况,G1 GC 并不是一个好选择。但如果换成一些不同的情况,比如在受限硬件上运行简单的 REST 请求的微服务呢?表格 5-3 展示了一个 Web 服务器处理大约每秒 11 个请求,使用单个 CPU,大约占用了可用 CPU 周期的 50%的响应时间。

表格 5-3. 使用不同 GC 算法的单个 CPU 的响应时间

GC 算法 平均响应时间 90th% 响应时间 99th% 响应时间 CPU 利用率
串行 0.10 秒 0.18 秒 0.69 秒 53%
吞吐量 0.16 秒 0.18 秒 1.40 秒 49%
G1 GC 0.13 秒 0.28 秒 0.40 秒 48%

默认(串行)算法仍然具有最佳的平均时间,比其他算法快 30%。同样,这是因为串行收集器对于年轻代的收集通常比其他算法快,因此平均请求由串行收集器延迟较少。

一些不幸的请求会被串行收集器的完整 GC 打断。在这个实验中,串行收集器进行完整 GC 的平均时间为 592 毫秒,最长的甚至达到了 730 毫秒。结果是 1%的请求几乎花费了 700 毫秒。

这仍然优于吞吐量收集器的表现。吞吐量收集器的完整 GC 平均为 1,192 毫秒,最大为 1,510 毫秒。因此,吞吐量收集器的第 99th%响应时间是串行收集器的两倍。而且平均时间也因这些异常值而偏差。

G1 GC 位于中间某处。就平均响应时间而言,它比串行收集器更差,因为更简单的串行收集器算法更快。在这种情况下,主要适用于小 GC,串行收集器平均需要 86 毫秒,而 G1 GC 则需要 141 毫秒。因此,在 G1 GC 的情况下,平均请求会被延迟更长时间。

尽管如此,G1 GC 的 99th%响应时间明显低于串行收集器。在这个示例中,G1 GC 能够避免完整 GC,因此没有了串行收集器超过 500 毫秒的延迟。

这里有一个优化的选择:如果平均响应时间是最重要的目标,那么(默认的)串行收集器是更好的选择。如果你想要优化第 99th%响应时间,G1 GC 胜出。这是一个判断的问题,但对我来说,平均时间的 30 毫秒差异不如第 99th%时间的 300 毫秒差异重要—因此在这种情况下,G1 GC 比平台的默认收集器更合理。

这个示例对 GC 的消耗很大;特别是非并发收集器需要执行大量的完整 GC 操作。如果我们调整测试,使得所有对象都可以在不需要完整 GC 的情况下被收集,那么串行算法可以与 G1 GC 相匹配,如表 5-4 所示。

表 5-4. 单 CPU 使用不同 GC 算法的响应时间(无完整 GC)

GC 算法 平均响应时间 第 90th%响应时间 第 99th%响应时间 CPU 利用率
串行 0.05 秒 0.08 秒 0.11 秒 53%
吞吐量 0.08 秒 0.09 秒 0.13 秒 49%
G1 GC 0.05 秒 0.08 秒 0.11 秒 52%

因为没有完整 GC,串行收集器相对于 G1 GC 的优势被消除了。当 GC 活动较少时,所有数字都在同一范围内,并且所有收集器的表现几乎相同。另一方面,没有完整 GC 的情况相当罕见,这是串行收集器表现最佳的情况。在足够的 CPU 周期下,G1 GC 通常会比默认的串行收集器更好。

单超线程 CPU 硬件

那么单核机器或 Docker 容器如何? 在这种情况下,CPU 是超线程的(因此在 JVM 看来是一个双 CPU 的机器),JVM 不会默认使用串行收集器——它认为有两个 CPU,因此在 JDK 8 中会默认使用吞吐量收集器,在 JDK 11 中会使用 G1 GC。 但事实证明,串行收集器在这种硬件上通常也是有利的。 表 5-5 显示了在单个超线程 CPU 上运行前一批次实验时发生的情况。

表 5-5. 在单个超线程 CPU 上使用不同 GC 算法的处理时间

GC 算法 经过时间 垃圾回收暂停时间
串行 432 秒 82 秒
吞吐量 478 秒 117 秒
G1 GC 476 秒 72 秒

串行收集器不会运行多个线程,因此其时间与我们之前的测试基本上没有变化。 其他算法有所改进,但并不像我们希望的那样多——吞吐量收集器将运行两个线程,但暂停时间并没有减半,而是减少了约 20%。 同样,G1 GC 仍然无法为其后台线程获取足够的 CPU 周期。

所以至少在这种情况下——长时间运行的批处理作业并频繁进行垃圾收集时——JVM 的默认选择是错误的,应用程序最好使用串行收集器,尽管存在“两个”CPU。 如果有两个实际的 CPU(即两个核心),情况会有所不同。 吞吐量收集器仅需要 72 秒来完成操作,这比串行收集器所需的时间少。 在这一点上,串行收集器的实用性就会减弱,所以我们将在未来的示例中放弃它。

串行收集器有一个额外的要点:即使应用程序的堆非常小(比如 100 MB),使用串行收集器仍然可能表现更好,而与可用的核心数量无关。

何时使用吞吐量收集器

当一台机器有多个可用的 CPU 时,GC 算法之间可能会发生更复杂的交互,但在基本水平上,G1 GC 和吞吐量收集器之间的权衡与我们刚刚看到的相同。 例如,表 5-6 显示了我们的样本应用程序在具有四个核心的机器上运行两个或四个应用程序线程时的工作情况(其中核心不是超线程的)。

表 5-6. 使用不同 GC 算法进行批处理的时间

应用程序线程 G1 GC 吞吐量
410 秒(60.8%) 446 秒(59.7%)
513 秒(99.5%) 536 秒(99.5%)

表中的时间是运行测试所需的秒数,并显示了机器的 CPU 利用率。当有两个应用程序线程时,G1 GC 比吞吐量收集器显著更快。主要原因是吞吐量收集器花了 35 秒暂停进行完全 GC。G1 GC 能够避免这些收集,虽然会(相对轻微地)增加 CPU 时间。

即使有四个应用程序线程,G1 在这个例子中仍然胜出。在这里,吞吐量收集器总共暂停了应用程序线程 176 秒。而 G1 GC 仅暂停了应用程序线程 88 秒。G1 GC 后台线程确实需要与应用程序线程竞争 CPU 周期,这让应用程序线程少了大约 65 秒。这仍意味着 G1 GC 快了 23 秒。

当应用程序的经过时间至关重要时,吞吐量收集器将比 G1 GC 更有优势,因为它暂停应用程序线程的时间少于 G1 GC。这种情况发生在以下一种或多种情况:

  • 没有(或很少)进行完全 GC。完全 GC 暂停很容易支配应用程序的暂停时间,但如果它们根本不发生,那么吞吐量收集器就不再处于劣势。

  • 老年代通常是满的,导致后台的 G1 GC 线程工作更多。

  • G1 GC 线程饥饿于 CPU。

在接下来详细介绍各种算法如何工作以及这些要点背后的原因的章节中,将更清楚地解释这些内容(以及围绕它们调整收集器的方法)。现在,我们将看一些例子来证明这一点。

首先,让我们看一下表格 5-7 中的数据。这个测试与我们之前用于长计算批处理作业的代码相同,尽管有一些修改:多个应用程序线程正在进行计算(在这种情况下为两个),老年代以对象为种子保持 65%满,几乎所有对象都可以直接从年轻代收集。这个测试在一个具有四个 CPU(非超线程)的系统上运行,以确保后台的 G1 GC 线程有足够的 CPU 资源运行。

表 5-7。带有长寿命对象的批处理

指标 G1 GC 吞吐量
经过时间 212 秒 193 秒
CPU 使用率 67% 51%
年轻代 GC 暂停 30 秒 13.5 秒
完全 GC 暂停 0 秒 1.5 秒

由于只有少量对象被晋升到老年代,吞吐量收集器仅暂停了应用程序线程 15 秒,其中只有 1.5 秒用于收集老年代。

尽管老年代没有许多新对象被晋升进去,但测试种子会为老年代添加垃圾以便 G1 GC 线程扫描。这会给后台 GC 线程增加更多工作量,并导致 G1 GC 为了补偿老年代的填充而在收集年轻代时做更多工作。最终结果是,在两线程测试中,G1 GC 使应用程序暂停了 30 秒——比吞吐量收集器更多。

另一个例子:当 G1 GC 后台线程没有足够的 CPU 运行时,吞吐量收集器的表现会更好,正如 表 5-8 所示。

表 5-8. 忙碌 CPU 下的批处理

指标 G1 GC 吞吐量
经过时间 287 秒 267 秒
CPU 使用率 99% 99%
年轻代 GC 暂停 80 秒 63 秒
全 GC 暂停 0 秒 37 秒

实际上,这与单 CPU 的情况没有什么不同:G1 GC 后台线程与应用程序线程之间的 CPU 周期竞争意味着,即使没有 GC 暂停发生,应用程序线程也会被有效暂停。

如果我们更关心交互处理和响应时间,那么吞吐量收集器要比 G1 GC 更难胜过。如果您的服务器缺少 CPU 周期,以至于 G1 GC 和应用程序线程争夺 CPU,那么 G1 GC 的响应时间将更差(与我们已经看到的情况类似)。如果服务器调优得没有全 GC,则 G1 GC 和吞吐量收集器通常会产生类似的结果。但是吞吐量收集器有更多的全 GC,G1 GC 的平均、90th% 和 99th% 响应时间就会更好。

快速总结

  • 目前对于大多数应用程序来说,G1 GC 是更好的算法选择。

  • 在单 CPU 机器上运行 CPU 密集型应用程序时,串行收集器是有道理的,即使该单 CPU 是超线程的。对于那些不是 CPU 密集型的作业,G1 GC 在这样的硬件上仍然更好。

  • 对于 CPU 密集型作业在多 CPU 机器上,吞吐量收集器是合理的选择。即使对于不是 CPU 密集型的作业,如果它相对较少进行全 GC 或者老年代通常是满的,吞吐量收集器可能也是更好的选择。

基本 GC 调优

尽管 GC 算法在处理堆的方式上有所不同,它们共享基本的配置参数。在许多情况下,这些基本配置就足以运行一个应用程序。

调整堆大小

GC 的第一个基本调优是应用程序堆大小。高级调优会影响堆的代大小;作为第一步,本节将讨论设置整体堆大小。

像大多数性能问题一样,选择堆大小是一个权衡问题。如果堆太小,程序将花费太多时间执行 GC,而不是足够的时间执行应用程序逻辑。但是仅仅指定一个非常大的堆也不一定是答案。GC 暂停的时间取决于堆大小,因此随着堆大小的增加,这些暂停的持续时间也会增加。暂停的频率会减少,但其持续时间将使整体性能下降。

当使用非常大的堆时,还会出现第二个危险。计算机操作系统使用虚拟内存管理机器的物理内存。一台机器可能有 8GB 的物理 RAM,但操作系统会使其看起来可用的内存量要多得多。虚拟内存的量取决于操作系统的配置,但假设操作系统看起来有 16GB 内存。操作系统通过称为交换(或分页,尽管这两个术语之间有技术上的区别,但对本讨论并不重要)的过程来管理这一点。您可以加载使用高达 16GB 内存的程序,操作系统将不活动部分的程序复制到磁盘。当需要这些内存区域时,操作系统将其从磁盘复制到 RAM(通常,它首先需要将某些内容从 RAM 复制到磁盘以腾出空间)。

这个过程对于运行大量应用程序的系统效果很好,因为大多数应用程序不会同时活动。但对于 Java 应用程序来说,效果不佳。如果在此系统上运行具有 12GB 堆的 Java 程序,则操作系统可以通过将 8GB 的堆保持在内存中,将 4GB 保存在磁盘上来处理(这简化了情况,因为其他程序将使用部分内存)。JVM 不会知道这一点;交换由操作系统透明处理。因此,JVM 将愉快地填充其被告知使用的全部 12GB 堆。这会导致严重的性能损失,因为操作系统将数据从磁盘交换到 RAM(这本身是一个昂贵的操作)。

更糟糕的是,保证发生交换的唯一时机是在进行全局垃圾收集(GC)时,此时 JVM 必须访问整个堆。如果系统在进行全局 GC 期间发生交换,暂停时间将比通常情况下长上一个数量级。同样地,当使用 G1 GC 时,后台线程在堆中扫描时,由于长时间等待数据从磁盘复制到主存储器,很可能会滞后,导致昂贵的并发模式失败。

因此,调整堆大小的第一个原则是永远不要指定比机器上的物理内存更大的堆大小——如果有多个 JVM 运行,这也适用于所有堆的总和。您还需要为 JVM 的本机内存留出一些空间,并为其他应用程序留出一些内存空间:通常至少需要为常见操作系统配置留出 1GB 的空间。

堆的大小由两个值控制:初始值(使用 -XmsN 指定)和最大值(-XmxN)。默认值因操作系统、系统 RAM 量和使用的 JVM 而异。默认值也可能受命令行上其他标志的影响;堆大小是 JVM 的核心人机工程调整之一。

JVM 的目标是根据可用于其的系统资源找到“合理”的默认堆初始值,并在应用程序需要更多内存时(根据其执行 GC 的时间)将堆增长到“合理”的最大值。在本章和下一章稍后讨论的一些高级调整标志和细节缺失时,初始和最大大小的默认值在 表 5-9 中给出。JVM 将略微向下舍入这些值以对齐目的;打印大小的 GC 日志将显示这些值与本表中的数字不完全相等。

表 5-9. 默认堆大小

操作系统和 JVM 初始堆(Xms 最大堆(Xmx
Linux 最小值(512 MB,物理内存的 1/64) 最小值(32 GB,物理内存的 1/4)
macOS 64 MB 最小值(1 GB,物理内存的 1/4)
Windows 32 位客户端 JVMs 16 MB 256 MB
Windows 64 位服务器 JVMs 64 MB 最小值(1 GB,物理内存的 1/4)

在物理内存少于 192 MB 的计算机上,最大堆大小将是物理内存的一半(96 MB 或更少)。

注意,表 5-9 中的数值是那些在 Docker 容器中 JDK 8 版本更新到 192 之前指定内存限制的情况下将会不正确的调整:JVM 将使用机器上的总内存来计算默认大小。在之后的 JDK 8 版本和 JDK 11 中,JVM 将使用容器的内存限制。

为堆设置初始值和最大值允许 JVM 根据工作负载调整其行为。如果 JVM 发现初始堆大小的 GC 过多,它将不断增加堆大小,直到 JVM 执行“正确”的 GC 量,或者直到堆达到其最大大小。

对于不需要大型堆的应用程序,这意味着根本不需要设置堆大小。相反,您指定 GC 算法的性能目标:您愿意容忍的暂停时间、您想要在 GC 中花费的时间百分比等。细节取决于所使用的 GC 算法,并将在下一章讨论(尽管即使在那种情况下,也选择了默认值,以便对于广泛的应用程序范围,这些值也无需调整)。

在 JVM 运行在隔离容器中的世界中,通常需要指定最大堆。在主要运行单个 JVM 的虚拟机上,默认的初始堆大小只有分配给虚拟机的内存的四分之一。同样,在带有内存限制的 JDK 11 Docker 容器中,通常希望堆消耗大部分内存(留出前面提到的余地)。这里的默认值更适合运行多个应用程序的系统,而不是专门为特定 JVM 的容器。

没有明确的规则决定最大堆值的大小(除非不指定大于机器支持的大小)。一个好的经验法则是调整堆大小,使其在完整 GC 后占用 30%。要计算这一点,请运行应用程序直到达到稳定状态配置:即已加载任何缓存,已创建最大数量的客户端连接等。然后使用jconsole连接到应用程序,强制进行完整 GC,并观察完整 GC 完成时使用的内存量。(或者,对于吞吐量 GC,如果可用,可以查阅 GC 日志。)如果采用这种方法,请确保调整容器(如果适用)以获得额外的 0.5–1 GB 内存,用于 JVM 的非堆需求。

请注意,即使显式设置了最大大小,堆的自动调整大小也会发生:堆将从其默认初始大小开始,并且 JVM 将增加堆以满足 GC 算法的性能目标。指定比所需更大的堆不一定会带来内存惩罚:它只会增长到足以满足 GC 性能目标的程度。

另一方面,如果您确切地知道应用程序需要多大的堆大小,您可以将堆的初始值和最大值都设置为该值(例如,-Xms4096m -Xmx4096m)。这样可以使 GC 稍微更高效,因为它永远不需要弄清楚是否应调整堆的大小。

快速总结

  • JVM 将尝试根据其运行的机器找到合理的最小和最大堆大小。

  • 除非应用程序需要比默认更大的堆,否则请考虑调整 GC 算法的性能目标(在下一章节中给出)而不是微调堆大小。

分配代大小

一旦确定了堆大小,JVM 必须决定将堆的多少分配给年轻代和多少分配给老年代。JVM 通常会自动执行此操作,并且通常在确定年轻代和老年代之间的最佳比率方面表现良好。在某些情况下,您可能需要手动调整这些值,尽管大多数情况下,本节的目的是提供垃圾收集工作原理的理解。

不同代大小的性能影响应该很明显:如果年轻代相对较大,则年轻代的 GC 暂停时间将增加,但年轻代的收集频率将减少,并且升级到老年代的对象将减少。但另一方面,因为老年代相对较小,它会更频繁地填满并执行更多的全 GC。在这里找到平衡是关键。

不同的 GC 算法尝试以不同方式找到这种平衡。但是,所有 GC 算法都使用相同的一组标志来设置代的大小;本节介绍了这些通用标志。

调整代大小的命令行标志都会调整年轻代的大小;老年代会得到剩余的一切。可以使用多种标志来设置年轻代的大小:

-XX:NewRatio=N

设置年轻代和老年代的比例。

-XX:NewSize=N

设置年轻代的初始大小。

-XX:MaxNewSize=N

设置年轻代的最大大小。

-XmnN

设置NewSizeMaxNewSize为相同值的快捷方式。

年轻代首先由NewRatio大小设定,其默认值为 2。影响堆空间大小设定的参数通常指定为比率;该值在一个方程中用于确定受影响空间的百分比。NewRatio值在以下公式中使用:

Initial Young Gen Size = Initial Heap Size / (1 + NewRatio)

将堆的初始大小和NewRatio代入计算得到的值将成为年轻代的设置。因此,默认情况下,年轻代从初始堆大小的 33%开始。

或者,可以通过指定NewSize标志来显式设置年轻代的大小。如果设置了此选项,则它将优先于从NewRatio计算出的值。该标志没有默认值,因为默认情况下是从NewRatio计算出的。

随着堆的扩展,年轻代的大小也会扩展,直到由MaxNewSize标志指定的最大大小。默认情况下,该最大值也是使用NewRatio值设置的,尽管它基于最大(而非初始)堆大小。

通过指定年轻代的最小和最大大小来调整年轻代的性能最终会变得相当困难。当堆大小固定时(通过将-Xms设置为-Xmx),通常最好使用-Xmn来指定年轻代的固定大小。如果应用程序需要动态大小的堆并需要更大(或更小)的年轻代,则专注于设置NewRatio值。

自适应大小调整

堆大小、代的大小和幸存者空间的大小可以在执行过程中变化,因为 JVM 尝试根据其策略和调整找到最佳性能。这是一种尽力而为的解决方案,并且依赖于过去的性能:假设未来的 GC 周期将与最近过去的 GC 周期类似。对于许多工作负载来说,这是一个合理的假设,即使分配速率突然发生变化,JVM 也将根据新信息重新调整其大小。

自适应大小以两种重要方式提供优势。首先,这意味着小型应用程序无需担心过度指定其堆大小。考虑用于调整 Java NoSQL 服务器等事务操作的管理命令行程序——这些程序通常存在时间较短,并且使用最小的内存资源。即使默认堆大小可以增长到 1 GB,这些应用程序也将使用 64(或 16)MB 的堆。由于自适应大小,像这样的应用程序无需进行特定调整;平台默认值确保它们不会使用大量内存。

第二,这意味着许多应用程序实际上根本不需要担心调整其堆大小——或者如果它们需要比平台默认值更大的堆,则可以指定更大的堆并忘记其他细节。 JVM 可以自动调整堆和代的大小,以使用最佳内存量,考虑到 GC 算法的性能目标。自适应大小是使自动调整工作的原因。

然而,调整大小需要一小部分时间——这在大部分情况下发生在 GC 暂停期间。如果您花时间精细调整 GC 参数和应用程序堆大小的大小约束,则可以禁用自适应大小。禁用自适应大小对于经历明显不同阶段的应用程序也很有用,如果您想要为这些阶段中的一个最佳调整 GC。

在全局级别,可以通过关闭-XX:-UseAdaptiveSizePolicy标志(默认为true)来禁用自适应大小。除了年轻代的幸存者空间(在下一章中详细讨论),如果将最小堆大小和最大堆大小设置为相同值,并且新生代的初始大小和最大大小设置为相同值,则自适应大小也将有效地关闭。

要查看 JVM 如何调整应用程序中的空间大小,请设置-XX:+PrintAdaptiveSizePolicy标志。当执行 GC 时,GC 日志将包含详细信息,详细说明在集合期间如何调整各代的大小。

快速总结

  • 在整体堆大小内部,各代的大小受到分配给年轻代的空间量的控制。

  • 年轻代将与整体堆大小同步增长,但也可以作为总堆大小的百分比而波动(基于年轻代的初始大小和最大大小)。

  • 自适应调整控制 JVM 在堆内调整年轻代与老年代比例的方式。

  • 通常应保持启用自适应调整,因为调整这些代大小是 GC 算法尝试实现其暂停时间目标的方式。

  • 对于精细调整的堆,可以禁用自适应调整以获得小幅性能提升。

元空间大小

当 JVM 加载类时,必须跟踪这些类的某些元数据。这占用了称为 元空间 的独立堆空间。在旧版 JVM 中,这是由称为 永久代 的不同实现处理的。

对于最终用户而言,元空间是不透明的:我们知道它保存了大量与类相关的数据,并且在某些情况下需要调整该区域的大小。

请注意,元空间不保存类的实际实例(Class 对象)或反射对象(例如,Method 对象);这些对象保存在常规堆中。元空间中的信息仅供编译器和 JVM 运行时使用,并且它所保存的数据被称为 类元数据

预先计算特定程序所需的元空间大小并没有一个很好的方法。其大小与使用的类数量成比例,因此更大的应用程序将需要更大的空间。这是 JDK 技术变化使生活更轻松的另一个领域:调整永久代曾经相当常见,但现在调整元空间则相对罕见。主要原因是元空间大小的默认值非常慷慨。Table 5-10 列出了默认的初始和最大大小。

表 5-10. 元空间的默认大小

JVM 默认初始大小 默认最大大小
32 位客户端 JVM 12 MB 无限制
32 位服务器 JVM 16 MB 无限制
64 位 JVM 20.75 MB 无限制

元空间类似于常规堆的单独实例。它的大小根据初始大小(-XX:MetaspaceSize=N)动态设置,并会根据需要增加到最大大小(-XX:MaxMetaspaceSize=N)。

调整元空间大小需要进行完整的 GC,因此这是一个昂贵的操作。如果在程序启动时(加载类时)出现大量的完整 GC,往往是因为正在调整永久代或元空间的大小,因此增加初始大小是改善这种情况下启动性能的好方法。例如,服务器通常指定初始元空间大小为 128 MB、192 MB 或更大。

Java 类与其他任何东西一样都可以符合 GC 的条件。在应用服务器中,这种情况经常发生,每次部署(或重新部署)应用程序时会创建新的类加载器。然后,旧的类加载器将不再被引用,并且符合 GC 的条件,任何它们定义的类也是如此。与此同时,应用程序的新类将具有新的元数据,因此元空间必须有足够的空间。这通常会导致完全 GC,因为元空间需要增长(或丢弃旧的元数据)。

限制元空间大小的一个原因是防止类加载器泄漏:当应用服务器(或类似 IDE 的其他程序)不断定义新的类加载器和类,并保持对旧类加载器的引用时,会填满元空间并在机器上消耗大量内存的潜力。另一方面,在这种情况下,实际的类加载器和类对象也仍然在主堆中,并且在元空间的内存成为问题之前,主堆很可能会填满并导致 OutOfMemoryError

堆转储(参见 第七章)可用于诊断存在哪些类加载器,从而有助于确定是否有类加载器泄漏填满了元空间。否则,可以使用 jmap 并带有参数 -clstats 打印有关类加载器的信息。

快速摘要

  • 元空间保存类元数据(而不是类对象),并表现得像一个单独的堆。

  • 此区域的初始大小可以基于加载所有类后的使用情况。这将稍微加快启动速度。

  • 定义和丢弃大量类的应用程序将在元空间填满并且旧类被移除时偶尔会看到完全 GC。这在开发环境中尤其常见。

控制并行性

除串行收集器外,所有 GC 算法均使用多线程。这些线程的数量由 -XX:ParallelGCThreads=N 标志控制。此标志的值影响以下操作所使用的线程数量:

  • 当年轻一代使用 -XX:+UseParallelGC 时的收集

  • 当使用 -XX:+UseParallelGC 时老年代的收集

  • 当使用 -XX:+UseG1GC 时年轻一代的收集

  • G1 GC 的停止-世界阶段(尽管不是完全 GC)

因为这些 GC 操作会停止所有应用程序线程的执行,所以 JVM 会尽可能使用尽可能多的 CPU 资源以最小化暂停时间。默认情况下,这意味着 JVM 将在每台机器上的 CPU 上运行一个线程,最多八个。一旦达到这个阈值,JVM 将为每 1.6 个 CPU 添加一个新线程。因此,在具有超过八个 CPU 的机器上,显示的总线程数(其中 N 是 CPU 数)如下:

ParallelGCThreads = 8 + ((N - 8) * 5 / 8)

有时候这个数目过大。在一个拥有八个 CPU 的机器上,使用小堆(比如 1 GB)的应用程序,如果将堆分配给四到六个线程,会稍微提高效率。在拥有 128 个 CPU 的机器上,83 个 GC 线程对于除了最大堆之外的其他情况来说都太多了。

如果在具有 CPU 限制的 Docker 容器中运行 JVM,则该 CPU 限制将用于此计算。

此外,如果一台机器上运行多个 JVM,限制所有 JVM 的总 GC 线程数是个好主意。当它们运行时,GC 线程非常高效,每个线程将占用单 CPU 的 100%(这就是为什么吞吐量收集器的平均 CPU 使用率比预期高的原因)。在拥有八个或更少 CPU 的机器上,GC 将占用机器 CPU 的 100%。在拥有更多 CPU 和多个 JVM 的机器上,太多的 GC 线程仍将并行运行。

假设一台拥有 16 个 CPU 的机器上运行四个 JVM 实例;每个 JVM 默认会有 13 个 GC 线程。如果这四个 JVM 同时执行 GC 操作,那么机器将有 52 个耗 CPU 的线程竞争 CPU 时间。这会导致相当多的竞争;将每个 JVM 的 GC 线程限制为四个将更有效率。即使四个 JVM 不太可能同时执行 GC 操作,但其中一个 JVM 使用 13 个线程执行 GC 意味着其余 JVM 中的应用线程现在必须在 16 个 CPU 中有 13 个正在忙于执行 GC 任务的机器上竞争 CPU 资源。在这种情况下,为每个 JVM 提供四个 GC 线程提供了更好的平衡。

请注意,此标志不会设置 G1 GC 使用的后台线程数(尽管它确实会影响)。详细信息请参见下一章节。

快速总结

  • 所有 GC 算法使用的基本线程数都基于机器上的 CPU 数。

  • 当单台机器上运行多个 JVM 时,线程数会过高,必须进行减少。

GC 工具

由于 GC 对 Java 性能至关重要,许多工具都监控其性能。了解 GC 日志的效果对于了解应用程序性能的影响是最佳方式,GC 日志记录了程序执行期间每次 GC 操作。

GC 日志中的详细信息根据 GC 算法而异,但日志的基本管理对所有算法都是相同的。然而,GC 日志的管理在 JDK 8 和后续版本中并不相同:JDK 11 使用一组不同的命令行参数来启用和管理 GC 日志。我们将在此讨论 GC 日志的管理,并在下一章节的特定于算法的调优部分详细介绍日志内容。

在 JDK 8 中启用 GC 日志记录。

JDK 8 提供多种启用 GC 日志的方法。指定-verbose:gc-XX:+PrintGC中的任何一个标志都将创建一个简单的 GC 日志(这些标志是彼此的别名,默认情况下日志是禁用的)。-XX:+PrintGCDetails标志将创建一个包含更多信息的日志。推荐使用该标志(默认情况下也是false);仅使用简单日志很难诊断 GC 发生的情况。

配合详细的日志,建议包含-XX:+PrintGCTimeStamps-XX:+PrintGCDateStamps,以确定 GC 操作之间的时间。这两个参数的区别在于,时间戳是相对于 0 的(基于 JVM 启动时),而日期时间戳则是实际的日期字符串。由于日期时间戳需要格式化日期,稍微效率略低一些,尽管这是一个不太频繁的操作,但其影响不太可能被注意到。

GC 日志被写入标准输出,尽管可以(并且通常应该)使用-Xloggc:filename标志来更改其位置。使用-Xloggc会自动启用简单的 GC 日志,除非也启用了PrintGCDetails

可以通过日志轮换来限制 GC 日志中保留的数据量;这对于长时间运行的服务器非常有用,否则可能会在几个月内用日志填满磁盘。日志文件轮换由以下标志控制:-XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=N -XX:GCLogFileSize=*N*。默认情况下,UseGCLogFileRotation是禁用的。当启用该标志时,默认文件数为 0(意味着无限制),默认日志文件大小为 0(意味着无限制)。因此,为了使日志轮换按预期工作,必须为所有这些选项指定值。注意,文件大小将会向上舍入至 8 KB,以避免小于此值的问题。

将所有这些内容整合在一起,用于日志记录的一组有用标志如下:

-Xloggc:gc.log -XX:+PrintGCTimeStamps -XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFile=8 -XX:GCLogFileSize=8m

这将记录带有时间戳的 GC 事件,以便与其他日志进行关联,并将保留的日志限制在 8 个文件中的 64 MB。这种日志记录足够精简,甚至可以在生产系统上启用。

在 JDK 11 中启用 GC 日志记录

JDK 11 及更高版本使用 Java 的新统一日志记录功能。这意味着所有的日志记录(包括与 GC 相关的和其他的)都是通过-Xlog标志启用的。然后你可以附加各种选项来控制日志记录的行为。为了指定类似 JDK 8 中长示例的日志记录,你需要使用以下标志:

-Xlog:gc*:file=gc.log:time:filecount=7,filesize=8M

冒号将命令分成四个部分。你可以运行java -Xlog:help:来获取更多有关可用选项的信息,但以下是它们在这个字符串中的映射。

第一部分(gc*)指定了应该启用日志记录的模块;我们启用了所有 GC 模块的日志记录。有一些选项可以仅记录特定的部分(例如 gc+age 将记录关于对象老化的信息,这是下一章节中讨论的一个主题)。这些特定的模块通常在默认日志级别下的输出有限,因此您可能会使用类似 gc*,gc+age=debug 的方式来记录所有 gc 模块的基本(info 级别)消息以及老化代码的调试级别消息。通常,以 info 级别记录所有模块是可以接受的。

第二部分设置了日志文件的目标位置。

第三部分(time)是一个装饰器:这个装饰器指示将消息记录为带有时间戳的形式,与我们为 JDK 8 指定的方式相同。可以指定多个装饰器。

最后,第四部分指定了输出选项;在这种情况下,我们说当日志文件达到 8 MB 时进行日志轮转,总共保留八个日志文件。

值得注意的是:在 JDK 8 和 JDK 11 之间,日志轮转处理稍有不同。假设我们指定了一个名为 gc.log 的日志文件,并且应该保留三个文件。在 JDK 8 中,日志会被记录如下:

  1. 开始记录到 gc.log.0.current

  2. 当日志文件满时,将其重命名为 gc.log.0 并开始记录到 gc.log.1.current

  3. 当日志文件满时,将其重命名为 gc.log.1 并开始记录到 gc.log.2.current

  4. 当日志文件满时,将其重命名为 gc.log.2,移除 gc.log.0,并开始记录到一个新的 gc.log.0.current

  5. 重复这个周期。

在 JDK 11 中,日志会按照以下方式记录:

  1. 开始记录到 gc.log

  2. 当日志文件满时,将其重命名为 gc.log.0 并开始一个新的 gc.log

  3. 当日志文件满时,将其重命名为 gc.log.1 并开始一个新的 gc.log

  4. 当日志文件满时,将其重命名为 gc.log.2 并开始一个新的 gc.log

  5. 当日志文件满时,将其重命名为 gc.log.0,移除旧的 gc.log.0,并开始一个新的 gc.log

如果你想知道为什么在之前的 JDK 11 命令中我们指定了保留七个日志文件,这就是原因:在这种情况下将有八个活动文件。无论如何,请注意,文件名称中附加的数字并不代表文件创建的顺序。这些数字在一个循环中重复使用,因此有一定的顺序,但是最旧的日志文件可以是任何一个。

gc 日志包含了与每个收集器相关的大量信息,因此我们将在下一章节详细介绍这些细节。解析日志以获取关于应用程序的汇总信息也很有用:例如它有多少次暂停,平均暂停时间以及总暂停时间等。

不幸的是,并没有很多优秀的开源工具可以解析日志文件。与分析器一样,商业厂商提供了支持,例如来自 jClarity(Censum)和 GCeasy 的服务。后者提供了基本日志解析的免费服务。

对于堆的实时监控,请使用jvisualvmjconsolejconsole的内存面板显示堆的实时图表,如图 5-4 所示。

一张显示堆占用量随 GC 周期变化的图表

图 5-4. 实时堆显示

此视图显示整个堆,它定期在使用约 100 MB 和 160 MB 之间循环。jconsole也可以只显示 Eden 区、幸存者空间、老年代或永久代。如果我选择 Eden 作为要绘制的区域,它将显示类似的模式,因为 Eden 在 0 MB 和 60 MB 之间波动(而且,您可以猜到,这意味着如果我选择了老年代绘制,它将基本上是 100 MB 的平直线)。

对于可脚本化的解决方案,jstat是首选工具。jstat提供九个选项来打印关于堆的不同信息;jstat -options将提供完整列表。一个有用的选项是-gcutil,它显示在 GC 中花费的时间以及当前填充的每个 GC 区域的百分比。jstat的其他选项将以 KB 为单位显示 GC 大小。

记住,jstat可以带一个可选参数——重复执行命令的毫秒数——以便在应用程序中随时间监视 GC 的效果。以下是每秒重复的一些示例输出:

% jstat -gcutil 23461 1000
  S0     S1     E      O      P     YGC     YGCT    FGC    FGCT     GCT
 51.71   0.00  99.12  60.00  99.93     98    1.985     8    2.397    4.382
  0.00  42.08   5.55  60.98  99.93     99    2.016     8    2.397    4.413
  0.00  42.08   6.32  60.98  99.93     99    2.016     8    2.397    4.413
  0.00  42.08  68.06  60.98  99.93     99    2.016     8    2.397    4.413
  0.00  42.08  82.27  60.98  99.93     99    2.016     8    2.397    4.413
  0.00  42.08  96.67  60.98  99.93     99    2.016     8    2.397    4.413
  0.00  42.08  99.30  60.98  99.93     99    2.016     8    2.397    4.413
 44.54   0.00   1.38  60.98  99.93    100    2.042     8    2.397    4.439
 44.54   0.00   1.91  60.98  99.93    100    2.042     8    2.397    4.439

当监视进程 ID 23461 启动时,程序已经执行了 98 次年轻代收集(YGC),总计耗时 1.985 秒(YGCT)。此外,它还执行了 8 次完全 GC(FGC),总共需要 2.397 秒(FGCT);因此 GC 总时间(GCT)为 4.382 秒。

此处显示了年轻代的所有三个部分:两个幸存者空间(S0S1)和 Eden 区(E)。监控开始时,Eden 正在填满(99.12%),因此在下一秒会进行年轻代收集:Eden 减少到 5.55%的使用率,幸存者空间交换位置,并且少量内存被提升到老年代(O),其使用率增至 60.98%。像往常一样,永久代(P)几乎没有变化,因为应用程序已加载了所有必要的类。

如果您忘记启用 GC 日志记录,这是一个良好的替代方法,以观察 GC 如何随时间运行。

快速概述

  • GC 日志是诊断 GC 问题所需的关键数据;它们应定期收集(即使在生产服务器上)。

  • 使用PrintGCDetails标志可以获得更好的 GC 日志文件。

  • 提供用于解析和理解 GC 日志的程序是现成的;它们有助于总结 GC 日志中的数据。

  • jstat可以为实时程序提供良好的 GC 可见性。

概要

垃圾收集器的性能是任何 Java 应用程序整体性能的关键特性之一。对于许多应用程序而言,唯一需要调整的是选择合适的 GC 算法,并且如果需要的话,增加应用程序的堆大小。自适应调整将允许 JVM 自动调整其行为,以利用给定的堆提供良好的性能。

更复杂的应用程序将需要额外的调整,特别是针对特定的 GC 算法。如果本章中的简单 GC 设置无法提供应用程序所需的性能,请查阅调整建议。

第六章:垃圾收集算法

第五章研究了所有垃圾收集器的一般行为,包括适用于所有 GC 算法的 JVM 标志:如何选择堆大小、代大小、日志记录等。基本的垃圾收集调优适用于许多情况。当它们不适用时,就需要检查正在使用的 GC 算法的具体操作,以确定如何更改其参数以最小化对应用程序的影响。

调整单个收集器所需的关键信息是在启用该收集器时从 GC 日志中获取的数据。因此,本章从查看每个算法的日志输出的角度开始,这使我们能够理解 GC 算法的工作原理及如何调整以获得更好的性能。然后,每个部分都包括调优信息以实现更佳的性能。

本章还涵盖了一些新的实验性收集器的详细信息。目前写作时,这些收集器可能不是百分之百稳定的,但很可能会在下一个 Java LTS 版本发布时成为成熟的、适合生产的收集器(就像 G1 GC 最初是实验性收集器,现在成为 JDK 11 的默认选择)。

几种不寻常的情况影响所有 GC 算法的性能:分配非常大的对象、既不短命也不长寿的对象等。本章末尾将涵盖这些情况。

理解吞吐量收集器

我们将从查看各个垃圾收集器开始,首先是吞吐量收集器。尽管我们已经看到 G1 GC 收集器通常更受欢迎,但吞吐量收集器的细节更为简单,更好地奠定了理解工作原理的基础。

回顾第五章中的内容,垃圾收集器必须执行三个基本操作:找到未使用的对象、释放它们的内存并压缩堆。吞吐量收集器在同一 GC 周期内执行所有这些操作;这些操作合称为收集。这些收集器可以在单个操作期间收集青年代或老年代。

图 6-1 显示了进行年轻代收集前后的堆。

一个堆在进行年轻代收集前后的图表。

图 6-1 吞吐量 GC 年轻代收集

当 Eden 区填满时,会发生年轻代收集。年轻代收集将所有对象移出 Eden 区:一些对象移动到一个幸存者空间(本图中的 S0),一些对象移动到老年代,从而导致老年代包含更多对象。当然,许多对象因不再被引用而被丢弃。

因为 Eden 区在此操作后通常为空,考虑到它已经被压缩,这可能看起来有些不寻常,但这正是其效果。

在使用PrintGCDetails的 JDK 8 GC 日志中,吞吐量收集器的小 GC 如下所示:

17.806: [GC (Allocation Failure) [PSYoungGen: 227983K->14463K(264128K)]
             280122K->66610K(613696K), 0.0169320 secs]
	     [Times: user=0.05 sys=0.00, real=0.02 secs]

此 GC 在程序开始后的 17.806 秒发生。年轻代中的对象现在占据了 14,463 KB(14 MB,在幸存者空间中);在 GC 之前,它们占据了 227,983 KB(227 MB)。¹ 此时年轻代的总大小为 264 MB。

与此同时,堆的整体占用量(包括年轻代和老年代)从 280 MB 减少到 66 MB,此时整个堆的大小为 613 MB。此操作花费的时间不到 0.02 秒(输出末尾的 0.02 秒实际上是 0.0169320 秒,四舍五入)。程序消耗的 CPU 时间比实际时间更多,因为年轻代收集是由多个线程完成的(在此配置中,有四个线程)。

在 JDK 11 中,相同的日志看起来可能是这样的:

[17.805s][info][gc,start       ] GC(4) Pause Young (Allocation Failure)
[17.806s][info][gc,heap        ] GC(4) PSYoungGen: 227983K->14463K(264128K)
[17.806s][info][gc,heap        ] GC(4) ParOldGen: 280122K->66610K(613696K)
[17.806s][info][gc,metaspace   ] GC(4) Metaspace: 3743K->3743K(1056768K)
[17.806s][info][gc             ] GC(4) Pause Young (Allocation Failure)
                                          496M->79M(857M) 16.932ms
[17.086s][info][gc,cpu         ] GC(4) User=0.05s Sys=0.00s Real=0.02s

这里的信息是相同的;只是格式不同。这个日志条目有多行;前一个日志条目实际上是一行(但在这种格式中不会重现)。此日志还打印出元空间的大小,但这些在年轻代收集期间永远不会改变。元空间也不包括在此示例的第五行报告的总堆大小中。

图 6-2 显示了在进行完整的 GC 之前和之后堆的情况。

完整 GC 前后堆的示意图。

图 6-2. 吞吐量完整的 GC

老年代收集器会释放年轻代中的所有内容。只有那些具有活动引用的对象才会留在老年代中,并且所有这些对象都已经被压缩,以便老年代的开始被占用,其余部分为空闲。

GC 日志报告的操作类似于这样:

64.546: [Full GC (Ergonomics) [PSYoungGen: 15808K->0K(339456K)]
          [ParOldGen: 457753K->392528K(554432K)] 473561K->392528K(893888K)
	  [Metaspace: 56728K->56728K(115392K)], 1.3367080 secs]
	  [Times: user=4.44 sys=0.01, real=1.34 secs]

现在,年轻代占用了 0 字节(其大小为 339 MB)。请注意,在图中这意味着幸存者空间也已被清除。老年代中的数据从 457 MB 减少到 392 MB,因此整个堆使用量从 473 MB 降至 392 MB。元空间的大小未改变;在大多数完整的 GC 中不会对其进行收集。(如果元空间空间不足,JVM 将运行完整的 GC 来收集它,并且您将看到元空间的大小发生变化;稍后我会展示这一点。)由于在完整的 GC 中有大量工作要做,因此实际花费了 1.3 秒的时间,以及 4.4 秒的 CPU 时间(再次为四个并行线程)。

JDK 11 中类似的日志如下:

[63.205s][info][gc,start       ] GC(13) Pause Full (Ergonomics)
[63.205s][info][gc,phases,start] GC(13) Marking Phase
[63.314s][info][gc,phases      ] GC(13) Marking Phase 109.273ms
[63.314s][info][gc,phases,start] GC(13) Summary Phase
[63.316s][info][gc,phases      ] GC(13) Summary Phase 1.470ms
[63.316s][info][gc,phases,start] GC(13) Adjust Roots
[63.331s][info][gc,phases      ] GC(13) Adjust Roots 14.642ms
[63.331s][info][gc,phases,start] GC(13) Compaction Phase
[63.482s][info][gc,phases      ] GC(13) Compaction Phase 1150.792ms
[64.482s][info][gc,phases,start] GC(13) Post Compact
[64.546s][info][gc,phases      ] GC(13) Post Compact 63.812ms
[64.546s][info][gc,heap        ] GC(13) PSYoungGen: 15808K->0K(339456K)
[64.546s][info][gc,heap        ] GC(13) ParOldGen: 457753K->392528K(554432K)
[64.546s][info][gc,metaspace   ] GC(13) Metaspace: 56728K->56728K(115392K)
[64.546s][info][gc             ] GC(13) Pause Full (Ergonomics)
                                            462M->383M(823M) 1336.708ms
[64.546s][info][gc,cpu         ] GC(13) User=4.446s Sys=0.01s Real=1.34s

快速总结

  • 吞吐量收集器有两个操作:次要收集和完整的 GC,每个操作都标记、释放和压缩目标代。

  • 从 GC 日志中获取的时间是确定 GC 对使用这些收集器的应用程序的整体影响的快速方法。

自适应和静态堆大小调整

调整吞吐量收集器关键在于暂停时间,以及在整体堆大小和老年代与年轻代大小之间取得平衡。

在这里需要考虑两个权衡。首先,我们有时间与空间之间的经典编程权衡。更大的堆在机器上消耗更多内存,消耗该内存的好处(至少在一定程度上)是应用程序将具有更高的吞吐量。

第二个权衡涉及执行 GC 所需的时间长度。通过增加堆大小可以减少完全 GC 暂停的次数,但这可能会因 GC 时间较长而导致平均响应时间增加。同样,通过将更多的堆分配给年轻代而不是老年代可以缩短完全 GC 暂停时间,但这反过来会增加老年代 GC 集合的频率。

这些权衡的影响显示在图 6-3 中。该图显示了以不同堆大小运行的股票 REST 服务器的最大吞吐量。对于较小的 256 MB 堆,服务器在 GC 中花费了大量时间(实际上是总时间的 36%);因此吞吐量受限。随着堆大小的增加,吞吐量迅速增加,直到堆大小设置为 1,500 MB。之后,吞吐量增加速度较慢:此时应用程序并非真正受 GC 限制(GC 时间约占总时间的 6%)。边际收益递减法已悄然而至:应用程序可以使用额外内存以提高吞吐量,但增益变得更有限。

在堆大小达到 4,500 MB 后,吞吐量开始略微下降。此时,应用程序已经达到了第二个权衡:额外的内存导致更长的 GC 周期,即使这些周期较少,也会降低总体吞吐量。

此图中的数据是通过在 JVM 中禁用自适应大小调整来获取的;最小堆和最大堆大小设置为相同值。可以在任何应用程序上运行实验,并确定堆和代的最佳大小,但通常更容易让 JVM 做出这些决策(这通常是发生的,因为默认情况下启用了自适应大小调整)。

jp2e 0603

图 6-3。各种堆大小的吞吐量

在吞吐量收集器中,自适应大小调整堆(和代)以满足其暂停时间目标。这些目标是使用以下标志设置的:-XX:MaxGCPauseMillis=N-XX:GCTimeRatio=N

MaxGCPauseMillis 标志指定应用程序愿意容忍的最大暂停时间。可能会有诱惑将其设置为 0,或者像 50 毫秒这样的小值。请注意,此目标适用于次要 GC 和完全 GC。如果使用非常小的值,应用程序将会得到非常小的老年代:例如可以在 50 毫秒内清理的老年代。这将导致 JVM 执行非常频繁的完全 GC,性能将非常糟糕。因此,请保持现实:将该值设置为可以实现的值。默认情况下,此标志未设置。

GCTimeRatio标志指定您愿意应用程序在 GC 中花费的时间量(与其应用级线程运行时间相比)。这是一个比率,因此N的值需要一些思考。该值在以下方程中使用,以确定应用程序线程理想情况下应该运行的时间百分比:

T h r o u g h p u t G o a l = 1 - 1 (1+GCTimeRatio)

GCTimeRatio的默认值为 99。将该值代入方程得出 0.99,意味着目标是在应用程序处理中花费 99%的时间,仅在 GC 中花费 1%的时间。但不要被默认情况下这些数字如何对应所迷惑。GCTimeRatio为 95 并不意味着 GC 应该运行高达 5%的时间:它意味着 GC 应该运行高达 1.94%的时间。

更容易的做法是决定您希望应用程序执行工作的最低百分比(例如,95%),然后根据以下方程计算GCTimeRatio的值:

G C T i m e R a t i o = Throughput (1-Throughput)

对于通过量目标为 95%(0.95),该方程得出GCTimeRatio为 19。

JVM 使用这两个标志在初始(-Xms)和最大(-Xmx)堆大小建立的边界内设置堆的大小。MaxGCPauseMillis标志优先级较高:如果设置了该标志,则调整年轻代和老年代的大小,直到达到暂停时间目标。一旦达到目标,堆的总体大小将增加,直到达到时间比率目标。一旦两个目标都达到,JVM 将尝试减少堆的大小,以便最终达到满足这两个目标的最小可能堆大小。

因为默认情况下未设置暂停时间目标,自动堆大小调整的常见效果是堆(和代数)大小将增加,直到满足GCTimeRatio目标。尽管如此,该标志的默认设置实际上是乐观的。当然,您的经验会有所不同,但我更习惯于看到应用程序在 GC 中花费 3%到 6%的时间,并表现良好。有时甚至我会在内存严重受限的环境中处理应用程序,这些应用程序最终会在 GC 中花费 10%到 15%的时间。GC 对这些应用程序的性能有重大影响,但总体性能目标仍然能够达到。

因此,最佳设置将根据应用程序目标而异。在没有其他目标的情况下,我从时间比率 19 开始(GC 中的时间为 5%)。

表 6-1展示了这种动态调优对于需要小堆且几乎不进行 GC 的应用程序的影响(这是具有少量长寿命周期对象的标准 REST 服务器)。

表 6-1. 动态 GC 调优效果

GC 设置 结束堆大小 GC 中的时间百分比 OPS
默认 649 MB 0.9% 9.2
MaxGCPauseMillis=50ms 560 MB 1.0% 9.2
Xms=Xmx=2048m 2 GB 0.04% 9.2

默认情况下,堆的最小大小为 64 MB,最大大小为 2 GB(由于机器具有 8 GB 物理内存)。在这种情况下,GCTimeRatio 的工作就如预期的那样:堆动态调整为 649 MB,此时应用程序在 GC 中花费的总时间约为总时间的 1%。

在这种情况下设置 MaxGCPauseMillis 标志开始减小堆的大小以满足暂停时间目标。因为在此示例中垃圾收集器的工作量很小,所以它成功地仅花费总时间的 1% 在 GC 中,同时保持了 9.2 OPS 的吞吐量。

最后,请注意,并非总是越多越好。完整的 2 GB 堆确实意味着应用程序在 GC 中花费的时间较少,但在这里 GC 并非主要的性能因素,因此吞吐量并未增加。通常情况下,花费时间优化应用程序的错误区域并没有帮助。

如果将相同的应用程序更改为每个用户在全局缓存中保存先前的 50 个请求(例如,像 JPA 缓存那样),垃圾收集器将需要更加努力。表 6-2 显示了这种情况下的权衡。

表 6-2. 堆占用对动态 GC 调优的影响

GC 设置 最终堆大小 GC 时间百分比 OPS
默认 1.7 GB 9.3% 8.4
MaxGCPauseMillis=50ms 588 MB 15.1% 7.9
Xms=Xmx=2048m 2 GB 5.1% 9.0
Xmx=3560M; MaxGCRatio=19 2.1 GB 8.8% 9.0

在一个花费大量时间在 GC 中的测试中,GC 的行为是不同的。JVM 永远无法满足此测试的 1% 吞吐量目标;它尽力适应默认目标,并做出了合理的工作,使用了 1.7 GB 的空间。

当给出一个不切实际的暂停时间目标时,应用程序的行为变得更糟。为了达到 50 ms 的收集时间,堆保持为 588 MB,但这意味着现在 GC 变得过于频繁。因此,吞吐量显著下降。在这种情况下,更好的性能来自于指示 JVM 通过将初始大小和最大大小都设置为 2 GB 来利用整个堆。

最后,表的最后一行显示了当堆大小合理时会发生的情况,并且我们设置了一个实际的时间比例目标为 5%。JVM 自身确定大约 2 GB 是最佳的堆大小,并且它实现了与手动调优情况相同的吞吐量。

快速总结

  • 动态堆调整是堆大小调整的良好首步。对于大部分应用程序而言,这将是唯一需要的,动态设置将最小化 JVM 的内存使用。

  • 可以静态地调整堆大小以获得最大可能的性能。JVM 为一组合理的性能目标确定的大小是调整的良好起点。

理解 G1 垃圾收集器

G1 GC 在堆内操作离散的区域。每个区域(默认约为 2,048 个)可以属于老年代或新生代,并且代的区域不一定是连续的。在老年代有区域的想法是,当并发后台线程寻找无引用对象时,某些区域将包含比其他区域更多的垃圾。一个区域的实际收集仍然需要停止应用线程,但是 G1 GC 可以专注于主要是垃圾的区域,并且只花一点时间清空这些区域。这种方法——仅清理主要是垃圾的区域——是 G1 GC 名称的由来:垃圾优先。

这不适用于年轻代的区域:在年轻代 GC 期间,整个年轻代要么被释放,要么被晋升(到幸存者空间或老年代)。尽管如此,年轻代是以区域来定义的,部分原因是如果区域预定义,调整大小的代就更容易。

G1 GC 被称为并发收集器,因为在老年代内自由对象的标记与应用线程同时进行(即它们保持运行)。但它并不完全是并发的,因为年轻代的标记和压缩需要停止所有应用线程,并且老年代的压缩也发生在应用线程停止时。

G1 GC 有四个逻辑操作:

  • 年轻代收集

  • 背景,并发标记周期

  • 混合收集

  • 如果需要,进行完整的 GC

我们将依次查看每个操作,从 G1 GC 年轻代收集开始,如图 6-4 所示。

一个 G1 GC young 收集之前和之后堆的图示。

图 6-4. G1 GC 年轻代收集

此图中每个小方块代表一个 G1 GC 区域。每个区域的数据由黑色区域表示,区域内的字母标识其属于的代([E]den,[O]ld generation,[S]urvivor space)。空白区域不属于任何代;G1 GC 根据需要任意使用它们。

当 Eden 填满时(在本例中,填满了四个区域),触发 G1 GC 年轻代收集。收集后,Eden 为空(尽管区域被分配给它,随着应用程序的进行,这些区域将开始填充数据)。至少有一个区域被分配给了幸存者空间(在此示例中部分填充),并且一些数据已经移动到了老年代。

在 G1 中,GC 日志对这个收集过程的描述与其他收集器有些不同。JDK 8 的示例日志使用了PrintGCDetails,但是 G1 GC 的日志细节更加详细。这些示例只展示了一些重要的行。

这是年轻代的标准收集过程:

23.430: [GC pause (young), 0.23094400 secs]
...
   [Eden: 1286M(1286M)->0B(1212M)
   	Survivors: 78M->152M Heap: 1454M(4096M)->242M(4096M)]
   [Times: user=0.85 sys=0.05, real=0.23 secs]

年轻代的收集在真实时间中花费了 0.23 秒,其中 GC 线程消耗了 0.85 秒的 CPU 时间。共移出了 1,286 MB 的对象从伊甸园(自适应调整大小为 1,212 MB),其中 74 MB 移至存活区(其大小从 78 M 增至 152 MB),其余对象被释放。我们通过观察堆总占用减少了 1,212 MB 来确认它们已被释放。在一般情况下,一些存活区的对象可能会被移至老年代,如果存活区满了,一些来自伊甸园的对象则会直接晋升到老年代——在这些情况下,老年代的大小会增加。

JDK 11 中类似的日志如下:

[23.200s][info   ][gc,start     ] GC(10) Pause Young (Normal)
                                           (G1 Evacuation Pause)
[23.200s][info   ][gc,task      ] GC(10) Using 4 workers of 4 for evacuation
[23.430s][info   ][gc,phases    ] GC(10)   Pre Evacuate Collection Set: 0.0ms
[23.430s][info   ][gc,phases    ] GC(10)   Evacuate Collection Set: 230.3ms
[23.430s][info   ][gc,phases    ] GC(10)   Post Evacuate Collection Set: 0.5ms
[23.430s][info   ][gc,phases    ] GC(10)   Other: 0.1ms
[23.430s][info   ][gc,heap      ] GC(10) Eden regions: 643->606(606)
[23.430s][info   ][gc,heap      ] GC(10) Survivor regions: 39->76(76)
[23.430s][info   ][gc,heap      ] GC(10) Old regions: 67->75
[23.430s][info   ][gc,heap      ] GC(10) Humongous regions: 0->0
[23.430s][info   ][gc,metaspace ] GC(10) Metaspace: 18407K->18407K(1067008K)
[23.430s][info   ][gc           ] GC(10) Pause Young (Normal)
                                           (G1 Evacuation Pause)
                                           1454M(4096M)->242M(4096M) 230.104ms
[23.430s][info   ][gc,cpu       ] GC(10) User=0.85s Sys=0.05s Real=0.23s

并发的 G1 GC 周期开始和结束如图 6-5 所示。

一个展示 G1 并发周期前后堆的图表。

Figure 6-5. G1 GC 执行的并发收集

该图表显示了三个要观察的重要点。首先,年轻代已经改变了其占用:在并发周期内可能会有至少一个(甚至更多)年轻代收集。因此,在标记周期之前的伊甸园区域已经完全释放,并且开始分配新的伊甸园区域。

其次,现在一些区域被标记为 X。这些区域属于老年代(请注意它们仍然包含数据)——这些是标记周期确定包含大部分垃圾的区域。

最后,请注意,老年代(由带有 O 或 X 标记的区域组成)在周期完成后实际上更加占用。这是因为标记周期期间发生的年轻代收集将数据晋升到了老年代。此外,标记周期实际上并不释放老年代中的任何数据:它只是识别大部分是垃圾的区域。这些区域的数据将在稍后的周期中释放。

G1 GC 的并发周期有几个阶段,有些会停止所有应用线程,有些则不会。第一个阶段称为initial-mark(在 JDK 8 中)或concurrent start(在 JDK 11 中)。该阶段停止所有应用线程——部分因为它也执行了年轻代收集,并设置了周期的后续阶段。

在 JDK 8 中,看起来是这样的:

50.541: [GC pause (G1 Evacuation pause) (young) (initial-mark), 0.27767100 secs]
    ... lots of other data ...
    [Eden: 1220M(1220M)->0B(1220M)
    	Survivors: 144M->144M Heap: 3242M(4096M)->2093M(4096M)]
    [Times: user=1.02 sys=0.04, real=0.28 secs]

并在 JDK 11 中:

[50.261s][info   ][gc,start      ] GC(11) Pause Young (Concurrent Start)
                                              (G1 Evacuation Pause)
[50.261s][info   ][gc,task       ] GC(11) Using 4 workers of 4 for evacuation
[50.541s][info   ][gc,phases     ] GC(11)   Pre Evacuate Collection Set: 0.1ms
[50.541s][info   ][gc,phases     ] GC(11)   Evacuate Collection Set: 25.9ms
[50.541s][info   ][gc,phases     ] GC(11)   Post Evacuate Collection Set: 1.7ms
[50.541s][info   ][gc,phases     ] GC(11)   Other: 0.2ms
[50.541s][info   ][gc,heap       ] GC(11) Eden regions: 1220->0(1220)
[50.541s][info   ][gc,heap       ] GC(11) Survivor regions: 144->144(144)
[50.541s][info   ][gc,heap       ] GC(11) Old regions: 1875->1946
[50.541s][info   ][gc,heap       ] GC(11) Humongous regions: 3->3
[50.541s][info   ][gc,metaspace  ] GC(11) Metaspace: 52261K->52261K(1099776K)
[50.541s][info   ][gc            ] GC(11) Pause Young (Concurrent Start)
                                              (G1 Evacuation Pause)
                                              1220M->0B(1220M) 280.055ms
[50.541s][info   ][gc,cpu        ] GC(11) User=1.02s Sys=0.04s Real=0.28s

就像普通的年轻代收集一样,应用线程被停止(持续 0.28 秒),并且年轻代被清空(因此伊甸园最终大小为 0)。从年轻代移动了 71 MB 的数据到老年代。在 JDK 8 中有些难以理解(为 2,093 - 3,242 + 1,220);而 JDK 11 的输出更清晰地显示了这一点。

另一方面,JDK 11 的输出包含了一些我们还没有讨论过的内容的引用。首先是大小以区域而不是 MB 为单位。我们将在本章后面讨论区域大小,但在本示例中,区域大小为 1 MB。此外,JDK 11 还提到了一个新领域:巨大区域。那是老年代的一部分,也将在本章后面讨论。

初始标记或并发开始日志消息宣布后台并发周期已经开始。由于标记周期的初始标记阶段也需要停止所有应用程序线程,所以 G1 GC 利用了年轻代 GC 周期来完成这项工作。将初始标记阶段添加到年轻代 GC 的影响并不大:它使用的 CPU 周期比之前的收集(仅仅是一个普通的年轻代收集)多了 20%,尽管暂停时间略长。(幸运的是,机器上有多余的 CPU 周期供并行 G1 线程使用,否则暂停时间将会更长。)

接下来,G1 GC 扫描根区域:

50.819: [GC concurrent-root-region-scan-start]
51.408: [GC concurrent-root-region-scan-end, 0.5890230]

[50.819s][info ][gc             ] GC(20) Concurrent Cycle
[50.819s][info ][gc,marking     ] GC(20) Concurrent Clear Claimed Marks
[50.828s][info ][gc,marking     ] GC(20) Concurrent Clear Claimed Marks 0.008ms
[50.828s][info ][gc,marking     ] GC(20) Concurrent Scan Root Regions
[51.408s][info ][gc,marking     ] GC(20) Concurrent Scan Root Regions 589.023ms

这个过程需要 0.58 秒,但不会停止应用程序线程;它只使用后台线程。然而,这个阶段不能被年轻代收集打断,因此为这些后台线程提供可用的 CPU 周期至关重要。如果在根区域扫描期间年轻代填满了,那么年轻代收集(已经停止所有应用程序线程)必须等待根扫描完成。实际上,这意味着收集年轻代需要比平常更长的暂停时间。这种情况在 GC 日志中显示如下:

350.994: [GC pause (young)
	351.093: [GC concurrent-root-region-scan-end, 0.6100090]
	351.093: [GC concurrent-mark-start],
	0.37559600 secs]

[350.384s][info][gc,marking   ] GC(50) Concurrent Scan Root Regions
[350.384s][info][gc,marking   ] GC(50) Concurrent Scan Root Regions 610.364ms
[350.994s][info][gc,marking   ] GC(50) Concurrent Mark (350.994s)
[350.994s][info][gc,marking   ] GC(50) Concurrent Mark From Roots
[350.994s][info][gc,task      ] GC(50) Using 1 workers of 1 for marking
[350.994s][info][gc,start     ] GC(51) Pause Young (Normal) (G1 Evacuation Pause)

这里的 GC 暂停在根区域扫描结束之前开始。在 JDK 8 中,GC 日志中的交错输出表明年轻代收集必须暂停等待根区域扫描完成才能继续进行。在 JDK 11 中,这有点难以检测:你必须注意到根区域扫描结束的时间戳恰好与下一个年轻代收集开始的时间戳相同。

无论哪种情况,都无法准确知道年轻代收集延迟了多长时间。在这个例子中,它并不一定会延迟整整 610 毫秒;在那段时间内(直到年轻代实际填满),事情仍在继续。但在这种情况下,时间戳显示应用程序线程等待了额外的约 100 毫秒—这就是为什么年轻代 GC 暂停的持续时间比日志中其他暂停的平均持续时间长约 100 毫秒的原因(如果这种情况经常发生,这表明 G1 GC 需要更好地调整,如下一节所讨论的)。

在根区域扫描之后,G1 GC 进入并发标记阶段。这完全在后台进行;开始和结束时会打印一条消息:

111.382: [GC concurrent-mark-start]
....
120.905: [GC concurrent-mark-end, 9.5225160 sec]

[111.382s][info][gc,marking   ] GC(20) Concurrent Mark (111.382s)
[111.382s][info][gc,marking   ] GC(20) Concurrent Mark From Roots
...
[120.905s][info][gc,marking   ] GC(20) Concurrent Mark From Roots 9521.994ms
[120.910s][info][gc,marking   ] GC(20) Concurrent Preclean
[120.910s][info][gc,marking   ] GC(20) Concurrent Preclean 0.522ms
[120.910s][info][gc,marking   ] GC(20) Concurrent Mark (111.382s, 120.910s)
                                         9522.516ms

并发标记可以被中断,因此在此阶段可能发生年轻代收集(因此在省略号处会有大量 GC 输出)。

还请注意,在 JDK 11 示例中,输出具有与根区域扫描发生时相同的 GC 记录—20—。我们正在更细化地分解操作,而不像 JDK 日志将整个后台扫描视为一个操作。例如,当并发标记不能时,根扫描可能会引入暂停。

标记阶段后是重新标记阶段和正常的清理阶段:

120.910: [GC remark 120.959:
	[GC ref-PRC, 0.0000890 secs], 0.0718990 secs]
 	[Times: user=0.23 sys=0.01, real=0.08 secs]
120.985: [GC cleanup 3510M->3434M(4096M), 0.0111040 secs]
 	[Times: user=0.04 sys=0.00, real=0.01 secs]

[120.909s][info][gc,start     ] GC(20) Pause Remark
[120.909s][info][gc,stringtable] GC(20) Cleaned string and symbol table,
                                           strings: 1369 processed, 0 removed,
                                           symbols: 17173 processed, 0 removed
[120.985s][info][gc            ] GC(20) Pause Remark 2283M->862M(3666M) 80.412ms
[120.985s][info][gc,cpu        ] GC(20) User=0.23s Sys=0.01s Real=0.08s

这些阶段会停止应用程序线程,尽管通常只是短暂的时间。接下来会同时进行额外的清理阶段:

120.996: [GC concurrent-cleanup-start]
120.996: [GC concurrent-cleanup-end, 0.0004520]

[120.878s][info][gc,start      ] GC(20) Pause Cleanup
[120.879s][info][gc            ] GC(20) Pause Cleanup 1313M->1313M(3666M) 1.192ms
[120.879s][info][gc,cpu        ] GC(20) User=0.00s Sys=0.00s Real=0.00s
[120.879s][info][gc,marking    ] GC(20) Concurrent Cleanup for Next Mark
[120.996s][info][gc,marking    ] GC(20) Concurrent Cleanup for Next Mark
                                          117.168ms
[120.996s][info][gc            ] GC(20) Concurrent Cycle 70,177.506ms

而常规的 G1 GC 后台标记周期完成了——至少在找到垃圾方面如此。但实际上,很少有内存被释放。在清理阶段中回收了一点内存,但到目前为止,G1 GC 真正做的只是识别出大部分是垃圾并可以回收的旧区域(在 图 6-5 中用 X 标记的区域)。

现在 G1 GC 执行一系列混合 GC。它们被称为“混合”是因为它们执行了正常的年轻代收集,同时也收集了后台扫描中的一些标记区域。混合 GC 的效果显示在 图 6-6 中。

就像对于年轻代的收集一样,G1 GC 已经完全清空了 Eden 区并调整了幸存者空间。此外,两个标记的区域已被收集。这些区域已知主要包含垃圾,因此它们的大部分被释放了。这些区域中的任何存活数据都被移到另一个区域(就像从年轻代中的区域移到老年代的区域中的存活数据一样)。这就是 G1 GC 如何压缩老年代的方式——在执行时移动对象实质上是压缩堆。

G1 GC 混合收集前后堆的示意图。

图 6-6. G1 GC 执行的混合 GC

混合 GC 操作通常在日志中看起来是这样的:

79.826: [GC pause (mixed), 0.26161600 secs]
....
   [Eden: 1222M(1222M)->0B(1220M)
   	Survivors: 142M->144M Heap: 3200M(4096M)->1964M(4096M)]
   [Times: user=1.01 sys=0.00, real=0.26 secs]

[3.800s][info][gc,start      ] GC(24) Pause Young (Mixed) (G1 Evacuation Pause)
[3.800s][info][gc,task       ] GC(24) Using 4 workers of 4 for evacuation
[3.800s][info][gc,phases     ] GC(24)   Pre Evacuate Collection Set: 0.2ms
[3.825s][info][gc,phases     ] GC(24)   Evacuate Collection Set: 250.3ms
[3.826s][info][gc,phases     ] GC(24)   Post Evacuate Collection Set: 0.3ms
[3.826s][info][gc,phases     ] GC(24)   Other: 0.4ms
[3.826s][info][gc,heap       ] GC(24) Eden regions: 1222->0(1220)
[3.826s][info][gc,heap       ] GC(24) Survivor regions: 142->144(144)
[3.826s][info][gc,heap       ] GC(24) Old regions: 1834->1820
[3.826s][info][gc,heap       ] GC(24) Humongous regions: 4->4
[3.826s][info][gc,metaspace  ] GC(24) Metaspace: 3750K->3750K(1056768K)
[3.826s][info][gc            ] GC(24) Pause Young (Mixed) (G1 Evacuation Pause)
                                          3791M->3791M(3983M) 124.390ms
[3.826s][info][gc,cpu        ] GC(24) User=1.01s Sys=0.00s Real=0.26s
[3.826s][info][gc,start      ] GC(25) Pause Young (Mixed) (G1 Evacuation Pause)

注意,整个堆的使用情况已经减少了不止从 Eden 中移除的 1,222 MB。这个差异(16 MB)看起来很小,但要记住,同时一些幸存者空间被提升到老年代;此外,每个混合 GC 仅清理了目标老年代区域的一部分。随着我们的继续,你会发现确保混合 GC 清理足够的内存以防止未来并发故障是很重要的。

在 JDK 11 中,第一个混合 GC 被标记为 Prepared Mixed 并紧随并发清理之后。

混合 GC 循环将继续,直到几乎所有标记的区域都被收集,此时 G1 GC 将恢复常规的年轻代 GC 周期。最终,G1 GC 将开始另一个并发周期,确定应该释放老年代的哪些区域。

虽然混合 GC 循环通常在 GC 原因中标记为(Mixed),但有时在并发循环后(即G1 Evacuation Pause)会正常标记年轻代收集。如果并发循环发现老年代中可以完全释放的区域,则这些区域会在常规的年轻代撤离暂停期间被回收。技术上来说,这不是收集器实现中的混合循环。但从逻辑上讲,是的:对象从年轻代被释放或晋升到老年代,同时垃圾对象(实际上是区域)从老年代被释放。

如果一切顺利,这就是您在 GC 日志中看到的所有 GC 活动集。但还有一些失败案例需要考虑。

有时您会在日志中观察到完整 GC,这表明需要更多调整(包括可能增加堆空间)以提高应用程序性能。主要触发这种情况的是四次:

并发模式失败

G1 GC 启动标记周期,但在完成周期之前,老年代已满。在这种情况下,G1 GC 中止标记周期:

51.408: [GC concurrent-mark-start]
65.473: [Full GC 4095M->1395M(4096M), 6.1963770 secs]
 [Times: user=7.87 sys=0.00, real=6.20 secs]
71.669: [GC concurrent-mark-abort]

[51.408][info][gc,marking     ] GC(30) Concurrent Mark From Roots
...
[65.473][info][gc             ] GC(32) Pause Full (G1 Evacuation Pause)
                                          4095M->1305M(4096M) 60,196.377
...
[71.669s][info][gc,marking     ] GC(30) Concurrent Mark From Roots 191ms
[71.669s][info][gc,marking     ] GC(30) Concurrent Mark Abort

这意味着应该增加堆大小,必须尽快开始 G1 GC 后台处理,或者必须调整周期以更快运行(例如使用额外的后台线程)。如何执行这些操作的详细信息如下。

晋升失败

G1 GC 已完成标记周期,并开始执行混合 GC 来清理老区域。在清理足够空间之前,从年轻代晋升的对象太多,因此老年代仍然空间不足。在日志中,混合 GC 立即跟随完整 GC:

2226.224: [GC pause (mixed)
	2226.440: [SoftReference, 0 refs, 0.0000060 secs]
	2226.441: [WeakReference, 0 refs, 0.0000020 secs]
	2226.441: [FinalReference, 0 refs, 0.0000010 secs]
	2226.441: [PhantomReference, 0 refs, 0.0000010 secs]
	2226.441: [JNI Weak Reference, 0.0000030 secs]
		(to-space exhausted), 0.2390040 secs]
....
    [Eden: 0.0B(400.0M)->0.0B(400.0M)
    	Survivors: 0.0B->0.0B Heap: 2006.4M(2048.0M)->2006.4M(2048.0M)]
    [Times: user=1.70 sys=0.04, real=0.26 secs]
2226.510: [Full GC (Allocation Failure)
	2227.519: [SoftReference, 4329 refs, 0.0005520 secs]
	2227.520: [WeakReference, 12646 refs, 0.0010510 secs]
	2227.521: [FinalReference, 7538 refs, 0.0005660 secs]
	2227.521: [PhantomReference, 168 refs, 0.0000120 secs]
	2227.521: [JNI Weak Reference, 0.0000020 secs]
		2006M->907M(2048M), 4.1615450 secs]
    [Times: user=6.76 sys=0.01, real=4.16 secs]

[2226.224s][info][gc            ] GC(26) Pause Young (Mixed)
                                            (G1 Evacuation Pause)
                                            2048M->2006M(2048M) 26.129ms
...
[2226.510s][info][gc,start      ] GC(27) Pause Full (G1 Evacuation Pause)

这种失败意味着混合收集需要更快地进行;每个年轻代收集需要处理更多老年代的区域。

撤离失败

在进行年轻代收集时,幸存空间和老年代中没有足够的空间来容纳所有幸存对象。这会在 GC 日志中出现作为特定类型的年轻代 GC:

60.238: [GC pause (young) (to-space overflow), 0.41546900 secs]

[60.238s][info][gc,start       ] GC(28) Pause Young (Concurrent Start)
                                          (G1 Evacuation Pause)
[60.238s][info][gc,task        ] GC(28) Using 4 workers of 4
                                          for evacuation
[60.238s][info][gc             ] GC(28) To-space exhausted

这表明堆大部分已满或碎片化。G1 GC 会尝试补偿,但可能会最终执行完整的 GC。简单的解决方法是增加堆大小,虽然“高级调整”中提供了其他可能的解决方案。

巨大分配失败

分配非常大对象的应用程序可能会触发 G1 GC 中的另一种完整 GC;详细信息请参见“G1 GC 分配巨大对象”(包括如何避免)。在 JDK 8 中,除非使用特殊的日志参数,否则无法诊断这种情况,但在 JDK 11 中,可以通过此日志显示:

[3023.091s][info][gc,start     ] GC(54) Pause Full (G1 Humongous Allocation)

元数据 GC 阈值

正如我提到的,元空间本质上是一个独立的堆,与主堆独立收集。它不通过 G1 GC 进行收集,但是当 JDK 8 需要收集时,G1 GC 将在主堆上执行完整的 GC(立即在年轻代收集之前):

0.0535: [GC (Metadata GC Threshold) [PSYoungGen: 34113K->20388K(291328K)]
    73838K->60121K(794112K), 0.0282912 secs]
    [Times: user=0.05 sys=0.01, real=0.03 secs]
0.0566: [Full GC (Metadata GC Threshold) [PSYoungGen: 20388K->0K(291328K)]
    [ParOldGen: 39732K->46178K(584192K)] 60121K->46178K(875520K),
    [Metaspace: 59040K->59036K(1101824K)], 0.1121237 secs]
    [Times: user=0.28 sys=0.01, real=0.11 secs]

在 JDK 11 中,元空间可以在不需要完整 GC 的情况下进行收集/调整大小。

快速总结

  • G1 有多个循环(并发循环内的阶段)。运行 G1 的良好调整的 JVM 应该只会经历年轻代、混合和并发 GC 循环。

  • 在一些 G1 并发阶段会发生小的暂停。

  • 如果需要避免完整的 GC 循环,则应对 G1 进行调整。

G1 GC 的调整

调整 G1 GC 的主要目标是确保没有并发模式或疏散故障会导致需要进行完整的 GC。用于防止完整 GC 的技术也可以在频繁的年轻 GC 必须等待根区域扫描完成时使用。

在 JDK 8 中,调整以避免完整的收集是至关重要的,因为当 G1 GC 在 JDK 8 中执行完整的 GC 时,它会使用一个线程。这会导致比通常更长的暂停时间。在 JDK 11 中,完整的 GC 由多个线程执行,导致较短的暂停时间(基本上与使用吞吐量收集器执行完整 GC 的暂停时间相同)。这种差异是在使用 G1 GC 时更喜欢升级到 JDK 11 的一个原因(尽管一个避免完整 GC 的 JDK 8 应用程序也会表现良好)。

其次,调整可以尽量减少沿途发生的暂停。

这些是防止完整 GC 的选项:

  • 增加老年代的大小,可以通过增加总堆空间或调整两代之间的比率来实现。

  • 增加后台线程的数量(假设有足够的 CPU)。

  • 更频繁地执行 G1 GC 后台活动。

  • 增加在混合 GC 循环中完成的工作量。

这里可以应用很多调整,但是 G1 GC 的目标之一是不需要进行太多的调整。为此,G1 GC 主要通过一个标志进行调整:与用于调整吞吐量收集器的相同 -XX:MaxGCPauseMillis=N 标志。

当与 G1 GC 结合使用时(与吞吐量收集器不同),该标志确实具有默认值:200 毫秒。如果 G1 GC 的停止世界阶段的暂停开始超过该值,G1 GC 将尝试进行补偿 — 调整年轻代和老年代的比例、调整堆大小、更早地启动后台处理、更改保留阈值以及(最重要的)在混合 GC 循环期间处理更多或更少的老年代区域。

这里存在一些权衡:如果该值减小,年轻代大小将会收缩以达到暂停时间目标,但会执行更频繁的年轻代 GC。此外,在混合 GC 期间可以收集的老年代区域数量将减少以达到暂停时间目标,这会增加并发模式失败的可能性。

如果设置暂停时间目标不能防止发生全局 GC,可以分别调整这些不同的方面。为 G1 GC 调整堆大小的方法与其他 GC 算法相同。

调整 G1 背景线程

你可以将 G1 GC 的并发标记视为与应用程序线程的竞争:G1 GC 必须更快地清除旧一代,以防止应用程序将新数据提升到其中。要实现这一点,可以尝试增加后台标记线程的数量(假设机器上有足够的 CPU 可用)。

G1 GC 使用两组线程。第一组线程由 -XX:ParallelGCThreads=*N* 标志控制,你在 第五章 中首次看到了这个标志。该值影响停止应用程序线程时使用的线程数:年轻和混合收集以及必须停止线程的并发备注周期的阶段。第二个标志是 -XX:ConcGCThreads=*N*,它影响用于并发备注的线程数。

ConcGCThreads 标志的默认值定义如下:

ConcGCThreads = (ParallelGCThreads + 2) / 4

这个划分是基于整数的,因此将有一个后台扫描线程对应五个并行线程,两个后台扫描线程对应六到九个并行线程,依此类推。

增加后台扫描线程的数量将使并发周期变短,这应该会使 G1 GC 在混合 GC 周期结束前更容易释放旧一代,而不会被其他线程再次填满。一如既往,这假设 CPU 周期是可用的;否则,扫描线程将从应用程序中取走 CPU,并有效地引入暂停,就像我们在 第五章 中将串行收集器与 G1 GC 进行比较时看到的那样。

调整 G1 GC 的运行频率(更频繁或更少)

G1 GC 也可以在更早地开始后台标记周期时赢得竞争。该周期从堆达到由 -XX:InitiatingHeapOccupancyPercent=N 指定的占用率开始,其默认值为 45。此百分比指的是整个堆,而不仅仅是旧一代。

InitiatingHeapOccupancyPercent 值是恒定的;G1 GC 在尝试满足其暂停时间目标时不会更改该数字。如果该值设置得太高,应用程序将执行全局 GC,因为并发阶段没有足够的时间来完成,而其余堆已经填满。如果该值太小,应用程序将执行比通常更多的后台 GC 处理。

当然,那些后台线程在某个时候需要运行,因此硬件应该有足够的 CPU 来容纳它们。但是,如果运行太频繁,可能会导致严重的惩罚,因为那些停止应用程序线程的并发阶段将会有更多的小暂停。这些暂停会迅速积累,因此应该避免对 G1 GC 进行过于频繁的后台扫描。在并发循环后检查堆的大小,并确保 InitiatingHeapOccupancyPercent 的值高于该值。

调整 G1 GC 混合 GC 循环

在并发循环之后,G1 GC 不能开始新的并发循环,直到旧代中所有先前标记的区域都已被收集。因此,使 G1 GC 更早开始标记循环的另一种方法是在混合 GC 循环中处理更多区域(这样最终混合 GC 循环将减少)。

混合 GC 所做的工作量取决于三个因素。第一个因素是在第一次检测中发现的大部分垃圾的区域数量。没有直接影响这一点的方法:在混合 GC 中,如果一个区域的垃圾量达到 85%,则宣布其可收集。

第二个因素是 G1 GC 处理这些区域的最大混合 GC 循环数,由标志 -XX:G1Mixed``GCCountTarget=N 指定。默认值为 8;减少该值有助于克服晋升失败(但会延长混合 GC 循环的暂停时间)。

另一方面,如果混合 GC 暂停时间过长,可以增加该值,以减少混合 GC 过程中的工作量。只需确保增加该数字不会过长延迟下一个 G1 GC 并发循环,否则可能会导致并发模式失败。

最后,第三个因素是 GC 暂停的最大期望长度(即由 MaxGCPauseMillis 指定的值)。由 G1MixedGCCountTarget 标志指定的混合循环数是一个上限;如果在暂停目标时间内有时间,则 G1 GC 将收集超过已标记的旧代区域的八分之一(或者指定的任何值)。增加 MaxGCPauseMillis 标志的值允许在每个混合 GC 中收集更多旧代区域,从而允许 G1 GC 更早开始下一个并发循环。

快速总结

  • G1 GC 调优应始于设定合理的暂停时间目标。

  • 如果这样做后仍然存在全 GC 问题,并且无法增加堆大小,则可以针对特定失败应用特定调整:

    • 要使后台线程更频繁运行,请调整 InitiatingHeapOccupancyPercent

    • 如果有额外的 CPU 可用,通过 ConcGCThreads 标志调整线程数。

    • 为防止晋升失败,减少 G1MixedGCCountTarget 的大小。

了解 CMS 收集器

尽管 CMS 收集器已被弃用,但它仍然在当前 JDK 构建中可用。因此,本节介绍了如何调优它以及它为何被弃用的原因。

CMS 有三个基本操作:

  • 收集年轻代(停止所有应用程序线程)

  • 运行并发循环以清理老年代中的数据

  • 执行全局 GC 以压缩老年代(如有必要)

在 图 6-7 中展示了年轻代的 CMS 收集。

CMS 年轻代收集前后堆的图示。

图 6-7. CMS 执行的年轻代收集

CMS 年轻代收集类似于吞吐量年轻代收集:数据从伊甸园移动到一个幸存者空间(如果幸存者空间填满则移入老年代)。

CMS 的 GC 日志条目也类似(我仅展示 JDK 8 的日志格式):

89.853: [GC 89.853: [ParNew: 629120K->69888K(629120K), 0.1218970 secs]
		1303940K->772142K(2027264K), 0.1220090 secs]
		[Times: user=0.42 sys=0.02, real=0.12 secs]

当前年轻代的大小为 629 MB;收集后,其中有 69 MB 保留在幸存者空间。同样,整个堆的大小为 2,027 MB,在收集后占用了 772 MB。整个过程耗时 0.12 秒,尽管并行 GC 线程累计 CPU 使用时间为 0.42 秒。

在 图 6-8 中展示了一个并发循环。

CMS 根据堆的占用情况启动并发循环。当堆充分填满时,会启动背景线程遍历堆并移除对象。循环结束时,堆看起来像图中的底部行所示。请注意,老年代没有压缩:有些区域分配了对象,有些是空闲区域。当年轻代收集将对象从伊甸园移入老年代时,JVM 将尝试使用这些空闲区域来容纳对象。通常这些对象无法完全放入一个空闲区域,这就是为什么在 CMS 循环之后,堆的高水位标记更大的原因。

CMS 并发循环前后堆的图示。

图 6-8. CMS 执行的并发收集

在 GC 日志中,此周期显示为多个阶段。尽管大多数并发循环使用后台线程,某些阶段会引入短暂的暂停,停止所有应用程序线程。

并发循环从初始标记阶段开始,停止所有应用程序线程:

89.976: [GC [1 CMS-initial-mark: 702254K(1398144K)]
		772530K(2027264K), 0.0830120 secs]
		[Times: user=0.08 sys=0.00, real=0.08 secs]

这个阶段负责在堆中找到所有的 GC 根对象。第一组数字显示,目前占用老年代的 702 MB,总共 1,398 MB,而第二组数字显示整个 2,027 MB 堆的占用为 772 MB。在 CMS 周期的这个阶段,应用程序线程停止了 0.08 秒。

下一个阶段是标记阶段,不会停止应用程序线程。GC 日志中的这些行代表这个阶段:

90.059: [CMS-concurrent-mark-start]
90.887: [CMS-concurrent-mark: 0.823/0.828 secs]
		[Times: user=1.11 sys=0.00, real=0.83 secs]

标记阶段花费了 0.83 秒(和 1.11 秒的 CPU 时间)。由于这只是一个标记阶段,它对堆占用并没有做任何操作,因此关于此方面的数据未显示。如果有数据的话,可能会显示在这 0.83 秒内,由于应用线程继续执行,年轻代中分配对象导致堆的增长。

再来是预清理阶段,该阶段也与应用线程并发运行:

90.887: [CMS-concurrent-preclean-start]
90.892: [CMS-concurrent-preclean: 0.005/0.005 secs]
		[Times: user=0.01 sys=0.00, real=0.01 secs]

下一个阶段是备注阶段,但它涉及几个操作:

90.892: [CMS-concurrent-abortable-preclean-start]
92.392: [GC 92.393: [ParNew: 629120K->69888K(629120K), 0.1289040 secs]
		1331374K->803967K(2027264K), 0.1290200 secs]
		[Times: user=0.44 sys=0.01, real=0.12 secs]
94.473: [CMS-concurrent-abortable-preclean: 3.451/3.581 secs]
		[Times: user=5.03 sys=0.03, real=3.58 secs]

94.474: [GC[YG occupancy: 466937 K (629120 K)]
	94.474: [Rescan (parallel) , 0.1850000 secs]
	94.659: [weak refs processing, 0.0000370 secs]
	94.659: [scrub string table, 0.0011530 secs]
		[1 CMS-remark: 734079K(1398144K)]
		1201017K(2027264K), 0.1863430 secs]
	[Times: user=0.60 sys=0.01, real=0.18 secs]

等等,CMS 刚执行了一个预清理阶段?那么这个可中止的预清理阶段又是什么?

使用可中止的预清理阶段是因为备注阶段(严格来说,在输出中是最后一个条目)不是并发的——它将停止所有应用线程。CMS 希望避免年轻代收集紧随备注阶段之后发生的情况,这种情况下,应用线程将因连续的暂停操作而停止。这里的目标是通过防止连续暂停来最小化暂停长度。

因此,可中止的预清理阶段会等待年轻代填满约 50%。理论上,这是在年轻代收集之间的一半,为 CMS 避免连续出现暂停提供了最佳机会。在此示例中,可中止的预清理阶段从 90.8 秒开始,并等待大约 1.5 秒以进行常规年轻代收集(在日志的 92.392 秒处)。CMS 使用过去的行为来计算下一次可能发生的年轻代收集时间——在这种情况下,CMS 计算大约在 4.2 秒后会发生年轻代收集。因此在 2.1 秒后(在 94.4 秒时),CMS 结束了预清理阶段(虽然这是唯一停止该阶段的方法,但 CMS 称其为“中止”该阶段)。然后,最后,CMS 执行了备注阶段,导致应用线程暂停了 0.18 秒(在可中止的预清理阶段期间应用线程没有暂停)。

接下来是另一个并发阶段——扫描阶段:

94.661: [CMS-concurrent-sweep-start]
95.223: [GC 95.223: [ParNew: 629120K->69888K(629120K), 0.1322530 secs]
		999428K->472094K(2027264K), 0.1323690 secs]
		[Times: user=0.43 sys=0.00, real=0.13 secs]
95.474: [CMS-concurrent-sweep: 0.680/0.813 secs]
		[Times: user=1.45 sys=0.00, real=0.82 secs]

此阶段花费了 0.82 秒,并与应用线程并发运行。它还碰巧被一个年轻代收集中断了。这个年轻代收集与扫描阶段无关,但作为一个例子留在这里,显示年轻代收集可以与老年代收集阶段同时发生。在图 6-8 中,请注意在并发收集期间年轻代的状态发生了变化——在扫描阶段期间可能发生了任意数量的年轻代收集(由于可中止的预清理阶段至少会有一次年轻代收集)。

接下来是并发重置阶段:

95.474: [CMS-concurrent-reset-start]
95.479: [CMS-concurrent-reset: 0.005/0.005 secs]
	[Times: user=0.00 sys=0.00, real=0.00 secs]

这是并发阶段的最后一步;CMS 周期完成了,老年代中发现的未引用对象现在是自由的(导致堆中显示的情况见图 6-8)。不幸的是,日志没有提供任何有关释放了多少对象的信息;重置行也没有提供堆占用的信息。要了解这一点,请看下一个年轻收集:

98.049: [GC 98.049: [ParNew: 629120K->69888K(629120K), 0.1487040 secs]
		1031326K->504955K(2027264K), 0.1488730 secs]

现在比较老年代在 89.853 秒时的占用情况(CMS 周期开始之前),大约是 703 MB(此时整个堆占用了 772 MB,其中包括 69 MB 在幸存者空间,因此老年代消耗了剩余的 703 MB)。在 98.049 秒的收集中,老年代占用约 504 MB;因此 CMS 周期清理了大约 199 MB 的内存。

如果一切顺利,这些将是 CMS 运行的唯一周期,也是 CMS GC 日志中出现的唯一日志消息。但是还有三条更多的消息需要注意,这些消息表明 CMS 遇到了问题。第一条是并发模式失败:

267.006: [GC 267.006: [ParNew: 629120K->629120K(629120K), 0.0000200 secs]
	267.006: [CMS267.350: [CMS-concurrent-mark: 2.683/2.804 secs]
	[Times: user=4.81 sys=0.02, real=2.80 secs]
 	(concurrent mode failure):
	1378132K->1366755K(1398144K), 5.6213320 secs]
	2007252K->1366755K(2027264K),
	[CMS Perm : 57231K->57222K(95548K)], 5.6215150 secs]
	[Times: user=5.63 sys=0.00, real=5.62 secs]

当发生年轻收集并且老年代没有足够空间来容纳预期晋升的所有对象时,CMS 执行的基本上是完整 GC。所有应用程序线程都会停止,并且老年代中的任何死对象都会被清理,将其占用减少到 1,366 MB —— 这个操作使应用程序线程暂停了整整 5.6 秒。这个操作是单线程的,这也是它执行时间如此之长的一个原因(并且也是堆增长时并发模式失败变得更糟糕的一个原因)。

这种并发模式失败是 CMS 被弃用的一个主要原因。G1 GC 可能会发生并发模式失败,但是当它转回到完整 GC 时,在 JDK 11 中会并行执行该完整 GC(尽管在 JDK 8 中不会)。CMS 完整 GC 执行时间会长很多倍,因为它必须在单线程中执行。²

第二个问题发生在老年代有足够空间来容纳晋升的对象,但是空闲空间碎片化,所以晋升失败:

6043.903: [GC 6043.903:
	[ParNew (promotion failed): 614254K->629120K(629120K), 0.1619839 secs]
	6044.217: [CMS: 1342523K->1336533K(2027264K), 30.7884210 secs]
	2004251K->1336533K(1398144K),
	[CMS Perm : 57231K->57231K(95548K)], 28.1361340 secs]
	[Times: user=28.13 sys=0.38, real=28.13 secs]

在这里,CMS 启动了一个年轻的收集,并假设存在空间来容纳所有晋升的对象(否则,它会声明并发模式失败)。这一假设被证明是不正确的:CMS 无法晋升对象,因为老年代是碎片化的(或者,少见的情况是,要晋升的内存量大于 CMS 预期的量)。

结果,在年轻收集过程中(当所有线程已经停止时),CMS 收集并压缩了整个老年代。好消息是,通过堆的压缩,碎片问题已经解决(至少暂时解决了)。但是这导致了长达 28 秒的暂停时间。这个时间比 CMS 发生并发模式失败时要长得多,因为整个堆被压缩;而并发模式失败只是简单地释放了堆中的对象。此时的堆看起来就像吞吐收集器的完全 GC 结束时一样(图 6-2):年轻代完全为空,老年代已经被压缩。

最后,CMS 日志可能显示完全 GC,但没有任何常规的并发 GC 消息:

279.803: [Full GC 279.803:
		[CMS: 88569K->68870K(1398144K), 0.6714090 secs]
		558070K->68870K(2027264K),
		[CMS Perm : 81919K->77654K(81920K)],
		0.6716570 secs]

当元空间填满并且需要收集时,会发生这种情况。CMS 不会收集元空间,因此如果填满了,需要进行完全 GC 来丢弃任何未引用的类。“高级调整” 显示了如何解决这个问题。

快速总结

  • CMS 有几个 GC 操作,但预期的操作是小 GC 和并发周期。

  • CMS 中的并发模式失败和推广失败都很昂贵;应尽量调整 CMS 以避免这些问题。

  • 默认情况下,CMS 不会收集元空间。

调整以解决并发模式失败

在调整 CMS 时的主要关注点是确保不会发生并发模式或推广失败。正如 CMS GC 日志所示,发生并发模式失败是因为 CMS 没有及时清理老年代:当需要在年轻代执行收集时,CMS 计算到它没有足够的空间来晋升这些对象到老年代,于是首先收集老年代。

老年代最初通过将对象放置在彼此相邻的位置来填充。当老年代填充了一定量(默认为 70%)时,并发周期开始,并且后台 CMS 线程开始扫描老年代的垃圾。此时比赛开始:CMS 必须在老年代扫描和释放对象完成之前(剩余的 30% 填充),完成扫描老年代。如果并发周期失败,CMS 将经历并发模式失败。

我们可以尝试多种方法来避免这种失败:

  • 增加老年代的大小,可以通过将新生代与老年代的比例调整或完全添加更多堆空间来实现。

  • 更频繁地运行后台线程。

  • 使用更多后台线程。

如果有更多内存可用,更好的解决方案是增加堆的大小。否则,更改后台线程的操作方式。

更频繁地运行后台线程

让 CMS 赢得竞争的一种方法是更早地启动并发周期。如果并发周期在老年代填充了 60% 时开始,CMS 完成的机会就比在老年代填充了 70% 时开始的机会更大。实现这一点的最简单方法是设置这两个标志:

  • -XX:CMSInitiatingOccupancyFraction=N

  • -XX:+UseCMSInitiatingOccupancyOnly

同时使用这两个标志也使 CMS 更容易理解:如果两者都设置了,CMS 仅根据填充的老年代的百分比确定何时启动后台线程。(请注意,与 G1 GC 不同,在这里,占用比率仅为老年代,而不是整个堆。)

默认情况下,UseCMSInitiatingOccupancyOnly 标志为 false,CMS 使用更复杂的算法来确定何时启动后台线程。如果需要更早地启动后台线程,则最好以最简单的方式启动它,并将 UseCMSInitiatingOccupancyOnly 标志设置为 true

调整 CMSInitiatingOccupancyFraction 的值可能需要几次迭代。如果启用了 UseCMSInitiatingOccupancyOnly,则 CMSInitiatingOccupancyFraction 的默认值为 70:当老年代占用率为 70% 时,CMS 循环启动。

对于给定应用程序来说,该标志的更好值可以通过在 GC 日志中找到 CMS 循环失败开始的时间来确定。在日志中查找并发模式失败,然后回溯到最近的 CMS 循环开始的时间。CMS-initial-mark 行将显示 CMS 循环开始时老年代的填充程度:

89.976: [GC [1 CMS-initial-mark: 702254K(1398144K)]
		772530K(2027264K), 0.0830120 secs]
		[Times: user=0.08 sys=0.00, real=0.08 secs]

在此示例中,这大约为 50%(1,398 MB 中的 702 MB)。这还不够早,因此 CMSInitiatingOccupancyFraction 需要设置为低于 50 的值。(尽管该标志的默认值为 70,但此示例在老年代填充了 50% 时启动了 CMS 线程,因为未设置 UseCMSInitiatingOccupancyOnly 标志。)

这里的诱惑是将值设置为 0 或另一个较小的数字,以便后台 CMS 循环始终运行。通常不鼓励这样做,但只要您意识到正在做出的权衡,它可能会很好地解决问题。

第一个权衡出现在 CPU 时间上:CMS 后台线程将持续运行,并且它们会消耗相当多的 CPU —— 每个后台 CMS 线程将消耗一个 CPU 的 100%。当多个 CMS 线程运行并且作为结果总 CPU 占用率急剧上升时,也会有非常短暂的爆发。如果这些线程是不必要地运行,则会浪费 CPU 资源。

另一方面,使用这些 CPU 循环并不一定是个问题。即使在最佳情况下,后台 CMS 线程有时也必须运行。因此,机器必须始终有足够的 CPU 循环可用于运行这些 CMS 线程。因此,在确定机器大小时,您必须计划 CPU 的使用情况。

第二个折中方案更为重要,与暂停有关。正如 GC 日志所示,CMS 周期的某些阶段会停止所有应用线程。CMS 被使用的主要原因是为了限制 GC 暂停的影响,因此比需要的更频繁地运行 CMS 是得不偿失的。CMS 暂停通常比年轻代暂停短得多,特定应用程序可能不会对这些额外的暂停敏感——这是额外暂停与减少并发模式失败机会之间的折中。但是持续运行后台 GC 暂停可能会导致过度的总体暂停,最终会降低应用程序的性能。

除非那些折中方案是可以接受的,否则要注意不要将CMSInitiatingOccupancyFraction设置得比堆中的活动数据量高,至少要高出 10%到 20%。

调整 CMS 后台线程

每个 CMS 后台线程将在机器上占用 100% 的 CPU。如果应用程序遇到并发模式失败并且有额外的 CPU 周期可用,则可以通过设置 -XX:ConcGCThreads=N 标志来增加这些后台线程的数量。CMS 与 G1 GC 设置此标志的方式不同;它使用以下计算:

ConcGCThreads = (3 + ParallelGCThreads) / 4

因此,CMS 在比 G1 GC 更早的阶段增加了ConcGCThreads的值。

快速摘要

  • 避免并发模式失败是实现 CMS 最佳性能的关键。

  • 避免这些失败的最简单方法(如果可能的话)是增加堆的大小。

  • 否则,下一步是通过调整CMSInitiatingOccupancy​Frac⁠tion来更早地启动并发后台线程。

  • 调整后台线程的数量也可能有所帮助。

高级调整

关于调整的这一部分涵盖了一些相当不寻常的情况。尽管不经常遇到这些情况,但是本节解释了 GC 算法的许多底层细节。

续寿和幸存者空间

当年轻代被收集时,一些对象仍然存活。这不仅包括了那些注定会存在很长时间的新创建对象,还包括了其他短暂存在的对象。考虑一下在第五章中的BigDecimal计算的循环。如果 JVM 在该循环中间执行 GC,那么其中一些短命的 BigDecimal 对象就会不幸:它们刚刚被创建并且正在使用中,因此无法释放,但它们也不会存活足够长的时间以证明将它们移到老年代是合理的。

这就是为什么年轻代被分为两个幸存者空间和伊甸园的原因。这种设置允许对象在仍然在年轻代时有额外的机会被收集,而不是被提升到(并填满)老年代。

当年轻一代对象被收集时,JVM 发现仍然存活的对象,将其移动到幸存者空间而不是老年代。在第一次年轻一代收集期间,对象从伊甸园移动到幸存者空间 0。在下一次收集期间,活动对象从幸存者空间 0 和伊甸园移动到幸存者空间 1。此时,伊甸园和幸存者空间 0 完全为空。下一次收集将活动对象从幸存者空间 1 和伊甸园移动到幸存者空间 0,依此类推。(幸存者空间也被称为tofrom空间;在每次收集期间,对象从from空间移动到to空间。fromto只是在每次收集期间在两个幸存者空间之间切换的指针。)

显然,这种情况不能永远持续,否则就不会将任何对象移入老年代。对象在两种情况下被移入老年代。首先,幸存者空间相对较小。当在年轻代收集期间目标幸存者空间填满时,伊甸园中剩余的活动对象直接移入老年代。其次,对象在幸存者空间中可以保留的 GC 周期数量存在限制。该限制称为tenuring threshold

调优可能会影响到这些情况之一。幸存者空间占用年轻代分配的一部分,并且像堆的其他区域一样,JVM 动态调整它们的大小。幸存者空间的初始大小由-XX:InitialSurvivorRatio=N 标志决定,该标志在以下方程式中使用:

survivor_space_size = new_size / (initial_survivor_ratio + 2)

对于默认的初始幸存者比率为 8,每个幸存者空间将占年轻代的 10%。

JVM 可能会将幸存者空间的大小增加到由-XX:MinSurvivorRatio=N 标志设置的最大值。该标志在以下方程式中使用:

maximum_survivor_space_size = new_size / (min_survivor_ratio + 2)

默认情况下,此值为 3,这意味着幸存者空间的最大大小将为年轻代的 20%。请再次注意,该值是一个比率,因此比率的最小值给出了幸存者空间的最大大小。因此,名称有些反直觉。

要保持幸存者空间的固定大小,将SurvivorRatio设置为所需值,并禁用UseAdaptiveSizePolicy标志(尽管请记住,禁用自适应大小将同时应用于老年代和新生代)。

JVM 根据 GC 后幸存者空间的填充情况(遵循定义的比率)决定是否增加或减少幸存者空间的大小。幸存者空间将被调整大小,以便在 GC 后,默认情况下填充至 50%。可以使用-XX:TargetSurvivorRatio=N 标志来更改该值。

最后,还有一个问题是对象在在幸存者空间之间来回移动多少次后被移至老年代。这个答案由晋升阈值确定。JVM 不断计算它认为最佳的晋升阈值。阈值从由-XX:InitialTenuringThreshold=N标志指定的值开始(对于吞吐量和 G1 GC 收集器,默认值为 7,对于 CMS 为 6)。最终 JVM 将确定一个介于 1 和由-XX:MaxTenuringThreshold=N标志指定的值之间的阈值;对于吞吐量和 G1 GC 收集器,默认的最大阈值为 15,对于 CMS 为 6。

综合考虑所有因素,在什么情况下可能调整哪些值?查看晋升统计信息很有帮助;这些信息在我们迄今为止使用的 GC 日志命令中没有打印出来。

在 JDK 8 中,可以通过包含标志-XX:+PrintTenuringDistribution(默认为false)将晋升分布添加到 GC 日志中。在 JDK 11 中,可通过在Xlog参数中包含age*=debugage*=trace来添加。

最重要的是要查看幸存者空间是否太小,以至于在小型 GC 过程中,对象直接从 Eden 区晋升到老年代。要避免这种情况的原因是短寿命对象将填满老年代,导致频繁发生 Full GC。

在使用吞吐量收集器记录的 GC 日志中,该条件的唯一提示是这一行:

Desired survivor size 39059456 bytes, new threshold 1 (max 15)
	 [PSYoungGen: 657856K->35712K(660864K)]
	 1659879K->1073807K(2059008K), 0.0950040 secs]
	 [Times: user=0.32 sys=0.00, real=0.09 secs]

使用age*=debug的 JDK 11 日志类似;在收集过程中,它会打印出所需的幸存者大小。

这里单个幸存者空间的期望大小为 39 MB,而年轻代大小为 660 MB:JVM 计算出两个幸存者空间应占年轻代的约 11%。但一个悬而未决的问题是是否这个大小足以防止溢出。该日志并没有提供明确的答案,但 JVM 调整了晋升阈值至 1 表明它已经确定大多数对象直接晋升到老年代,因此最小化了晋升阈值。这个应用可能直接将对象晋升到老年代而不充分利用幸存者空间。

当使用 G1 GC 时,在 JDK 8 日志中可获得更详细的输出:

 Desired survivor size 35782656 bytes, new threshold 2 (max 6)
 - age   1:   33291392 bytes,   33291392 total
 - age   2:    4098176 bytes,   37389568 total

在 JDK 11 中,包含age*=trace在日志配置中即可获得该信息。

期望的幸存者空间与之前的示例相似——35 MB——但输出还显示了幸存者空间中所有对象的大小。有 37 MB 的数据需要晋升,幸存者空间确实溢出了。

是否可以改善这种情况取决于应用程序。如果对象的生存时间长于几个 GC 周期,它们最终会进入老年代,因此调整幸存者空间和保留阈值不会真正有所帮助。但是,如果对象在几个 GC 周期后就会消失,通过使幸存者空间更有效地安排可以获得一些性能。

如果增加了幸存者空间的大小(通过减少幸存比率),则会从年轻代的伊甸园部分中腾出内存。这正是对象实际分配的地方,意味着在进行小型 GC 之前可以分配的对象更少。因此,通常不建议选择这个选项。

另一个可能性是增加年轻代的大小。在这种情况下可能适得其反:对象可能较少地晋升到老年代,但由于老年代较小,应用程序可能更频繁进行完全 GC。

如果可以增加堆的大小,无论是年轻代还是幸存者空间都可以获得更多内存,这将是最佳解决方案。一个好的过程是增加堆大小(或者至少是年轻代大小),并减少幸存比率。这将增加幸存者空间的大小,而不是增加伊甸园的大小。应用程序最终应该有与之前大致相同数量的年轻代收集。但是,应该有更少的完全 GC,因为假设对象在更多 GC 周期后将不再存活。

如果调整了幸存者空间的大小,使其永远不会溢出,那么只有在达到MaxTenuringThreshold后对象才会被晋升到老年代。可以增加该值以使对象在更多年轻代 GC 周期后仍在幸存者空间中。但要注意,如果增加了保留阈值并且对象在幸存者空间中停留时间更长,那么在未来的年轻代收集期间,幸存者空间可能会溢出并重新开始直接晋升到老年代。

快速总结

  • 幸存者空间设计用于允许对象(特别是刚分配的对象)在年轻代中存留几个 GC 周期。这增加了对象在被晋升到老年代之前被释放的概率。

  • 如果幸存者空间太小,对象将直接晋升到老年代,从而导致更多的老年代 GC 周期。

  • 处理这种情况的最佳方法是增加堆的大小(或者至少增加年轻代),并允许 JVM 处理幸存者空间。

  • 在罕见情况下,调整保留阈值或幸存者空间大小可以防止对象晋升到老年代。

分配大对象

本节详细描述了 JVM 如何分配对象。这是有趣的背景信息,对于频繁创建大量大对象的应用程序是重要的。在这个上下文中,是一个相对的术语;它取决于 JVM 内某种类型缓冲区的大小。

这个缓冲区被称为线程本地分配缓冲区(TLAB)。对于所有的 GC 算法,TLAB 的大小都是一个考虑因素,而 G1 GC 还要考虑非常大的对象(再次强调,这是一个相对的术语——对于一个 2 GB 的堆来说,大于 512 MB 的对象就算是非常大的)。非常大的对象对 G1 GC 的影响可能很重要——在使用任何收集器时,TLAB 的大小调整是相当不寻常的,但是对于使用 G1 时的 GC 区域大小调整则更为常见。

线程本地分配缓冲区

第五章 讨论了对象在 eden 区内的分配方式;这样可以实现更快的分配(特别是对于那些很快被丢弃的对象)。

在 eden 区分配如此迅速的一个原因是每个线程都有一个专用的区域来分配对象——即线程本地分配缓冲区,或者称为 TLAB(Thread-Local Allocation Buffer)。当对象直接分配在共享空间,比如 eden 区时,需要一些同步来管理该空间内的空闲指针。通过为每个线程设置其专用的分配区域,线程在分配对象时无需执行任何同步操作。³

通常情况下,开发人员和最终用户对 TLAB 的使用是透明的:TLAB 默认是启用的,JVM 管理它们的大小和使用方式。关于 TLAB 的重要一点是它们的大小很小,因此无法在 TLAB 中分配大对象。大对象必须直接从堆中分配,这会因为同步而额外消耗时间。

当一个 TLAB 变满时,某个大小的对象就无法再在其中分配了。在这种情况下,JVM 有两种选择。一种选择是“退休”该 TLAB 并为该线程分配一个新的 TLAB。由于 TLAB 只是 eden 区内的一个部分,在下一次年轻代收集时,退休的 TLAB 将被清理,并且可以随后重新使用。或者 JVM 可以直接在堆上分配对象,并保留现有的 TLAB(至少直到线程再次向 TLAB 分配其他对象)。假设一个 TLAB 是 100 KB,已经分配了 75 KB。如果需要新分配 30 KB 的对象,那么可以退休该 TLAB,浪费 25 KB 的 eden 空间。或者可以直接从堆上分配 30 KB 的对象,并且希望下一个分配的对象能够适应 TLAB 中仍然空余的 25 KB 空间。

参数可以控制这一点(如本节后面讨论的),但关键在于 TLAB 的大小。默认情况下,TLAB 的大小基于三个因素:应用程序中的线程数、Eden 的大小和线程的分配速率。

因此,两种类型的应用程序可能会从调整 TLAB 参数中受益:分配大量大对象的应用程序以及与 Eden 大小相比具有相对较大数量线程的应用程序。默认情况下,TLAB 是启用的;可以通过指定-XX:-UseTLAB来禁用它们,尽管它们提供了显著的性能提升,但禁用它们始终是一个不好的主意。

由于 TLAB 大小的计算部分基于线程的分配速率,因此无法明确预测应用程序的最佳 TLAB 大小。相反,我们可以监视 TLAB 分配,以查看是否有任何分配发生在 TLAB 之外。如果有大量的分配发生在 TLAB 之外,我们有两个选择:减少分配的对象大小或调整 TLAB 大小参数。

监视 TLAB 分配是另一个案例,Java Flight Recorder 比其他工具更强大。图 6-9 显示了来自 JFR 记录的 TLAB 分配屏幕的样本。

jp2e 0609

图 6-9. Java Flight Recorder 中的 TLAB 视图

在此记录中选择的 5 秒钟内,有 49 个对象在 TLAB 之外分配;这些对象的最大大小为 48 字节。由于最小的 TLAB 大小为 1.35 MB,我们知道这些对象之所以分配到堆上,是因为在分配时 TLAB 已经满了:它们并非因为大小而直接在堆上分配。这在年轻代 GC 发生之前是典型的情况(因为 Eden 和因此从 Eden 中划分出的 TLAB 变满了)。

在此期间的总分配为 1.59 KB;在这个例子中,分配的数量和大小都不是令人担忧的原因。一些对象总会分配在 TLAB 之外,特别是当 Eden 接近年轻代集合时。与图 6-10 相比,该示例显示大量分配发生在 TLAB 之外。

jp2e 0610

图 6-10. TLAB 之外的过度分配发生

在本次记录中,在 TLAB 内分配的总内存为 952.96 MB,在 TLAB 外分配的对象总内存为 568.32 MB。这是一个情况,可以通过更改应用程序以使用较小的对象或调整 JVM 以在更大的 TLAB 中分配这些对象来产生有益影响。请注意,其他选项卡可以显示分配到 TLAB 之外的实际对象;我们甚至可以安排获取分配这些对象时的堆栈信息。如果 TLAB 分配存在问题,JFR 将快速指出。

在 JFR 之外,查看 TLAB 分配的最佳方法是在 JDK 8 的命令行中添加-XX:+PrintTLAB标志或在 JDK 11 的日志配置中包含tlab*=trace(此配置提供以下信息及更多)。然后,在每次 young collection 时,GC 日志将包含两种类型的行:描述每个线程的 TLAB 使用情况的行,以及描述 JVM 整体 TLAB 使用情况的摘要行。

每个线程的行看起来像这样:

TLAB: gc thread: 0x00007f3c10b8f800 [id: 18519] desired_size: 221KB
    slow allocs: 8  refill waste: 3536B alloc: 0.01613    11058KB
    refills: 73 waste  0.1% gc: 10368B slow: 2112B fast: 0B

此输出中的gc表示该行在 GC 期间打印;线程本身是一个常规应用程序线程。该线程的 TLAB 大小为 221 KB。自上次 young collection 以来,它从堆中分配了八个对象(slow allocs);这占此线程分配总量的 1.6%(0.01613),总量为 11,058 KB。TLAB 中“浪费”的 0.1%来自三个方面:当前 GC 周期开始时,TLAB 中有 10,336 字节空闲;其他(已退休)TLAB 中有 2,112 字节空闲;通过特殊的“快速”分配器分配的字节为 0。

在每个线程的 TLAB 数据打印完成后,JVM 会提供一行摘要数据(在 JDK 11 中通过配置tlab*=debug日志提供此数据):

TLAB totals: thrds: 66  refills: 3234 max: 105
        slow allocs: 406 max 14 waste:  1.1% gc: 7519856B
        max: 211464B slow: 120016B max: 4808B fast: 0B max: 0B

在此案例中,自上次 young collection 以来,有 66 个线程执行了某种形式的分配。在这些线程中,它们重新填充了其 TLAB 3,234 次;任何特定线程重新填充其 TLAB 的最大次数为 105 次。总体而言,堆中进行了 406 次分配(由一个线程最多进行了 14 次),TLAB 中的 1.1%由已退休 TLAB 中的空闲空间浪费掉。

在每个线程的数据中,如果线程显示出 TLAB 外的许多分配,请考虑调整它们的大小。

TLAB 大小调整

那些在 TLAB 外分配大量对象的应用程序将受益于可以将分配移至 TLAB 的更改。如果只有少数特定对象类型始终在 TLAB 外分配,则程序更改是最佳解决方案。

否则——或者无法进行程序更改——您可以尝试调整 TLAB 大小以适应应用程序使用情况。由于 TLAB 大小基于 eden 的大小,调整新的大小参数将自动增加 TLAB 的大小。

可以使用标志-XX:TLABSize=*N*来显式设置 TLAB 的大小(默认值 0 表示使用先前描述的动态计算)。该标志仅设置 TLAB 的初始大小;为了防止在每次 GC 时调整大小,请添加-XX:-ResizeTLAB(该标志的默认值为true)。这是探索调整 TLAB 性能的最简单(实际上是唯一有用的)选项。

当一个新对象不适合当前的 TLAB(但适合一个新的空 TLAB)时,JVM 需要做出决定:是在堆中分配对象,还是淘汰当前的 TLAB 并分配一个新的 TLAB。该决定基于几个参数。在 TLAB 的日志输出中,refill waste值给出了该决策的当前阈值:如果 TLAB 无法容纳比该值大的新对象,则该新对象将在堆中分配。如果所讨论的对象小于该值,则 TLAB 将被淘汰。

这个值是动态的,但默认从 TLAB 大小的 1%开始——具体来说,从-XX:TLABWasteTargetPercent=N指定的值开始。每次在堆外进行分配时,这个值会增加-XX:TLABWasteIncrement=N的值(默认为 4)。这样可以防止线程在 TLAB 中达到阈值并持续在堆中分配对象:随着目标百分比的增加,TLAB 被淘汰的机会也增加。调整TLABWasteTargetPercent值还会调整 TLAB 的大小,因此虽然可以调整此值,但其影响并不总是可预测。

最后,当 TLAB 调整大小生效时,可以使用-XX:MinTLABSize=N指定 TLAB 的最小大小(默认为 2 KB)。TLAB 的最大大小略小于 1 GB(可以由整数数组占用的最大空间,按照对象对齐目的四舍五入),并且不能更改。

快速总结

  • 分配大量大对象的应用程序可能需要调整 TLAB(尽管通常在应用程序中使用较小的对象是更好的方法)。

巨大对象

在可能的情况下,分配在 TLAB 外的对象仍然在伊甸园内分配。如果对象无法适应伊甸园,则必须直接在老年代中分配。这样会阻止该对象的正常 GC 生命周期,因此如果对象的生命周期很短,则 GC 会受到负面影响。在这种情况下,除了改变应用程序以避免需要这些短寿命的大对象外,几乎无能为力。

然而,在 G1 GC 中,巨大对象的处理方式不同:如果它们大于一个 G1 区域,则 G1 会将它们分配到老年代。因此,在 G1 GC 中使用大量巨大对象的应用程序可能需要特殊调整来补偿这一点。

G1 GC 区域大小

G1 GC 将堆分成具有固定大小的区域。区域大小不是动态的;它在启动时根据堆的最小大小(即Xms的值)确定。最小区域大小为 1 MB。如果最小堆大小大于 2 GB,则根据以下公式设置区域的大小(使用对数 2 为底):

region_size = 1 << log(Initial Heap Size / 2048);

简而言之,区域大小是最小的 2 的幂,使得初始堆大小被划分时接近 2048 个区域。这里也使用了一些最小和最大约束条件;区域大小始终至少为 1 MB,从不超过 32 MB。表 6-3 总结了所有可能性。

表 6-3。默认 G1 区域大小

堆大小 默认 G1 区域大小
少于 4 GB 1 MB
4 GB 和 8 GB 之间 2 MB
8 GB 和 16 GB 之间 4 MB
16 GB 和 32 GB 之间 8 MB
32 GB 和 64 GB 之间 16 MB
大于 64 GB 32 MB

可以使用-XX:G1HeapRegionSize=N标志设置 G1 区域的大小(默认值名义上为 0,表示使用刚刚描述的动态值)。此处给定的值应为 2 的幂(例如,1 MB 或 2 MB);否则,它将被舍入为最接近的 2 的幂。

G1 GC 分配巨大对象

如果 G1 GC 的区域大小为 1 MB,并且程序分配了一个 200 万字节的数组,那么该数组将无法放入单个 G1 GC 区域中。但是这些巨大的对象必须在连续的 G1 GC 区域中分配。如果 G1 GC 的区域大小为 1 MB,那么要分配一个 3.1 MB 的数组,G1 GC 必须在老年代中找到四个区域来分配该数组。(最后一个区域的其余部分将保持空闲,浪费 0.9 MB 的空间。)这会破坏 G1 GC 通常执行压缩的方式,即根据它们的填充程度释放任意区域。

实际上,G1 GC 将巨大对象定义为区域大小的一半,因此在这种情况下,分配 512 KB(加上 1 字节)的数组将触发我们正在讨论的巨大分配。

因为巨大对象直接分配在老年代中,所以在年轻代收集期间无法释放它。因此,如果对象的生存期较短,这也会破坏收集器的分代设计。巨大对象将在并发 G1 GC 周期中被收集。好的一面是,巨大对象可以快速释放,因为它是它所占用的区域中唯一的对象。巨大对象在并发周期的清理阶段被释放(而不是在混合 GC 期间)。

增加 G1 GC 区域的大小,以便程序将分配的所有对象都适合单个 G1 GC 区域,可以使 G1 GC 更高效。这意味着将 G1 区域大小设置为最大对象大小的两倍加 1 字节。

在 JDK 8u60 中对 G1 GC 的改进(以及所有 JDK 11 版本中)最小化了这个问题,因此它不再是它曾经经常是的关键问题。

快速总结

  • G1 区域的大小是以 2 的幂为单位的,从 1 MB 开始。

  • 堆大小与初始大小非常不同的堆将具有太多的 G1 区域;在这种情况下,应增加 G1 区域大小。

  • 应用程序分配大于 G1 区域一半大小的对象时,应增加 G1 区域的大小,以便对象可以适应 G1 区域。为了适应这一点,应用程序必须分配至少 512 KB 的对象(因为最小的 G1 区域是 1 MB)。

AggressiveHeap

AggressiveHeap标志(默认为false)最早在 Java 的早期版本中引入,旨在简化设置各种命令行参数——这些参数适用于运行单个 JVM 的内存很大的机器。尽管该标志自那些版本以来一直存在,但现在不再推荐使用(尽管官方尚未弃用)。

该标志的问题在于它隐藏了采用的实际调优设置,使得很难确定 JVM 正在设置的内容。现在,它设置的一些值是根据更好的关于运行 JVM 的机器的信息以及人体工程学的原则设置的,因此在某些情况下启用此标志会损害性能。我经常看到的命令行中包含此标志,然后稍后会覆盖它设置的值。(顺便说一句,这样做是有效的:命令行中后面的值目前会覆盖前面的值。这种行为没有得到保证。)

表 6-4 列出了启用AggressiveHeap标志时自动设置的所有调优项。

表 6-4. 使用AggressiveHeap启用的调优设置

标志
Xmx 半数内存或整体内存的最小值:160 MB
Xms Xmx相同
NewSize 设定为Xmx的 3/8
UseLargePages true
ResizeTLAB false
TLABSize 256 KB
UseParallelGC true
ParallelGCThreads 与当前默认相同
YoungPLABSize 256 KB(默认为 4 KB)
OldPLABSize 8 KB (默认为 1 KB)
CompilationPolicyChoice 0(当前默认)
ThresholdTolerance 100 (default is 10)
ScavengeBeforeFullGC false(默认为true
BindGCTaskThreadsToCPUs true(默认为false

这些最后六个标志足够晦涩,以至于我在本书的其他地方没有讨论过它们。简要来说,它们涵盖了以下几个领域:

PLAB 大小调整

PLABspromotion-local allocation buffers—这些是在 GC 中清理世代时每个线程使用的区域。每个线程可以晋升到特定的 PLAB,从而避免了同步的需要(类似于 TLAB 的工作方式)。

编译策略

JVM 随附了备用的 JIT 编译算法。当前默认算法曾在某个时期略显实验性,但现在已成为推荐政策。

禁用 young GC 在 full GC 之前

ScavengeBeforeFullGC设置为false意味着当发生全 GC 时,JVM 将不会在全 GC 前执行年轻代 GC。通常这是件坏事,因为这意味着年轻代中的垃圾对象(可进行回收)可能会阻止老年代对象的回收。显然,曾经有段时间这个设置是有意义的(至少对于某些基准测试来说),但一般建议是不要更改这个标志。

将 GC 线程绑定到 CPU

设置列表中的最后一个标志意味着每个并行 GC 线程都绑定到特定的 CPU(使用特定于操作系统的调用)。在有限的情况下——当 GC 线程是机器上唯一运行的东西,并且堆非常大时——这是有意义的。在一般情况下,最好让 GC 线程可以在任何可用的 CPU 上运行。

和所有的调优一样,你的效果可能有所不同,如果你仔细测试了AggressiveHeap标志并发现它能提升性能,那么尽管使用它。只需注意它在幕后所做的事情,并意识到每次 JVM 升级时,这个标志的相对优势都需要重新评估。

快速总结

  • AggressiveHeap标志是一个旧版尝试,旨在将堆参数设置为适合单个在非常大的机器上运行的 JVM 的值。

  • 由该标志设置的值不会随着 JVM 技术的进步而调整,因此从长远来看其实用性是可疑的(尽管它仍然经常被使用)。

完全控制堆大小

“堆大小的设置”讨论了堆的初始最小和最大大小的默认值。这些值取决于机器上的内存量以及使用的 JVM,并且在那里呈现的数据有许多特例情况。如果你对默认堆大小是如何计算的完整细节感兴趣,这一节会详细解释。这些细节包括低级调优标志;在某些情况下,调整这些计算方法可能比简单设置堆大小更为方便。例如,如果你想要运行多个具有共同(但调整过的)舒适性堆大小的 JVM,那么这可能是个例外。在大多数情况下,这一节的真正目的是完整解释这些默认值是如何选择的。

默认大小基于机器上的内存量,可以使用-XX:MaxRAM=N标志设置。通常情况下,JVM 通过检查机器上的内存量来计算该值。然而,JVM 将MaxRAM限制为 32 位 Windows 服务器的 4 GB 和 64 位 JVM 的 128 GB。最大堆大小是MaxRAM的四分之一。这就是为什么默认堆大小会有所不同:如果机器上的物理内存少于MaxRAM,则默认堆大小是其四分之一。但即使有数百 GB 的 RAM 可用,JVM 默认也只会使用 32 GB:128 GB 的四分之一。

实际上,默认的最大堆大小计算如下:

Default Xmx = MaxRAM / MaxRAMFraction

因此,默认的最大堆大小也可以通过调整 -XX:MaxRAMFraction=N 标志的值来设置,默认为 4。最后,为了保持事情的有趣性,-XX:ErgoHeapSizeLimit=N 标志也可以设置为 JVM 应该使用的最大默认值。默认情况下该值为 0(表示忽略);否则,如果它小于 MaxRAM / MaxRAMFraction,则使用该限制。

另一方面,在物理内存非常少的机器上,JVM 希望确保留足够的内存给操作系统。这就是为什么在只有 192 MB 内存的机器上,JVM 将最大堆限制为 96 MB 或更少的原因。这个计算基于 -XX:MinRAMFraction=N 标志的值,默认为 2:

if ((96 MB * MinRAMFraction) > Physical Memory) {
    Default Xmx = Physical Memory / MinRAMFraction;
}

初始堆大小的选择类似,尽管复杂度较低。初始堆大小的值确定如下:

Default Xms =  MaxRAM / InitialRAMFraction

从默认最小堆大小可以得出结论,InitialRAMFraction 标志的默认值为 64。这里的一个注意事项是,如果该值小于 5 MB——或者严格来说小于 -XX:OldSize=N(默认为 4 MB)加上 -XX:NewSize=N(默认为 1 MB)所指定的值——那么老年代和新生代大小的总和将用作初始堆大小。

快速总结

  • 默认的初始堆大小和最大堆大小的计算在大多数计算机上都很简单。

  • 在边缘处,这些计算可能会相当复杂。

实验性 GC 算法

在具有多个 CPU 的 JDK 8 和 JDK 11 生产 VM 中,您将根据应用程序的要求使用 G1 GC 或吞吐量收集器。在小型机器上,如果适合您的硬件,则将使用串行收集器。这些是支持生产的收集器。

JDK 12 引入了新的收集器。虽然这些收集器不一定是生产就绪的,但我们会为实验目的来一窥一下它们。

并发压缩:ZGC 和 Shenandoah

现有的并发收集器并非完全并发。无论是 G1 GC 还是 CMS 都没有对年轻代进行并发收集:释放年轻代需要停止所有应用线程。而且这些收集器都不进行并发压缩。在 G1 GC 中,老年代在混合 GC 循环中被压缩:在目标区域内,未释放的对象会被压缩到空白区域中。在 CMS 中,老年代在碎片化严重时进行压缩,以便允许新的分配。年轻代的收集也会通过将存活对象移动到幸存者空间或老年代来压缩堆的这部分。

在压缩期间,对象会在内存中移动它们的位置。这是 JVM 在此操作期间停止所有应用程序线程的主要原因——如果已知应用程序线程已停止,那么更新内存引用的算法将简单得多。因此,应用程序的暂停时间主要由移动对象和确保对它们的引用是最新的时间所主导。

为了解决这个问题,设计了两个实验性的收集器。第一个是 Z 垃圾收集器,或 ZGC;第二个是 Shenandoah 垃圾收集器。ZGC 首次出现在 JDK 11 中;Shenandoah GC 首次出现在 JDK 12 中,但现已被回溯到 JDK 8 和 JDK 11。来自 AdoptOpenJDK 的 JVM 构建(或者您自己从源代码编译的构建)包含这两个收集器;来自 Oracle 的构建仅包含 ZGC。

要使用这些收集器,必须指定-XX:+UnlockExperimentalVMOptions标志(默认情况下为false)。然后,您可以使用-XX:+UseZGC-XX:+UseShenandoahGC替换其他 GC 算法。像其他 GC 算法一样,它们有几个调优选项,但由于算法仍在开发中,所以我们将使用默认参数运行。 (并且这两个收集器都旨在以最少的调整运行。)

尽管它们采取了不同的方法,但两个收集器都允许堆的并发压缩,这意味着堆中的对象可以在不停止所有应用程序线程的情况下移动。这主要有两个影响。

首先,堆不再是分代的(即不再有年轻代和老年代;只有一个单一的堆)。年轻代背后的想法是,收集小部分堆比整个堆更快,并且其中许多(理想情况下大多数)对象将是垃圾。因此,年轻代允许在大部分时间内减少暂停时间。如果应用程序线程在收集期间不需要暂停,那么对于年轻代的需求就消失了,因此这些算法不再需要将堆分段。

第二个影响是可以预期应用程序线程执行的操作的延迟会降低(至少在许多情况下是这样)。考虑一个通常在 200 毫秒内执行的 REST 调用;如果该调用由于 G1 GC 中的年轻代收集而被中断,并且该收集花费了 500 毫秒,那么用户将看到该 REST 调用花费了 700 毫秒。当然,大多数调用不会遇到这种情况,但某些调用会,而这些异常情况将影响系统的整体性能。在不需要停止应用程序线程的情况下,进行并发压缩的收集器不会遇到这些异常情况。

这在某种程度上简化了情况。回顾一下关于 G1 GC 的讨论,堆区域中标记自由对象的后台线程有时会有短暂的暂停。因此,G1 GC 有三种类型的暂停:相对较长的完全 GC 暂停(理想情况下,您已经调整得足够好,不会发生这种情况),用于年轻代 GC 收集的较短暂停(包括释放和压缩部分老年代的混合收集),以及用于标记线程的非常短暂的暂停。

ZGC 和 Shenandoah 都有类似的暂停,属于后者的类别;在短时间内,所有应用程序线程都会停止。这些收集器的目标是保持这些时间非常短,大约在 10 毫秒的量级。

这些收集器还会在单个线程操作上引入延迟。具体细节因算法而异,但简而言之,应用程序线程访问对象时受到屏障的保护。如果对象恰好正在移动过程中,应用程序线程将在屏障处等待,直到移动完成。(同样,如果应用程序线程正在访问对象,则垃圾收集线程必须在屏障处等待,直到可以重新定位对象。)实际上,这是对对象引用的一种锁定形式,但这个术语使这个过程看起来比实际情况更为重型。总体而言,这对应用程序的吞吐量影响很小。

并发压缩的延迟影响

要了解这些算法的整体影响,可以考虑一下表 6-5 中的数据。该表显示了处理固定负载 500 OPS 的 REST 服务器使用各种收集器时的响应时间。这里的操作非常快速;它只是分配并保存一个相当大的字节数组(用一个已保存的数组替换,以保持内存压力恒定)。

表 6-5. 并发压缩收集器的延迟影响

收集器 平均时间 90th%时间 99th%时间 最大时间
吞吐量 GC 13 ms 60 ms 160 ms 265 ms
G1 GC 5 ms 10 ms 35 ms 87 ms
ZGC 1 ms 5 ms 5 ms 20 ms
Shenandoah GC 1 ms 5 ms 5 ms 22 ms

这些结果正是我们从各种收集器中所期望的。吞吐量收集器的完全 GC 时间导致最大响应时间为 265 毫秒,并且有很多超过 50 毫秒响应时间的异常情况。使用 G1 GC 后,这些完全 GC 时间已经消失,但仍然存在较短的时间用于年轻代收集,导致最大时间为 87 毫秒,约 10 毫秒的异常情况。并且使用并发收集器后,这些年轻代收集暂停也消失了,因此最大时间现在约为 20 毫秒,异常情况只有 5 毫秒。

一个警告:垃圾收集暂停传统上一直是像我们这里讨论的延迟异常的最大贡献者。但是还存在其他原因:服务器和客户端之间的临时网络拥塞,操作系统调度延迟等等。因此,虽然前两种情况中很多异常情况是由并发收集器仍然存在的那几毫秒的短暂暂停造成的,但我们现在正在进入那些其他因素也对总延迟有很大影响的领域。

并发紧凑收集器的吞吐效果

这些收集器的吞吐效果更难以分类。与 G1 GC 一样,这些收集器依赖于后台线程来扫描和处理堆。因此,如果这些线程没有足够的 CPU 周期可用,收集器将经历与之前看到的并发故障相同的情况,并最终进行完整的 GC。并发紧凑收集器通常比 G1 GC 后台线程使用更多的后台处理。

另一方面,如果后台线程有足够的 CPU 可用,使用这些收集器时的吞吐量将高于 G1 GC 或吞吐量收集器的吞吐量。这再次符合你在 第 5 章 中看到的情况。该章节的示例显示,当 G1 GC 将 GC 处理转移到后台线程时,其吞吐量可能高于吞吐量收集器。并发紧凑收集器与吞吐量收集器相比具有相同的优势,而与 G1 GC 相比则有类似(但更小)的优势。

无收集:Epsilon GC

JDK 11 中还包含一个什么都不做的收集器:epsilon collector。当你使用这个收集器时,对象从堆中永远不会被释放,当堆填满时,你将会得到内存溢出错误。

传统的程序当然不能使用这个收集器。它真正设计用于内部 JDK 测试,但在两种情况下可能会有用:

  • 非常短命的程序

  • 程序要精心编写以重用内存并永远不执行新分配

第二种情况在一些内存有限的嵌入式环境中很有用。这种编程是专业的,我们在这里不考虑它。但第一种情况具有有趣的可能性。

考虑一个程序的情况,它分配了一个包含 4,096 个元素的数组列表,每个元素都是一个 0.5 MB 的字节数组。使用各种收集器运行该程序的时间在 表 6-6 中显示。本示例中使用默认的 GC 调优。

表 6-6. 基于小型分配的程序的性能指标

收集器 时间 所需堆
吞吐量 GC 2.3 s 3,072 MB
G1 GC 3.24 s 4,096 MB
Epsilon 1.6 s 2,052 MB

禁用垃圾收集在这种情况下有显著优势,可提高 30% 的性能。而其他收集器需要显著的内存开销:就像我们见过的其他实验性收集器一样,ε 收集器不是分代的(因为对象无法释放,所以不需要设置一个单独的空间来能够快速释放它们)。因此,对于这个生成大约 2 GB 对象的测试,ε 收集器所需的总堆空间略高于这个值;我们可以使用 -Xmx2052m 运行这种情况。吞吐量收集器需要额外三分之一的内存来容纳其年轻代,而 G1 GC 需要更多的内存来设置其所有区域。

要使用这个收集器,你需要再次指定 -XX:+UnlockExperimentalVMOptions 标志,以及 -XX:+UseEpsilonGC

除非你确信程序永远不会需要比你提供的内存更多的内存,否则使用这个收集器是有风险的。但在这些情况下,它可以提供良好的性能提升。

概述

过去的两章花了大量时间深入探讨 GC(及其各种算法)的工作细节。如果 GC 消耗的时间超出了你的预期,了解所有这些工作原理应该有助于你采取必要的步骤来改善情况。

现在你已经了解了所有的细节,让我们退后一步来确定选择和调整垃圾收集器的方法。以下是一组快速问题,帮助你把所有内容放在上下文中:

你的应用程序能容忍一些完整 GC 暂停吗?

如果不是的话,G1 GC 就是首选算法。即使你可以容忍一些完整暂停,G1 GC 通常比并行 GC 更好,除非你的应用程序受限于 CPU。

默认设置能满足你的性能需求吗?

首先尝试默认设置。随着 GC 技术的成熟,自动调整越来越好。如果没有得到所需的性能,请确保 GC 是你的问题。查看 GC 日志,看看你在 GC 中花费的时间以及长时间暂停发生的频率。对于繁忙的应用程序,如果在 GC 中花费的时间不到 3%,你不太可能通过调整来获得很大的提升(尽管如果这是你的目标,你可以尝试减少异常值)。

你拥有的暂停时间是否接近你的目标?

如果是这样,调整最大暂停时间可能是你所需的全部。如果不是这样,你需要做其他事情。如果暂停时间过长但吞吐量正常,你可以减少年轻代的大小(对于完整的 GC 暂停,还有老年代);你将获得更多但较短的暂停时间。

即使 GC 暂停时间很短,吞吐量是否滞后?

您需要增加堆的大小(或至少是年轻代的大小)。更多并不总是更好:更大的堆会导致更长的暂停时间。即使使用并发收集器,更大的堆默认情况下会导致更大的年轻代,因此您将看到更长的年轻代收集暂停时间。但如果可以的话,请增加堆的大小,或至少增加代的相对大小。

如果您使用并发收集器,并因并发模式失败而出现全 GC,请问您是否使用了并发收集器并因提升失败而出现全 GC?

如果你有可用的 CPU,请尝试增加并行 GC 线程的数量,或通过调整InitiatingHeapOccupancyPercent提前启动后台扫描。对于 G1 来说,如果有未处理的混合 GC,请尝试减少混合 GC 计数目标,以便并行循环不会启动。

如果您使用并发收集器,并因提升失败而出现全 GC,请问您是否使用了并发收集器并因提升失败而出现全 GC?

在 G1 GC 中,疏散失败(到空间溢出)表示堆是碎片化的,但通常情况下,如果 G1 GC 提前执行后台扫描和更快地执行混合 GC,则可以解决此问题。尝试增加并发 G1 线程的数量、调整InitiatingHeapOccupancyPercent,或减少混合 GC 计数目标。

¹ 实际上,227,893 KB 只有 222 MB。为了方便讨论,在本章中,我将以 1,000 为单位截断 KB;假装我是一个磁盘制造商。

² 类似的工作也可以使 CMS 全 GC 使用并行线程运行,但 G1 GC 的工作被优先考虑。

³ 这是线程本地变量可以防止锁竞争的一种变体方法(参见第九章)。

第七章:堆内存最佳实践

第五章和第六章讨论了如何调整垃圾收集器以尽可能少地影响程序的详细信息。调整垃圾收集器非常重要,但通常通过利用更好的编程实践可以获得更好的性能提升。本章讨论了一些在 Java 中使用堆内存的最佳实践方法。

我们在这里有两个相互冲突的目标。第一个一般规则是尽量少地创建对象,并尽快丢弃它们。使用更少的内存是提高垃圾收集器效率的最佳方法。另一方面,频繁重新创建某些类型的对象可能会导致整体性能下降(即使 GC 性能得到改善)。如果这些对象被重复使用,程序可以看到显著的性能提升。对象可以以各种方式重复使用,包括线程本地变量、特殊对象引用和对象池。重复使用对象意味着它们将长时间存在并影响垃圾收集器,但是当它们被明智地重复使用时,整体性能将会提高。

本章讨论了这两种方法及其之间的权衡。不过首先,我们将研究了解堆内部发生情况的工具。

堆分析

GC 日志和在第五章讨论的工具非常适合了解 GC 对应用程序的影响,但为了更进一步的可视化,我们必须深入研究堆本身。本节讨论的工具能够深入了解应用程序当前正在使用的对象。

大多数情况下,这些工具仅对堆中的活动对象进行操作——在下一个完整的 GC 周期中将被回收的对象不包含在工具的输出中。在某些情况下,工具通过强制执行完整的 GC 来实现这一点,因此在使用工具后可能会影响应用程序的行为。在其他情况下,这些工具遍历堆并报告活动数据,而不会在此过程中释放对象。不管哪种情况,这些工具都需要时间和机器资源;它们通常在程序执行的测量过程中不是很有用。

堆直方图

减少内存使用是一个重要的目标,但与大多数性能主题一样,有助于将努力集中在最大化可用收益上。在本章后面,你将看到一个关于懒初始化Calendar对象的示例。这将在堆中节省 640 字节的空间,但如果应用程序总是初始化这样的对象,性能上几乎没有可测量的差异。必须进行分析以确定哪些类型的对象消耗了大量内存。

最简单的方法是通过堆直方图。直方图是查看应用程序内对象数量的快速方法,而无需进行完整的堆转储(因为堆转储可能需要一段时间进行分析,而且会消耗大量磁盘空间)。如果一些特定对象类型负责在应用程序中创建内存压力,那么堆直方图是发现这一问题的快速方法。

可以使用 jcmd(此处使用进程 ID 8898)获取堆直方图:

% jcmd 8998 GC.class_histogram
8898:

 num     #instances         #bytes  class name
----------------------------------------------
   1:        789087       31563480  java.math.BigDecimal
   2:        172361       14548968  C
   3:         13224       13857704  B
   4:        184570        5906240  java.util.HashMap$Node
   5:         14848        4188296  [I
   6:        172720        4145280  java.lang.String
   7:         34217        3127184  [Ljava.util.HashMap$Node;
   8:         38555        2131640  [Ljava.lang.Object;
   9:         41753        2004144  java.util.HashMap
  10:         16213        1816472  java.lang.Class

在直方图中,我们通常可以看到字符数组([C)和 String 对象位于最顶部附近,因为这些是最常见的 Java 对象。字节数组([B)和对象数组([Ljava.lang.Object;)也很常见,因为类加载器将其数据存储在这些结构中。如果您对此语法不熟悉,请参阅 Java Native Interface(JNI)文档。

在此示例中,包含 BigDecimal 类是值得追究的:我们知道示例代码产生了大量瞬态 BigDecimal 对象,但是让这么多对象在堆中存在并不是我们通常所期望的。GC.class_histogram 的输出仅包括活动对象,因为该命令通常会强制进行完整的 GC。您可以在命令中包含 -all 标志以跳过完整的 GC,但那么直方图将包含未引用的(垃圾)对象。

运行此命令也可以获得类似的输出:

% jmap -histo process_id

jmap 的输出包括可被收集(死)对象。在查看直方图之前强制进行完整的 GC,请改用此命令:

% jmap -histo:live process_id

直方图很小,因此对于自动化系统中的每个测试收集一个直方图可能很有帮助。但是,由于获取直方图需要几秒钟并触发完整的 GC,因此不应在性能测量稳定状态下获取。

堆转储

直方图非常适合识别由于分配过多某一两个特定类的实例而引起的问题,但是要进行更深入的分析,则需要堆转储。许多工具可以查看堆转储,并且其中大多数可以连接到实时程序以生成转储。通常更容易通过命令行生成转储,可以使用以下任一命令执行:

% jcmd process_id GC.heap_dump /path/to/heap_dump.hprof

或者

% jmap -dump:live,file=/path/to/heap_dump.hprof process_id

jmap 中包含 live 选项将在转储堆之前强制进行完整的 GC。这是 jcmd 的默认设置,但如果出于某种原因希望包含那些其他(死)对象,则可以在 jcmd 命令行的末尾指定 -all。如果以强制进行完整的 GC 的方式使用该命令,显然会在应用程序中引入长时间的暂停,但即使不强制进行完整的 GC,应用程序也将因为写入堆转储而暂停一段时间。

任一命令都会在给定目录中创建名为heap_dump.hprof的文件;然后可以使用各种工具打开该文件。其中最常见的工具如下:

jvisualvm

jvisualvm 的 Monitor 选项卡可以从运行中的程序中获取堆转储或打开以前生成的堆转储。从那里,您可以浏览堆,检查最大的保留对象,并针对堆执行任意查询。

mat

开源的 EclipseLink Memory Analyzer 工具 (mat) 可以加载一个或多个堆转储,并对它们进行分析。它可以生成报告,指出问题可能出现的位置,也可以用于浏览堆并在堆中执行类似 SQL 的查询。

堆的第一次分析通常涉及保留内存。对象的保留内存是如果该对象本身有资格被收集,将释放的内存量。在 [图 7-1 中,String Trio 对象的保留内存包括该对象占用的内存以及 Sally 和 David 对象占用的内存。它不包括 Michael 对象使用的内存,因为该对象有另一个引用,如果释放 String Trio,则该对象将不会有资格进行 GC。

![对象图显示一些对象有多个引用。###### 图 7-1. 保留内存的对象图保留大量堆空间的对象通常称为堆的 支配者 。如果堆分析工具显示少数对象主导了大部分堆,事情就简单了:您只需减少它们的创建数量,缩短它们的保留时间,简化它们的对象图或使它们变小。这可能说起来容易,但至少分析是简单的。更常见的情况是,需要进行侦查工作,因为程序可能在共享对象。就像前一个图中的 Michael 对象一样,这些共享对象不会计入任何其他对象的保留集,因为释放一个单独对象不会释放共享对象。此外,最大的保留大小通常是你无法控制的类加载器。作为一个极端的例子,图 7-2 显示了一个堆的顶部保留对象,来自一个根据客户端连接缓存项目并在全局哈希映射中弱引用的股票服务器版本(因此缓存项目具有多个引用)。内存分析器图表显示保留内存最多的顶级对象。

图 7-2. 内存分析器中的保留内存视图

堆包含 1.4 GB 的对象(该值不会出现在此选项卡上)。即使如此,单独引用的最大对象集合仅为 6 MB(并且,毫不奇怪,是类加载框架的一部分)。查看直接保留最大内存量的对象并不能解决内存问题。

这个例子展示了列表中多个StockPriceHistoryImpl对象的实例,每个对象都占用了相当数量的内存。从这些对象占用的内存量可以推断出它们是问题所在。但通常情况下,对象可能以一种共享的方式存在,这样查看保留堆也看不出明显的问题。

对象的直方图是一个有用的第二步(见图 7-3)。

股票服务器应用程序中的对象直方图。

图 7-3. 内存分析器中的直方图视图

直方图聚合了相同类型的对象,在这个例子中,可以明显地看出,七百万个TreeMap$Entry对象所保留的 1.4 GB 内存是关键。即使不知道程序内部发生了什么,也很容易利用内存分析工具追踪这些对象,看看是什么在持有它们。

堆分析工具提供了一种查找特定对象(或在这种情况下一组对象)的 GC 根源的方法,尽管直接跳转到 GC 根源并不一定有帮助。GC 根源是持有静态全局引用的系统对象,通过一长串其他对象引用到所讨论的对象。通常这些来自系统加载的类的静态变量或引导类路径。这包括Thread类和所有活动线程;线程通过它们的线程本地变量或通过它们目标Runnable对象的引用来保留对象(或者在Thread类的子类的情况下,子类的其他引用)。

在某些情况下,了解目标对象的 GC 根源是有帮助的,但如果对象具有多个引用,则会有多个 GC 根源。这里的引用是一个反向的树结构。假设有两个对象引用特定的TreeMap$Entry对象。这两个对象可能被其他两个对象引用,每个对象可能被其他三个对象引用,依此类推。根据 GC 根源的追踪,引用的爆炸意味着任何给定对象可能有多个 GC 根源。

相反,更有成效的方法是像侦探一样查找对象图中的最低点,这是通过检查对象及其传入引用,并追踪这些传入引用直到找到重复路径来完成的。在这种情况下,持有在树映射中的StockPriceHistoryImpl对象的引用有两个:ConcurrentHashMap,它保存会话的属性数据,以及WeakHashMap,它保存全局缓存。

在图 7-4 中,回溯足够展开,仅显示有关其中两个对象的少量数据。确认它是会话数据的方式是继续展开ConcurrentHashMap路径,直到清楚该路径是会话数据。对于WeakHashMap的路径,同样的逻辑也适用。

每个 TreeMap 对象被其他两个对象引用。

图 7-4. Memory Analyzer 中对象引用的回溯

此示例中使用的对象类型使得分析比通常情况下更容易一些。如果该应用程序的主要数据被建模为String对象而不是BigDecimal对象,并存储在HashMap对象而不是TreeMap对象中,事情会变得更加困难。堆转储中还有数十万其他字符串和数万个HashMap对象。找到有趣对象的路径需要一些耐心。作为一个经验法则,从集合对象(例如HashMap)开始而不是条目(例如HashMap$Entry),并寻找最大的集合。

快速总结

  • 知道哪些对象消耗了内存是了解需要优化代码中的哪些对象的第一步。

  • 直方图是识别因创建某一类型的太多对象而导致的内存问题的一种快速简便方法。

  • 堆转储分析是追踪内存使用的最强大技术,尽管需要耐心和努力才能充分利用。

内存不足错误

在这些情况下,JVM 在这些情况下会抛出内存不足错误:

  • JVM 没有可用的本机内存。

  • 元空间已耗尽。

  • Java 堆本身已经耗尽了内存:应用程序无法为给定的堆大小创建任何额外的对象。

  • JVM 在执行垃圾回收时花费了太多时间。

最后两种情况——涉及 Java 堆本身——更为常见,但不要仅仅因为内存不足错误就自动认为堆是问题所在。必须查看内存不足错误的原因(该原因是异常输出的一部分)。

本机内存耗尽

此列表中的第一个情况——JVM 没有可用的本机内存——是与堆毫不相关的原因。在 32 位 JVM 中,进程的最大大小为 4 GB(某些版本的 Windows 为 3 GB,在某些较旧的 Linux 版本中约为 3.5 GB)。指定非常大的堆大小,比如 3.8 GB,会使应用程序大小接近该限制。即使是 64 位 JVM,操作系统可能也没有足够的虚拟内存来满足 JVM 请求。

有关此主题的更全面讨论,请参阅第八章。请注意,如果内存不足错误的消息讨论的是分配本机内存,堆调整不是解决方案:您需要查看错误消息中提到的任何本机内存问题。例如,以下消息告诉您线程堆栈的本机内存已耗尽:

Exception in thread "main" java.lang.OutOfMemoryError:
unable to create new native thread

但请注意,JVM 有时会因与内存无关的事物而发出此错误。用户通常对可运行的线程数量有限制;此限制可能由操作系统或容器施加。例如,在 Linux 中,通常只允许用户创建 1,024 个进程(您可以通过运行ulimit -u来检查此值)。尝试创建第 1,025 个线程将抛出相同的OutOfMemoryError,宣称内存不足以创建本地线程,而实际上是由于进程数目的操作系统限制导致错误。

元空间内存不足

元空间内存不足错误也与堆无关,而是因为元空间本地内存已满。由于元空间默认没有最大大小,因此此错误通常是因为您选择设置了最大大小(本节的原因将在下文中变得清晰)。

此错误可能有两个根本原因:第一个是应用程序使用的类超过了您分配的元空间可以容纳的数量(参见“调整元空间大小”)。第二种情况更加棘手:涉及类加载器内存泄漏。这种情况在动态加载类的服务器中最为频繁。例如,Java EE 应用服务器就是一个典型例子。部署到应用服务器的每个应用程序都在自己的类加载器中运行(这提供了隔离,使得一个应用程序的类不与其他应用程序的类共享或干扰)。在开发过程中,每次修改应用程序后,必须重新部署:创建一个新的类加载器来加载新的类,旧的类加载器则被允许超出作用域。一旦类加载器超出作用域,类的元数据就可以被回收。

如果旧的类加载器未超出作用域,则类的元数据无法被释放,最终元空间将被填满并抛出内存不足错误。在这种情况下,增加元空间的大小会有所帮助,但最终只是推迟了错误的发生。

如果此情况发生在应用服务器环境中,除了联系应用服务器供应商并要求其修复泄漏外,别无他法。如果您正在编写自己的应用程序,并创建和丢弃大量类加载器,请确保正确地丢弃类加载器本身(特别是确保没有线程将其上下文类加载器设置为临时类加载器之一)。要调试此情况,刚才描述的堆转储分析非常有用:在直方图中,查找所有ClassLoader类的实例,并追踪它们的 GC 根以查看它们所持有的内容。

识别此情况的关键是再次查看内存溢出错误的全文输出。如果元空间已满,则错误文本将如下所示:

Exception in thread "main" java.lang.OutOfMemoryError: Metaspace

顺便说一下,类加载器泄漏是您应考虑设置元空间最大大小的原因。如果不加限制,具有类加载器泄漏的系统将消耗机器上的所有内存。

堆外内存

当堆本身内存不足时,错误消息如下:

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

引起内存不足条件的常见情况类似于我们刚讨论的元空间示例。应用程序可能仅需更多的堆空间:它持有的活动对象数量无法适应为其配置的堆空间。或者,应用程序可能存在内存泄漏:它继续分配额外的对象,而不允许其他对象超出作用域。在第一种情况下,增加堆大小将解决问题;在第二种情况下,增加堆大小只会推迟错误的发生。

在任一情况下,都需要进行堆转储分析以找出消耗最多内存的是什么;然后可以专注于减少这些对象的数量(或大小)。如果应用程序存在内存泄漏,则可以在几分钟内连续进行堆转储,并进行比较。mat已经内建了这种功能:如果打开了两个堆转储,mat有一个选项可以计算两个堆之间直方图的差异。

图 7-5 展示了由集合类(在本例中为HashMap)引起的 Java 内存泄漏的经典案例。(集合类是内存泄漏的最常见原因之一:应用程序将项目插入集合中,并且从不释放它们。)这是一个比较直方图视图:显示了两个堆转储中对象数量的差异。例如,与基线相比,目标堆转储中多了 19,744 个Integer对象。

克服这种情况的最佳方法是改变应用程序逻辑,以便在不再需要时主动从集合中丢弃项目。或者,使用弱引用或软引用的集合可以在应用程序中没有其他引用它们时自动丢弃这些项目,但这些集合也有成本(如本章后面所讨论的)。

直方图比较,显示哈希映射条目数量大幅增加。

图 7-5. 直方图比较

在抛出此类异常时,通常 JVM 不会退出,因为异常只影响 JVM 中的单个线程。让我们来看一个具有两个线程执行计算的 JVM。其中一个可能会收到OutOfMemoryError。默认情况下,该线程的线程处理程序将打印出堆栈跟踪,并且该线程将退出。

但是 JVM 仍然有另一个活动线程,所以 JVM 不会退出。并且因为遇到错误的线程已经终止,因此未来可能在一个 GC 周期中大量的内存可能被声明:所有终止线程引用的对象,而这些对象没有被其他线程引用。因此,生存线程将能够继续执行,并且通常具有足够的堆内存来完成其任务。

处理请求的线程池的服务器框架工作方式基本相同。它们通常会捕获错误并阻止线程终止,但这不会影响此讨论;线程正在执行的请求相关联的内存仍然会变得可收集。

因此,当此错误被抛出时,只有当它导致 JVM 中的最后一个非守护线程终止时,才会对 JVM 产生致命影响。这在服务器框架中永远不会发生,并且在具有多个线程的独立程序中通常不会发生。而且通常这种情况都会很好地解决,因为与活动请求相关联的内存通常会变得可收集。

如果你希望 JVM 在堆内存耗尽时退出,可以设置-XX:+ExitOnOutOfMemoryError标志,默认值为false

GC 超过限制

上一个案例中描述的恢复假设当一个线程遇到内存不足错误时,与该线程相关联的内存将变得可收集,JVM 可以恢复。这并不总是真实的,这导致了 JVM 抛出内存不足错误的最终情况:当它确定自己花费太多时间执行 GC 时:

Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded

当满足以下所有条件时,会抛出此错误:

  • 完全 GC 所花费的时间超过了由-XX:GCTimeLimit=N标志指定的值。默认值为 98(即,如果 98%的时间花在 GC 上)。

  • 完全 GC 回收的内存量小于由-XX:GCHeapFreeLimit=N标志指定的值。默认值为 2,这意味着在完全 GC 期间释放的堆小于 2%时,满足此条件。

  • 上述两个条件已经连续满足了五个完全 GC 周期(该值不可调)。

  • -XX:+UseGCOverheadLimit标志的值为true(默认情况下为true)。

请注意,所有这四个条件必须满足。在不抛出内存不足错误的应用程序中,常见的是连续发生五次以上的完全 GC。这是因为即使应用程序花费了 98%的时间执行完全 GC,但每次 GC 可能会释放堆的超过 2%。在这种情况下,考虑增加GCHeapFreeLimit的值。

注意,作为释放内存的最后一招,如果前两个条件连续四次完整的 GC 循环均满足,则在第五次完整的 GC 循环之前将释放 JVM 中的所有软引用。通常这可以避免错误,因为第五次循环可能会释放超过堆的 2%(假设应用程序使用软引用)。

快速总结

  • 内存不足错误出现的原因多种多样;不要假设堆空间是问题所在。

  • 对于元空间和常规堆来说,内存泄漏是最常见的导致内存不足错误的原因;堆分析工具可以帮助找出泄漏的根本原因。

使用更少的内存

在 Java 中更高效地使用内存的第一种方法是减少堆内存的使用。这个说法应该不足为奇:使用更少的内存意味着堆填充的频率降低,需要的 GC 循环也会减少。效果可能会倍增:更少的年轻代收集意味着对象的老化年龄不经常增加——这意味着对象不太可能晋升到老年代。因此,全量 GC 循环(或并发 GC 循环)的数量会减少。而如果这些全量 GC 循环可以释放更多内存,它们也会更不频繁地发生。

本节探讨了三种减少内存使用的方法:减少对象大小、使用对象的延迟初始化以及使用规范化对象。

减少对象大小

对象占用一定量的堆内存,因此减少内存使用的最简单方法是减少对象的大小。鉴于运行程序的机器的内存限制,可能无法将堆大小增加 10%,但将堆中一半对象的大小减少 20% 可以达到同样的目标。正如在第十二章中讨论的,Java 11 对于 String 对象有这样的优化,这意味着 Java 11 的用户可以将最大堆大小设置为比 Java 8 要求的小 25%,而不会影响 GC 或性能。

对象的大小可以通过(显而易见地)减少它所包含的实例变量的数量以及(不那么明显地)减少这些变量的大小来减少。

表 7-1. Java 类型实例变量的字节大小

类型 大小
byte 1
char 2
short 2
int 4
float 4
long 8
double 8
reference 8(在 32 位 Windows JVM 中为 4)^(a)
^(a) 详见“压缩指针”获取更多详情。

此处的 reference 类型是对任何 Java 对象(类的实例或数组)的引用。这个空间仅用于引用本身。包含对其他对象引用的对象的大小因是否考虑对象的浅层、深层或保留大小而异,但该大小还包括一些不可见的对象头字段。对于普通对象,在 32 位 JVM 上,头字段的大小为 8 字节,在 64 位 JVM 上为 16 字节(无论堆大小如何)。对于数组,在 32 位 JVM 或堆大小小于 32 GB 的 64 位 JVM 上,头字段的大小为 16 字节,否则为 24 字节。

例如,考虑以下类定义:

public class A {
    private int i;
}

public class B {
    private int i;
    private Locale l = Locale.US;
}

public class C {
    private int i;
    private ConcurrentHashMap chm = new ConcurrentHashMap();
}

在 64 位 JVM(堆大小小于 32 GB)上,这些对象的单个实例的实际大小如 表 7-2 所示。

表 7-2. 简单对象的字节大小

浅层大小 深层大小 保留大小
A 16 16 16
B 24 216 24
C 24 200 200

在类 B 中,定义 Locale 引用会增加 8 字节到对象大小,但在该示例中,Locale 对象是被其他类共享的。如果类不需要 Locale 对象,包含该实例变量只会浪费额外的引用字节。然而,如果应用程序创建大量 B 类的实例,这些字节会累积。

另一方面,定义和创建 ConcurrentHashMap 对象会消耗额外的字节用于对象引用,再加上哈希映射对象的额外字节。如果哈希映射从未被使用,那么 C 类的实例就会显得浪费。

仅定义必需的实例变量是节省对象空间的一种方法。不那么明显的情况涉及使用较小的数据类型。如果一个类需要跟踪八种可能的状态之一,可以使用 byte 而不是 int,可能会节省 3 字节。在频繁实例化的类中,使用 float 替代 doubleint 替代 long 等,可以帮助节省内存,尤其是在使用适当大小的集合(或者使用简单的实例变量而不是集合)时。如 第十二章 中讨论的那样,这可以实现类似的节省。

减少对象中的实例字段可以帮助减小对象的大小,但也存在一个灰色地带:那些用于存储基于数据片段计算结果的对象字段怎么处理?这是时间与空间之间的经典计算机科学折衷:是花费内存(空间)来存储值,还是花费时间(CPU 周期)根据需要计算值?在 Java 中,这种折衷也适用于 CPU 时间,因为额外的内存可能导致 GC 消耗更多的 CPU 周期。

例如,String 的哈希码是通过对字符串的每个字符进行加权求和得出的,这在计算上是相对耗时的。因此,String 类将该值存储在实例变量中,这样哈希码只需要计算一次:最终,重用该值几乎总是比不存储它而节省的内存效果更好。另一方面,大多数类的 toString() 方法不会缓存对象的字符串表示形式在实例变量中,这既会消耗实例变量的内存,也会消耗其引用的字符串的内存。通常,计算新字符串所需的时间比保留字符串引用所需的内存通常提供更好的性能。 (另外,String 的哈希值经常使用,对象的 toString() 表示通常很少使用。)

这绝对是一个因人而异的情况,以及在时间/空间连续体中切换使用内存以缓存值和重新计算值之间的最佳点,将取决于许多因素。如果减少 GC 是目标,平衡将更倾向于重新计算。

快速总结

  • 减少对象大小通常可以提高 GC 的效率。

  • 一个对象的大小并不总是立即显而易见:对象被填充以适应 8 字节边界,而对象引用的大小在 32 位和 64 位 JVM 之间是不同的。

  • 即使 null 实例变量也会在对象类中占用空间。

使用延迟初始化

大多数情况下,关于是否需要特定实例变量的决定并不像前一节所述的那样黑白分明。一个特定的类可能只有 10% 的时间需要一个 Calendar 对象,但是创建 Calendar 对象是昂贵的,因此保留该对象而不是按需重新创建它是有意义的。这是一种 延迟初始化 可以帮助的情况。

到目前为止,这个讨论假设实例变量是急切地初始化的。一个需要使用 Calendar 对象(并且不需要线程安全性)的类可能如下所示:

public class CalDateInitialization {
    private Calendar calendar = Calendar.getInstance();
    private DateFormat df = DateFormat.getDateInstance();

    private void report(Writer w) {
        w.write("On " + df.format(calendar.getTime()) + ": " + this);
    }
}

而延迟初始化字段则在计算性能上有少许的折衷——代码必须每次执行时测试变量的状态:

public class CalDateInitialization {
    private Calendar calendar;
    private DateFormat df;

    private void report(Writer w) {
        if (calendar == null) {
            calendar = Calendar.getInstance();
            df = DateFormat.getDateInstance();
        }
        w.write("On " + df.format(calendar.getTime()) + ": " + this);
    }
}

当涉及的操作很少使用时,最好使用延迟初始化。如果操作经常使用,就不会节省内存(因为它总是被分配),并且在常见操作上会有轻微的性能损失。

当涉及的代码必须是线程安全时,延迟初始化变得更加复杂。作为第一步,最简单的方法是简单地添加传统的同步:

public class CalDateInitialization {
    private Calendar calendar;
    private DateFormat df;

    private synchronized void report(Writer w) {
        if (calendar == null) {
            calendar = Calendar.getInstance();
            df = DateFormat.getDateInstance();
        }
        w.write("On " + df.format(calendar.getTime()) + ": " + this);
    }
}

引入同步机制到解决方案中,可能会导致同步成为性能瓶颈的可能性增加。这种情况应该很少见。懒初始化带来的性能好处只有在很少初始化这些字段的对象时才会发生,因为如果通常初始化这些字段,实际上并没有节省内存。因此,当一个不经常使用的代码路径突然被大量线程同时使用时,同步对于懒初始化字段会成为一个瓶颈。这种情况并非不可想象,但也不是最常见的情况。

只有当惰性初始化的变量本身是线程安全的时,才能解决那个同步瓶颈。DateFormat对象不是线程安全的,所以在当前示例中,无论锁是否包括Calendar对象,都不会有什么影响:如果懒初始化对象突然被频繁使用,围绕DateFormat对象的必需同步将是一个问题。线程安全的代码应该如下所示:

public class CalDateInitialization {
    private Calendar calendar;
    private DateFormat df;

    private void report(Writer w) {
        unsychronizedCalendarInit();
        synchronized(df) {
            w.write("On " + df.format(calendar.getTime()) + ": " + this);
        }
    }
}

涉及不是线程安全的实例变量的惰性初始化可以始终在该变量周围进行同步(例如,使用前面显示的方法的synchronized版本)。

考虑一个稍微不同的例子,在这个例子中,一个大的ConcurrentHashMap是惰性初始化的:

public class CHMInitialization {
    private ConcurrentHashMap chm;

    public void doOperation() {
        synchronized(this) {
            if (chm == null) {
                chm = new ConcurrentHashMap();
                ... code to populate the map ...
            }
        }
        ...use the chm...
    }
}

因为ConcurrentHashMap可以被多个线程安全访问,在这个例子中额外的同步是懒初始化可能引入同步瓶颈的少见情况之一。(尽管这种瓶颈仍然很少见;如果对哈希映射的访问频率很高,考虑懒初始化是否真的能节省任何东西。)双重检查锁定惯用法解决了这个瓶颈:

public class CHMInitialization {
    private volatile ConcurrentHashMap instanceChm;

    public void doOperation() {
        ConcurrentHashMap chm = instanceChm;
        if (chm == null) {
            synchronized(this) {
                chm = instanceChm;
                if (chm == null) {
                    chm = new ConcurrentHashMap();
                    ... code to populate the map
                    instanceChm = chm;
                }
            }
            ...use the chm...
        }
    }
}

存在重要的线程问题:实例变量必须声明为volatile,并且通过将实例变量分配给一个局部变量可以获得轻微的性能好处。更多细节请参见第九章;在偶尔有需要的情况下,这是要遵循的设计模式。

急切地去初始化

惰性初始化变量的推论是通过将它们的值设置为null急切地去初始化它们。这允许相关对象更快地被垃圾收集器回收。尽管理论上听起来像一件好事,但实际上只在有限的情况下才有用。

一个适合惰性初始化的变量可能看起来像是急切去初始化的候选变量:在前面的例子中,CalendarDateFormat 对象可以在 report() 方法完成后设置为 null。然而,如果该变量不会在方法的后续调用中使用(或者在类的其他地方使用),那么没有理由在首次创建实例变量时将其作为实例变量。只需在方法中创建局部变量,在方法完成时,局部变量将会超出作用域,垃圾收集器可以释放它。

与关于不需要急切去初始化变量的规则的常见例外情况相反,类似于 Java 集合框架中的类:这些类长时间持有数据的引用,然后被告知不再需要该数据。考虑 JDK 中 ArrayList 类的 remove() 方法的实现(某些代码已简化):

public E remove(int index) {
    E oldValue = elementData(index);
    int numMoved = size - index - 1;
    if (numMoved > 0)
        System.arraycopy(elementData, index+1,
                         elementData, index, numMoved);
    elementData[--size] = null; // clear to let GC do its work
    return oldValue;
}

JDK 源码本身几乎没有注释,但是有关 GC 的代码注释如下:像这样将变量设置为 null 的操作是不寻常的,因此需要一些解释。在这种情况下,跟踪数组的最后一个元素被移除时发生的情况。数组中剩余的项目数量——size 实例变量被减少。假设 size 从 5 减少到 4。现在 elementData[4] 中存储的任何内容都无法访问:它超出了数组的有效大小。

elementData[4] 在这种情况下是一个过时的引用。elementData 数组可能会长时间保持活动状态,因此不再需要引用的任何内容都需要明确设置为 null

这种过时引用的概念是关键:如果长寿类缓存然后丢弃对象引用,则必须小心避免过时引用。否则,显式地将对象引用设置为 null 将不会带来太多性能好处。

快速总结

  • 仅当常见的代码路径将变量保持未初始化状态时才使用惰性初始化。

  • 线程安全代码的惰性初始化是不寻常的,但通常可以依赖于现有的同步机制。

  • 对于使用线程安全对象进行代码的惰性初始化,使用双重检查锁定。

使用不可变和规范化对象

在 Java 中,许多对象类型是不可变的。这包括具有对应原始类型的对象——IntegerDoubleBoolean 等,以及其他基于数字的类型,如 BigDecimal。当然,最常见的 Java 对象是不可变的 String。从程序设计的角度来看,为自定义类表示不可变对象通常是一个好主意。

当这些对象快速创建并且被丢弃时,它们对年轻代的影响很小;正如你在 第五章 中看到的,该影响是有限的。但与任何对象一样,如果许多不可变对象晋升到老年代,性能可能会受到影响。

因此,没有理由避免设计和使用不可变对象,即使这些对象似乎不能被改变和必须重新创建可能有点逆向思维。但在处理这些对象时通常可以进行的优化之一是避免创建相同对象的重复副本。

这个最好的例子是Boolean类。任何 Java 应用程序只需要两个Boolean类的实例:一个为 true,一个为 false。不幸的是,Boolean类设计不佳。因为它有一个公共构造函数,应用程序可以随意创建任意数量的这些对象,尽管它们都与两个标准Boolean对象之一完全相同。更好的设计应该是Boolean类只有一个私有构造函数,并且静态方法根据参数返回Boolean.TRUEBoolean.FALSE。如果你的不可变类可以遵循这样的模式,你可以防止它们贡献到应用程序的堆使用中。(理想情况下,显然你永远不应该创建Boolean对象;必要时应该只使用Boolean.TRUEBoolean.FALSE。)

这些不可变对象的唯一表示被称为对象的标准版本

创建标准对象

即使某个特定类的对象宇宙几乎是无限的,使用标准值通常可以节省内存。JDK 提供了一种机制来为最常见的不可变对象做到这一点:字符串可以调用intern()方法来找到字符串的标准版本。更多关于字符串驻留的细节在第十二章中进行了详细考察;现在我们来看如何为自定义类实现同样的功能。

要使对象标准化,创建一个映射来存储对象的标准版本。为了防止内存泄漏,确保映射中的对象是弱引用的。这样的类的框架如下所示:

public class ImmutableObject {
    private static WeakHashMap<ImmutableObject, ImmutableObject>
        map = new WeakHashMap();

    public ImmutableObject canonicalVersion(ImmutableObject io) {
        synchronized(map) {
            ImmutableObject canonicalVersion = map.get(io);
            if (canonicalVersion == null) {
                map.put(io, new WeakReference(io));
                canonicalVersion = io;
            }
            return canonicalVersion;
        }
    }
}

在多线程环境中,同步可能会成为瓶颈。如果坚持使用 JDK 类,没有简单的解决方案,因为它们不提供用于弱引用的并发哈希映射。然而,已经有提议在 JDK 中添加CustomConcurrentHashMap,最初作为 Java 规范请求(JSR)166 的一部分,并且你可以找到各种第三方实现这样的类。

快速总结

  • 不可变对象提供了特殊生命周期管理的可能性:标准化。

  • 通过标准化消除不可变对象的重复副本可以大大减少应用程序使用的堆量。

对象生命周期管理

本章讨论的第二个广泛的内存管理主题是对象生命周期管理。在大多数情况下,Java 试图最小化开发人员管理对象生命周期所需的工作:开发人员在需要时创建对象,当它们不再需要时,对象超出范围并由垃圾收集器释放。

有时,这种正常的生命周期并不是最优的。一些对象的创建成本很高,管理这些对象的生命周期将提高应用程序的效率,即使需要垃圾收集器做更多的工作。本节探讨了何时以及如何改变对象的正常生命周期,无论是通过重用对象还是保持对它们的特殊引用。

对象重用

对象重用通常通过两种方式实现:对象池和线程局部变量。全球的 GC 工程师现在正因为这些技术而叹息,因为它们会影响 GC 的效率。特别是出于这个原因,对象池技术在 GC 圈子里广受厌恶,虽然在开发圈子里,出于许多其他原因,对象池也广受厌恶。

在某种程度上,这个立场的原因似乎很明显:被重用的对象在堆中存活时间长。如果堆中有很多对象,那么创建新对象的空间就越少,因此 GC 操作会更频繁。但这只是故事的一部分。

正如您在第六章中看到的,当对象创建时,它会分配到 Eden 区。它会在幸存者空间中来回移动几个年轻代 GC 周期,最终最终被提升到老年代。每次处理新创建的(或最近创建的)池化对象时,GC 算法必须执行一些工作来复制它并调整对它的引用,直到它最终进入老年代。

虽然看起来像是结束了,但一旦对象被提升到老年代,就可能导致更多的性能问题。执行完整的 GC 所需的时间与老年代中仍然存活的对象数量成正比。存活数据的量比堆的大小更重要;处理具有少量存活对象的 3 GB 老年代比处理其中 75% 对象存活的 1 GB 老年代更快。

使用并发收集器并避免完整的 GC 并不能使情况好转太多,因为并发收集器标记阶段所需的时间也与仍存活数据的量有关。特别是对于 CMS,池中的对象可能在不同时间被提升,增加了由于碎片化而导致并发失败的机会。总体而言,对象在堆中存留的时间越长,GC 的效率就越低。

因此:对象重用是不好的。现在我们可以讨论如何以及何时重用对象了。

JDK 提供了一些常见的对象池:讨论了线程池,详见第九章,以及软引用。软引用稍后在本节中讨论,本质上是一大批可重用对象的池。与此同时,Java 服务器依赖于对象池来管理与数据库和其他资源的连接。线程本地值也是类似情况;JDK 充满了使用线程本地变量避免重新分配某些类型对象的类。显然,即使是 Java 专家也理解某些情况下需要对象重用的必要性。

对象重用的原因是许多对象的初始化成本很高,重用它们比增加 GC 时间的权衡更有效。对于像 JDBC 连接池这样的事物来说,创建网络连接,可能登录并建立数据库会话是昂贵的。在这种情况下,对象池是一个很大的性能优势。线程池是为了节省创建线程的时间而池化线程;随机数生成器作为线程本地变量提供,以节省初始化所需的时间;等等。

这些示例共享的一个特点是对象的初始化时间很长。在 Java 中,对象的分配是快速且廉价的(对于不重用对象的论点通常集中在此部分)。对象的初始化性能取决于对象本身。你应该仅考虑重新使用初始化成本非常高的对象,只有在初始化这些对象的成本是程序中主要操作之一时才这样做。

这些示例共享的另一个特点是共享对象的数量往往很少,这减少了它们对 GC 操作的影响:它们不足以减慢 GC 循环。在池中有少量对象不会对 GC 效率产生太大影响;将堆填满池化对象会显著减慢 GC 的速度。

这里只是 JDK 和 Java 程序重用对象的一些示例(以及原因):

线程池

初始化线程的成本很高。

JDBC 连接池

初始化数据库连接的成本很高。

大数组

Java 要求在分配数组时,数组中的所有单个元素必须初始化为默认的零值(适当时为 null0false)。对于大数组来说,这可能是耗时的。

本地 NIO 缓冲区

分配直接的 java.nio.Buffer(从调用 allocateDirect() 方法返回的缓冲区)是一种昂贵的操作,无论缓冲区的大小如何。最好创建一个大缓冲区,并从中切片以根据需要管理缓冲区,并返回以供未来操作重用。

安全类

MessageDigestSignature 和其他安全算法的实例化成本很高。

字符串编码器和解码器对象

JDK 中的各种类创建和重用这些对象。大多数情况下,这些也是软引用,正如你将在下一节中看到的那样。

StringBuilder辅助程序

在计算中间结果时,BigDecimal类会重复使用一个StringBuilder对象。

随机数生成器

无论是Random还是(尤其是)SecureRandom类的实例,种子成本都很高。

来自 DNS 查找的名称

网络查找很昂贵。

ZIP 编码器和解码器

有趣的是,这些对象初始化起来并不特别昂贵。然而,它们释放起来却很昂贵,因为它们依赖于对象终结来确保它们使用的本地内存也被释放。详见“终结器和最终引用”以获取更多详细信息。

两种选择(对象池和线程本地变量)在性能上有所不同。让我们更详细地看看这些差异。

对象池

对象池因多种原因而不受喜爱,其中只有一些与其性能有关。它们可能很难正确地调整大小。它们还将对象管理的负担重新放回到程序员身上:程序员不能简单地让对象超出作用域,而必须记得将对象返回到池中。

但重点在于对象池的性能,它受以下因素的影响:

GC 影响

正如你所见,持有大量对象会显著降低 GC 的效率(有时甚至极大地)。

同步

对象池不可避免地会进行同步,如果对象经常被移除和替换,那么池可能会有很多争用。结果是,访问池可能会变得比初始化新对象还要慢。

节流

对象池的这种性能影响可能是有益的:池允许对稀缺资源的访问进行节流。正如在第二章中讨论的那样,如果试图增加系统的负载超过其处理能力,性能将会下降。这就是线程池至关重要的原因之一。如果太多线程同时运行,CPU 将不堪重负,性能将下降(例如在第九章中有示例)。

这个原则也适用于远程系统访问,并且在 JDBC 连接中经常见到。如果向数据库创建的 JDBC 连接超过其处理能力,数据库的性能将会下降。在这些情况下,最好通过限制池的大小来节流资源(例如 JDBC 连接),即使这意味着应用程序中的线程必须等待空闲资源。

线程本地变量

通过将它们存储为线程本地变量来重复使用对象会导致各种性能权衡:

生命周期管理

线程本地变量比池中的对象更易于管理且成本更低。这两种技术都要求您获取初始对象:您可以从池中检出对象,或者在线程本地对象上调用get()方法。但是,对象池要求在使用完毕后将对象返回(否则其他人无法使用它)。线程本地对象始终在线程内可用,不需要显式返回。

基数

线程本地变量通常会在线程数和保存(重用)对象数之间建立一对一的对应关系。这并不是严格的情况。线程的变量副本在线程第一次使用时才创建,因此可能会比线程少的保存对象。但不能有比线程更多的保存对象,大部分时间它们的数量是相同的。

另一方面,对象池的大小可以任意设定。如果一个请求有时需要一个 JDBC 连接,有时需要两个,那么 JDBC 池可以相应地设置大小(例如,为 8 个线程设置 12 个连接)。线程本地变量无法有效地做到这一点;它们也不能调节对资源的访问(除非线程数本身作为节流器)。

同步

线程本地变量不需要同步,因为它们只能在单个线程内使用;线程本地的get()方法相对较快。(这并不总是如此;在 Java 的早期版本中,获取线程本地变量是昂贵的。如果您因为过去性能不佳而回避线程本地变量,请重新考虑在当前版本的 Java 中使用它们。)

同步提出了一个有趣的观点,因为线程本地对象的性能优势通常是通过节省同步成本来表达的(而不是通过重用对象来节省)。例如,Java 提供了ThreadLocalRandom类;在示例股票应用程序中使用了该类(而不是单个Random实例)。否则,本书中的许多示例都会在单个Random对象的next()方法上遇到同步瓶颈。使用线程本地对象是避免同步瓶颈的好方法,因为只有一个线程可以使用该对象。

然而,如果这些示例每次需要时都简单地创建一个新的Random类实例,同步问题就很容易解决了。然而,用这种方式解决同步问题并没有帮助整体性能:初始化一个Random对象是昂贵的,而频繁创建该类的实例会比多个线程共享一个实例的同步瓶颈性能更差。

使用ThreadLocalRandom类可以获得更好的性能,如表 7-3 所示。本例计算了在三种情况下每个四个线程创建 10,000 个随机数所需的时间:

  • 每个线程都会构造一个新的Random对象来计算 10,000 个数。

  • 所有线程共享一个常见的静态Random对象。

  • 所有线程共享一个常见的静态ThreadLocalRandom对象。

表 7-3. 使用ThreadLocalRandom生成 10,000 个随机数的效果

操作 耗时
创建新的Random 134.9 ± 0.01 微秒
ThreadLocalRandom 52.0 ± 0.01 微秒
共享Random 3,763 ± 200 微秒

在竞争锁的情况下进行微基准测试总是不可靠的。在本表的最后一行中,线程几乎总是在争夺Random对象上的锁;在实际应用中,争用量将会少得多。尽管如此,使用共享对象可能会看到一些争用,而每次创建新对象的代价超过使用ThreadLocalRandom对象要昂贵两倍以上。

这里的教训——以及对象重用的一般经验——是,当对象的初始化时间很长时,不要害怕探索对象池或线程本地变量来重用那些昂贵的创建对象。然而,总是要保持平衡:大型通用类的对象池几乎肯定会导致更多性能问题而不是解决问题。将这些技术留给那些初始化昂贵且重用对象数量较少的类。

快速总结

  • 一般情况下不鼓励对象重用作为通用操作,但对于初始化代价高的少量对象可能是适当的。

  • 在通过对象池或线程本地变量重用对象之间存在权衡。一般来说,线程本地变量更容易处理,假设想要实现线程与可重用对象的一对一对应关系。

软引用、弱引用及其他引用

Java 中的软引用和弱引用也允许对象被重用,尽管作为开发者,我们并不总是用这些术语来思考。这些引用类型——通常我们将其称为不定引用——更常用于缓存长时间计算或数据库查询的结果,而不是重用简单对象。例如,在股票服务器中,可以使用间接引用来缓存getHistory()方法的结果(这需要进行长时间计算或长时间数据库调用)。这个结果只是一个对象,当通过不定引用进行缓存时,我们只是因为初始化代价昂贵才会重用这个对象。

对许多程序员来说,这种情况“感觉”不同。事实上,甚至术语也反映了这一点:没有人会说“缓存”一个线程以便重用,但我们将在数据库操作结果缓存方面探讨不定引用的重用。

不定引用相较于对象池或线程本地变量的优势在于,不定引用最终会被垃圾收集器回收。如果对象池包含最近执行的最后 10,000 个股票查找,并且堆空间开始不足,那么应用程序就无法继续运行:在存储这些 10,000 个元素后,剩余堆空间就是应用程序可以使用的所有剩余堆空间。如果这些查找通过不定引用存储,JVM 可以释放一些空间(取决于引用类型),从而提高 GC 吞吐量。

缺点是不定引用对垃圾收集器效率的影响略大。图 7-6 显示了没有和有不定引用(在本例中是软引用)时内存使用的对比。

被缓存的对象占用 512 字节。左侧消耗的内存就是这些(除了指向对象的实例变量的内存)。右侧,对象被缓存在 SoftReference 对象中,增加了 40 字节的内存消耗。不定引用与任何其他对象一样:它们会消耗内存,并且其他东西(图右侧的 cachedValue 变量)会强引用它们。

不定引用对垃圾收集器的第一个影响是应用程序使用更多内存。对垃圾收集器的第二个更大影响是,不定引用对象要经过至少两个 GC 周期才能被垃圾收集器回收。

不定引用内存使用示意图。

图 7-6. 不定引用分配的内存

图 7-7 显示了引用不再被强引用(即 lastViewed 变量被设置为 null)时的情况。如果没有引用指向 StockHistory 对象,则在处理该对象所在代的下一个 GC 期间,它将被释放。因此,图的左侧现在消耗 0 字节。

图的右侧仍然消耗内存。引用被释放的确切时间取决于不定引用的类型,但现在我们先来看软引用的情况。引用将一直保留,直到 JVM 决定对象最近未被使用。在此之后,第一个 GC 周期释放引用对象,但不释放不定引用对象本身。应用程序最终的内存状态如 图 7-8 所示。

不定引用内存使用示意图。

图 7-7. 不定引用通过 GC 周期保留内存

无限引用内存使用的示意图

图 7-8. 无限引用不会立即被清除

无限引用对象本身现在至少有两个强引用:应用程序创建的原始强引用和在引用队列中由 JVM 创建的新强引用。在无限引用对象本身被垃圾收集器回收之前,所有这些强引用都必须被清除。

通 通常,这种清理工作由处理引用队列的代码完成。该代码会被通知有一个新对象进入队列,并立即移除所有对该对象的强引用。然后,在下一个 GC 周期中,无限引用对象(被引用对象)将被释放。最坏的情况是,引用队列不会立即处理,可能会经过多个 GC 周期,才会清理干净。即便是在最好的情况下,无限引用也必须经过两个 GC 周期才能被释放。

根据无限引用的类型,这个通用算法存在一些重要的变化,但所有无限引用在某种程度上都存在这种惩罚。

软引用

软引用 用于当所讨论的对象有很大可能在将来被重用,但你希望让垃圾收集器在对象未被最近使用时回收它(计算时还考虑堆的可用内存量)。软引用基本上是一个大型的最近最少使用(LRU)对象池。确保及时清除软引用是获得良好性能的关键。

这是一个例子。股票服务器可以设置一个全局缓存,按股票代码(或股票代码和日期)存储股票历史。当有请求查询从2019 年 9 月 1 日2019 年 12 月 31 日TPKS股票历史时,可以查询缓存,看看是否已经有类似请求的结果。

缓存这些数据的原因是请求往往对某些项目的需求比其他项目更频繁。如果TPKS是最常请求的股票,它可以预计会保留在软引用缓存中。另一方面,单独的KENG请求会在缓存中存活一段时间,但最终会被回收。这也考虑了随时间变化的请求:对DNLD的请求集群可以重用第一个请求的结果。随着用户意识到DNLD是一个糟糕的投资,这些缓存项最终会从堆中消失。

软引用何时被释放?首先,被引用对象不能在其他地方被强引用。如果软引用是对其被引用对象的唯一剩余引用,并且软引用最近没有被访问,那么在下一个 GC 周期中才会释放被引用对象。具体而言,公式的函数像这样伪代码:

long ms = SoftRefLRUPolicyMSPerMB * AmountOfFreeMemoryInMB;
if (now - last_access_to_reference > ms)
   free the reference

This code has two key values. The first value is set by the -XX:SoftRefLRUPolicyMSPerMB=N flag, which has a default value of 1,000.

The second value is the amount of free memory in the heap (once the GC cycle has completed). The free memory in the heap is calculated based on the maximum possible size of the heap minus whatever is in use.

So how does that all work? Take the example of a JVM using a 4 GB heap. After a full GC (or a concurrent cycle), the heap might be 50% occupied; the free heap is therefore 2 GB. The default value of SoftRefLRUPolicyMSPerMB (1,000) means that any soft reference that has not been used for the past 2,048 seconds (2,048,000 ms) will be cleared: the free heap is 2,048 (in megabytes), which is multiplied by 1,000:

   long ms = 2048000; // 1000 * 2048
   if (System.currentTimeMillis() - last_access_to_reference_in_ms > ms)
       free the reference

If the 4 GB heap is 75% occupied, objects not accessed in the last 1,024 seconds are reclaimed, and so on.

To reclaim soft references more frequently, decrease the value of the SoftRefLRUPolicyMSPerMB flag. Setting that value to 500 means that a JVM with a 4 GB heap that is 75% full will reclaim objects not accessed in the past 512 seconds.

Tuning this flag is often necessary if the heap fills up quickly with soft references. Say that the heap has 2 GB free and the application starts to create soft references. If it creates 1.7 GB of soft references in less than 2,048 seconds (roughly 34 minutes), none of those soft references will be eligible to be reclaimed. There will be only 300 MB of space left in the heap for other objects; GC will occur frequently as a result (yielding bad overall performance).

If the JVM completely runs out of memory or starts thrashing too severely, it will clear all soft references, since the alternative would be to throw an OutOfMemoryError. Not throwing the error is good, but indiscriminately throwing away all the cached results is probably not ideal. Hence, another time to lower the SoftRefLRUPolicyMSPerMB value is when the reference processing GC logs indicates that a very large number of soft references are being cleared unexpectedly. As discussed in “GC overhead limit reached”, that will occur only after four consecutive full GC cycles (and if other factors apply).

On the other side of the spectrum, a long-running application can consider raising that value if two conditions are met:

  • A lot of free heap is available.

  • The soft references are infrequently accessed.

That is an unusual situation. It is similar to a situation discussed about setting GC policies: you may think that if the soft reference policy value is increased, you are telling the JVM to discard soft references only as a last resort. That is true, but you’ve also told the JVM not to leave any headroom in the heap for normal operations, and you are likely to end up spending too much time in GC instead.

因此,警告是不要使用太多软引用,因为它们很容易填满整个堆。这个警告甚至比对创建具有太多实例的对象池的警告更强烈:当对象数量不太多时,软引用效果很好。否则,考虑一个更传统的、作为 LRU 缓存实现的、有界大小的对象池。

弱引用

当涉及的引用在多个线程同时使用时,应该使用弱引用。否则,弱引用很可能被垃圾收集器回收:仅具有弱引用的对象在每个 GC 周期中都会被回收。

这意味着弱引用永远不会进入(对软引用)在图 7-7 中所示的状态。当强引用被移除时,弱引用立即被释放。因此,程序状态直接从图 7-6 移动到图 7-8。

不过,这里有趣的效果在于弱引用最终在堆中的位置。引用对象就像其他 Java 对象一样:它们在年轻代中创建,最终晋升到老年代。如果弱引用的引用对象在弱引用本身仍在年轻代时被释放,那么弱引用将很快被释放(在下一个次要 GC)。(这假设引用队列快速处理所涉及对象。)如果引用对象保留时间足够长,使得弱引用被晋升到老年代,那么弱引用将不会在下一个并发或完整 GC 周期之前被释放。

以股票服务器的缓存为例,假设我们知道如果特定客户访问TPKS,他们几乎总是会再次访问它。基于客户端连接保持该股票的值作为强引用是有道理的:它将始终对他们可用,而一旦他们登出,连接就清除了并且回收了内存。

现在,当另一个用户需要TPKS的数据时,他们将如何找到它?由于对象在内存中的某处,我们不希望再次查找它,但连接基础的缓存对第二个用户不起作用。因此,除了基于连接保持TPKS数据的强引用外,在全局缓存中保持该数据的弱引用也是有意义的。现在第二个用户将能够找到TPKS数据——假设第一个用户没有关闭他们的连接。(这是在“堆分析”中使用的场景,数据有两个引用,不容易通过查看具有最大保留内存的对象找到。)

这就是所谓的同时访问。这就像我们对 JVM 说的:“嘿,只要有人对这个对象感兴趣,请告诉我它在哪里,但是如果他们不再需要它,请将其丢弃,我会自己重新创建它。”与软引用相比,后者基本上是说:“嘿,尽量保持这个对象在内存中,只要有足够的内存,并且似乎偶尔有人访问它。”

不理解这个区别是在使用弱引用时最常见的性能问题。不要误以为弱引用和软引用类似,只是释放得更快:一个被软引用引用的对象将可用(通常)几分钟甚至几小时,但一个被弱引用引用的对象只有在其引用对象仍然存在时才可用(取决于下一个 GC 周期清除它)。

Finalizers 和 final references

每个 Java 类都从 Object 类继承了一个 finalize() 方法;该方法可用于在对象符合 GC 条件后清理数据。这听起来像是一个不错的功能,而且在某些情况下是必需的。然而,在实践中,它被证明是一个糟糕的想法,你应该尽量避免使用这个方法。

Finalizer 是如此糟糕,以至于在 JDK 11 中 finalize() 方法已被弃用(尽管在 JDK 8 中没有)。我们将在本节的其余部分详细讨论为什么 finalizer 是糟糕的问题,但首先,让我们先有点动力。Finalizer 最初是为了解决 JVM 管理对象生命周期时可能出现的问题而引入 Java 中的。在像 C++ 这样的语言中,当你不再需要对象时,必须显式地销毁对象,对象的析构函数可以清理对象的状态。在 Java 中,当对象因超出作用域而自动回收时,finalizer 充当了析构函数的角色。

例如,JDK 中的类在操作 ZIP 文件时使用了一个 finalizer,因为打开 ZIP 文件会使用分配本地内存的本地代码。当 ZIP 文件关闭时,该内存会被释放,但如果开发者忘记调用 close() 方法会发生什么呢?即使开发者忘记了,finalizer 也可以确保已调用 close() 方法。

JDK 8 中许多类都像这样使用 finalizer,但在 JDK 11 中,它们全部使用了一个不同的机制:Cleaner 对象。这将在下一节中讨论。如果您有自己的代码,并且有意使用 finalizer(或者在不可用清理器机制的 JDK 8 上运行),请继续阅读以了解如何处理它们。

最终器(Finalizers)因为功能原因不好,并且对性能也有影响。最终器实际上是一个不定引用的特殊情况:JVM 使用一个私有引用类(java.lang.ref.Finalizer,它又是一个 java.lang.ref.FinalReference)来跟踪那些定义了 finalize() 方法的对象。当分配一个定义了 finalize() 方法的对象时,JVM 会分配两个对象:对象本身和一个 Finalizer 引用,该引用使用对象作为其引用物。

与其他不定引用一样,至少需要两个 GC 周期才能释放不定引用对象。然而,在这里的惩罚要比其他不定引用类型大得多。当软引用或弱引用的引用物变得可回收时,引用物本身立即被释放;这导致了先前在 图 7-8 中展示的内存使用情况。软引用或弱引用被放置在引用队列上,但引用对象不再引用任何东西(也就是说,其 get() 方法返回 null 而不是原始引用物)。对于软引用和弱引用,两个 GC 周期的惩罚仅适用于引用对象本身(而不是引用物)。

对于最终引用,情况并非如此。Finalizer 类的实现必须访问引用物以调用引用物的 finalize() 方法,因此当最终器引用放置在其引用队列上时,引用物无法被释放。当最终器的引用物变得可回收时,程序状态由 图 7-9 反映。

当引用队列处理最终器时,Finalizer 对象(通常)将从队列中移除,然后可以进行回收。只有这时候引用物才会被释放。这就是为什么最终器对垃圾收集的性能影响要比其他不定引用大得多——引用物所消耗的内存可能比不定引用对象本身消耗的内存更多。

不定引用内存使用的图示。

图 7-9. 最终引用保留更多内存

这导致了最终器的功能问题,即 finalize() 方法可能会无意中创建对引用物的新强引用。这再次导致 GC 性能损耗:现在引用物直到再次不再被强引用时才会被释放。功能上,这会造成一个大问题,因为下次引用物变得可回收时,其 finalize() 方法将不会被调用,引用物的预期清理也不会发生。这种错误足以成为尽量少用最终器的理由。

因此,如果不得不使用最终器,请确保对象访问的内存尽可能少。

存在一个避免至少某些这些问题的终结器的替代方法,特别是允许在正常 GC 操作期间释放参考对象。这是通过简单地使用另一种类型的无限制引用来实现的,而不是隐式使用Finalizer引用。

有时建议使用另一种无限制的引用类型:PhantomReference类。(事实上,这就是 JDK 11 所做的,如果你在 JDK 11 上,Cleaner对象会比这里呈现的示例更容易使用,这在 JDK 8 中真正有用。)这是一个很好的选择,因为在强引用不再引用参考物体之后,引用对象会相对快速被清理,而且在调试时,引用的目的是清楚的。尽管如此,使用弱引用也可以实现相同的目标(而且,弱引用可以在更多地方使用)。在某些情况下,如果软引用的缓存语义与应用程序的需求匹配,还可以使用软引用。

要创建一个替代的终结器,必须创建一个无限制引用类的子类,以保存在收集参考对象后需要清理的任何信息。然后,在引用对象的方法中执行清理(而不是在参考类中定义finalize()方法)。

这里是这样一个类的概述,它使用了弱引用。构造函数在这里分配了一个本地资源。在正常使用情况下,预计会调用setClosed()方法;这将清理本地内存。

private static class CleanupFinalizer extends WeakReference {

    private static ReferenceQueue<CleanupFinalizer> finRefQueue;
    private static HashSet<CleanupFinalizer> pendingRefs = new HashSet<>();

    private boolean closed = false;

    public CleanupFinalizer(Object o) {
        super(o, finRefQueue);
        allocateNative();
        pendingRefs.add(this);
    }

    public void setClosed() {
        closed = true;
        doNativeCleanup();
    }

    public void cleanup() {
        if (!closed) {
            doNativeCleanup();
        }
    }

    private native void allocateNative();
    private native void doNativeCleanup();
}

然而,弱引用也被放置在一个引用队列中。当从队列中提取引用时,可以检查确保本地内存已被清理(如果尚未清理,则进行清理)。

引用队列的处理发生在守护线程中:

static {
    finRefQueue = new ReferenceQueue<>();
    Runnable r = new Runnable() {
        public void run() {
            CleanupFinalizer fr;
            while (true) {
                try {
                    fr = (CleanupFinalizer) finRefQueue.remove();
                    fr.cleanup();
                    pendingRefs.remove(fr);
                } catch (Exception ex) {
                    Logger.getLogger(
                           CleanupFinalizer.class.getName()).
                           log(Level.SEVERE, null, ex);
                }
            }
        }
    };
    Thread t = new Thread(r);
    t.setDaemon(true);
    t.start();
}

所有这些都在一个private static内部类中,对使用实际类的开发人员隐藏起来,其外观如下:

public class CleanupExample {
    private CleanupFinalizer cf;
    private HashMap data = new HashMap();

    public CleanupExample() {
        cf = new CleanupFinalizer(this);
    }

    ...methods to put things into the hashmap...

    public void close() {
        data = null;
        cf.setClosed();
    }
}

开发人员构建此对象的方式与构建任何其他对象的方式相同。他们被告知调用close()方法,这将清理本地内存,但如果他们没有这样做,也没关系。弱引用仍然存在于幕后,因此在内部类处理弱引用时,CleanupFinalizer类有自己的机会清理该内存。

这个示例的一个棘手部分是需要使用pendingRefs这组弱引用。如果没有这些引用,弱引用本身将在能够将它们放入引用队列之前被收集。

这个示例克服了传统终结器的两个限制:它提供了更好的性能,因为与参考对象相关联的内存(在这种情况下是data哈希映射)在参考物体被收集后立即释放(而不是在finalizer()方法中执行),并且在清理代码中没有办法使参考对象复活,因为它已经被收集。

尽管如此,适用于使用 finalizers 的其他异议也适用于此代码:您无法确保垃圾收集器会释放引用对象,也无法确保引用队列线程会处理队列中的任何特定对象。如果有大量这些对象,处理引用队列将是昂贵的。像所有不确定引用一样,此示例仍应谨慎使用。

清理器对象

在 JDK 11 中,使用新的 java.lang.ref.Cleaner 类替代 finalize() 方法要容易得多。该类使用 PhantomReference 类在对象不再强引用时得到通知。这遵循与我刚刚建议在 JDK 8 中使用的 CleanupFinalizer 类相同的概念,但由于它是 JDK 的核心特性,开发者不必担心设置线程处理和自己的引用:他们只需注册清理器应处理的适当对象,让核心库来处理剩余的工作。

从性能的角度来看,这里的棘手部分是获取“适当”的对象来向清理器注册。清理器将保持对注册对象的强引用,因此该对象本身永远不会变为幽灵可达。相反,您创建一种影子对象并注册它。

举例来说,让我们看看 java.util.zip.Inflater 类。该类需要一些清理,因为它在处理期间必须释放分配的本地内存。当调用 end() 方法时执行此清理代码,并鼓励开发者在完成对象使用时调用该方法。但是,当对象被丢弃时,我们必须确保已调用 end() 方法;否则,我们将面临本地内存泄漏的风险。¹

伪代码中,Inflater 类看起来像这样:

public class java.util.zip.Inflater {
    private static class InflaterZStreamRef implements Runnable {
        private long addr;
        private final Cleanable cleanable;
        InflaterZStreamRef(Inflater owner, long addr) {
            this.addr = addr;
            cleanable = CleanerFactory.cleaner().register(owner, this);
        }

        void clean() {
            cleanable.clean();
        }

        private static native void freeNativeMemory(long addr);
        public synchronized void run() {
            freeNativeMemory(addr);
        }
    }

    private InflaterZStreamRef zsRef;

    public Inflater() {
        this.zsRef = new InflaterZStreamRef(this, allocateNativeMemory());
    }

    public void end() {
        synchronized(zsRef) {
            zsRef.clean();
        }
    }
}

此代码比实际实现更简单,后者(出于兼容性原因)必须跟踪可能重写 end() 方法的子类,并且当然本地内存分配更复杂。此处要理解的重点是,内部类提供了一个 Cleaner 可以强引用的对象。同时,也注册到清理器的外部类(owner)参数提供了触发器:当它只能幽灵可达时,清理器被触发,并且可以使用保存的强引用作为清理的钩子。

注意这里的内部类是 static 的。否则,它将包含对 Inflater 类本身的隐式引用,那么 Inflater 对象就永远无法变为幻像可达:从 CleanerInflaterZStreamRef 对象的引用始终是强引用,从而到 Inflater 对象的引用也是强引用。作为一个规则,将执行清理操作的对象不能包含对需要清理的对象的引用。因此,开发者不鼓励使用 lambda 表达式,而是使用类,因为 lambda 表达式再次很容易引用封闭类。

快速总结

  • 持续(软,弱,幻像和最终)引用改变了 Java 对象的普通生命周期,允许它们以可能比池或线程局部变量更符合 GC 友好的方式重复使用。

  • 当应用程序对对象感兴趣但只有在应用程序的其他地方有强引用时,应使用弱引用。

  • 软引用长时间保持对象,提供简单的 GC 友好的 LRU 缓存。

  • 持续引用会消耗自己的内存,并长时间保持其他对象的内存;应该节省使用。

  • 终结器是最初设计用于对象清理的特殊引用类型;现在鼓励使用新的 Cleaner 类,不再推荐使用终结器。

压缩 Oops

使用简单编程时,64 位 JVM 比 32 位 JVM 慢。这种性能差距是因为 64 位对象引用:64 位引用在堆中占用两倍空间(8 字节),而 32 位引用(4 字节)则不然。这导致了更多的 GC 周期,因为现在堆中的空间更少了,不能容纳其他数据。

JVM 可以通过使用压缩 oops 来补偿额外的内存。Oopsordinary object pointers 的缩写,它们是 JVM 用作对象引用的句柄。当 oops 只有 32 位长时,它们只能引用 4 GB 的内存( 2 32 ),这就是为什么 32 位 JVM 受限于 4 GB 堆大小的原因。(同样的限制也适用于操作系统级别,这就是为什么任何 32 位进程受限于 4 GB 地址空间的原因。)当 oops 是 64 位长时,它们可以引用 exabytes 的内存,远远超过您可能实际放入机器的量。

这里有一个折中方案:如果有 35 位 oops 呢?然后指针可以引用 32 GB 内存( 2 35 ),而在堆中占用的空间仍然少于 64 位引用。问题在于没有 35 位寄存器来存储这样的引用。不过,JVM 可以假设引用的最后 3 位都是 0。现在每个引用都可以在堆中以 32 位存储。当引用存储到 64 位寄存器中时,JVM 可以将其左移 3 位(在末尾添加三个零)。当从寄存器中保存引用时,JVM 可以将其右移 3 位,丢弃末尾的零。

这使 JVM 具有可以引用 32 GB 内存的指针,同时在堆中仅使用 32 位。但这也意味着 JVM 无法访问任何不可被 8 整除的地址的对象,因为从压缩 oop 中的任何地址结束都以三个零结尾。第一个可能的 oop 是 0x1,左移后变为 0x8。接下来的 oop 是 0x2,左移后变为 0x10(16)。因此,对象必须位于 8 字节边界上。

结果表明,在 JVM 中,对象已经对齐到 8 字节边界;这是大多数处理器的最佳对齐方式。因此,使用压缩 oops 并不会丢失任何东西。如果 JVM 中的第一个对象存储在位置 0 并占据 57 个字节,下一个对象将存储在位置 64——浪费了无法分配的 7 个字节。这种内存权衡是值得的(无论是否使用压缩 oops),因为给定 8 字节对齐,对象可以更快地访问。

但这就是 JVM 不试图模拟可以访问 64 GB 内存的 36 位引用的原因。在这种情况下,对象必须对齐到 16 字节边界,从堆中存储压缩指针的节省将被浪费在内存对齐的对象之间。

这有两个影响。首先,对于堆大小在 4 GB 到 32 GB 之间的情况,请使用压缩 oops。可以使用 -XX:+UseCompressedOops 标志启用压缩 oops;当最大堆大小小于 32 GB 时,默认情况下启用压缩 oops(在 “Reducing Object Size” 中指出,64 位 JVM 上使用 32 GB 堆的对象引用大小为 4 字节——这是默认情况,因为默认情况下启用了压缩 oops)。

其次,一个使用 31 GB 堆和压缩 oops 的程序通常比使用 33 GB 堆的程序更快。尽管 33 GB 堆更大,但该堆中指针使用的额外空间意味着该较大堆将执行更频繁的 GC 循环,并且性能更差。

因此,最好使用小于 32 GB 的堆,或者比 32 GB 大几 GB 的堆。一旦向堆添加额外的内存以弥补未压缩引用所占用的空间,GC 循环次数将减少。没有硬性规定指出在减轻未压缩指针的 GC 影响之前需要多少内存——但鉴于平均堆的 20% 可能用于对象引用,计划至少使用 38 GB 是一个良好的起点。

快速摘要

  • 默认情况下,只要它们最有用,压缩指针(Compressed oops)就会被启用。

  • 使用压缩指针的 31 GB 堆通常会胜过稍大的过大以至于无法使用压缩指针的堆。

摘要

快速的 Java 程序关键取决于内存管理。调整 GC 很重要,但要获得最大性能,必须有效地利用应用程序中的内存。

有一段时间,硬件趋势倾向于劝阻开发人员考虑内存:如果我的笔记本电脑有 16 GB 的内存,我对一个额外的未使用的 8 字节对象引用有多担心呢?在内存受限的容器云世界中,这种担忧再次显而易见。尽管如此,即使在大型硬件上运行具有大型堆的应用程序时,也很容易忘记编程的正常时间/空间权衡可能会转向时间/空间和时间权衡:在堆中使用过多空间可能会通过需要更多的 GC 使事情变慢。在 Java 中,管理堆始终很重要。

这种管理的主要重点在于何时以及如何使用特殊的内存技术:对象池、线程局部变量和不确定引用。明智地使用这些技术可以极大地提高应用程序的性能,但过度使用它们同样会降低性能。在数量有限——需要考虑的对象数量较少且有界——的情况下,使用这些内存技术可能非常有效。

¹ 如果不及时调用 end() 方法并依赖 GC 来清除本地内存,我们仍然会出现本地内存泄漏的情况;有关更多详情,请参阅第八章。

第八章:本地内存最佳实践

堆是 Java 应用程序中内存消耗最大的部分,但 JVM 将分配并使用大量本地内存。虽然第七章讨论了从程序设计角度有效管理堆的方法,但堆的配置以及它如何与操作系统的本地内存交互,是应用程序整体性能的另一个重要因素。这里存在术语冲突,因为 C 程序员倾向于将其本地内存的部分称为 C 堆。为了保持 Java 中心的世界观,我们将继续使用来指代 Java 堆,使用本地内存来指代 JVM 的非堆内存,包括 C 堆。

本章讨论本地(或操作系统)内存的这些方面。我们首先讨论 JVM 的整体内存使用,目标是了解如何监视这种使用以解决性能问题。然后,我们将讨论调整 JVM 和操作系统以实现最佳内存使用的各种方法。

占用空间

堆(通常)占据了 JVM 使用的内存量最大的部分,但 JVM 还使用内存进行其内部操作。这种非堆内存是本地内存。本地内存也可以通过应用程序分配(通过 JNI 调用malloc()和类似方法,或者在使用 New I/O 或 NIO 时)。JVM 使用的本地和堆内存总和构成了应用程序的总占用空间

从操作系统的角度来看,这个总占用空间是性能的关键。如果没有足够的物理内存来容纳一个应用程序的整个总占用空间,性能可能会开始受到影响。这里关键词是可能。部分本地内存仅在启动时使用(例如,加载类路径中的 JAR 文件相关的内存),如果该内存被交换出去,可能不会被注意到。一个 Java 进程使用的部分本地内存与系统上的其他 Java 进程共享,并且还有一小部分与系统上其他类型的进程共享。然而,为了达到最佳性能,你需要确保所有 Java 进程的总占用空间不超过机器的物理内存(并且还需要留一些内存供其他应用程序使用)。

测量占用空间

要测量进程的总占用空间,您需要使用特定于操作系统的工具。在基于 Unix 的系统中,像topps这样的程序可以在基本水平上显示这些数据;在 Windows 上,您可以使用perfmonVMMap。无论使用哪种工具和平台,您都需要查看进程的实际分配内存(而不是保留内存)。

分配和保留内存之间的区别是由 JVM(以及所有程序)管理内存的方式造成的。考虑一个使用参数 -Xms512m -Xmx2048m 指定的堆。堆从使用 512 MB 开始,并根据需要调整大小以满足应用程序的 GC 目标。

这个概念是已提交(或已分配)内存和保留内存(有时称为进程的虚拟大小)之间的基本区别。JVM 必须告诉操作系统,堆可能需要多达 2 GB 的内存,因此这段内存是保留的:操作系统承诺,当 JVM 尝试在增加堆的大小时分配额外的内存时,该内存将可用。

尽管如此,最初只分配了 512 MB 的内存,并且这 512 MB 是正在使用的所有内存(用于堆)。这个(实际上已分配的)内存被称为已提交内存。随着堆的重新调整,已提交内存的数量会波动;特别是随着堆大小的增加,已提交内存相应增加。

这种差异几乎适用于 JVM 分配的所有重要内存。随着更多代码被编译,代码缓存从初始值增长到最大值。元空间与堆分开分配,从其初始(提交)大小增长到其最大(保留)大小。

线程堆栈是一个例外。每当 JVM 创建一个线程时,操作系统会分配一些本地内存来保存该线程的堆栈,向进程分配更多内存(至少直到线程退出)。线程堆栈在创建时就完全分配了。

在 Unix 系统中,通过各种操作系统工具报告的进程的常驻集大小 (RSS),可以估算应用程序的占用空间。这个值是进程正在使用的已提交内存量的良好估算,尽管有两种不精确之处。首先,在 JVM 和其他进程之间在操作系统级别共享的几页(即共享库的文本部分)被计入每个进程的 RSS 中。其次,进程可能分配了比任何时候都更多的内存。尽管如此,跟踪进程的 RSS 是监视总内存使用的一个很好的首次方法。在较新的 Linux 内核上,PSS 是对 RSS 的一种细化,去除了其他程序共享的数据。

在 Windows 系统上,等效的概念称为应用程序的工作集,这是任务管理器报告的内容。

最小化占用空间

要减少 JVM 使用的占用空间,请限制以下内存的使用量:

堆是内存中占用最大的一部分,尽管令人惊讶的是它可能只占总占用空间的 50% 到 60%。使用较小的最大堆(或设置 GC 调优参数,使堆永远不会完全扩展)可以限制程序的占用空间。

线程堆栈

线程栈非常大,特别是对于 64 位的 JVM。详见第九章了解限制线程栈消耗的方法。

代码缓存

代码缓存使用本地内存来保存编译后的代码。如第四章所述,可以对其进行调优(尽管由于空间限制而无法编译所有代码可能会导致性能下降)。

本地库分配

本地库可以分配它们自己的内存,有时这可能是相当重要的。

接下来的几节讨论如何监控和减少这些区域。

快速总结

  • JVM 的总体占用内存对其性能有显著影响,特别是如果机器的物理内存受限。占用空间是性能测试的另一个方面,应经常进行监控。

本地内存追踪

JVM 提供有限的可见性,显示它如何分配本地内存。重要的是要意识到,此跟踪适用于 JVM 本身分配的内存,但不包括应用程序使用的任何本地库分配的内存。这包括第三方本地库和 JDK 自带的本地库(例如 libsocket.so)。

使用选项 -XX:NativeMemoryTracking=*off|summary|detail* 可以启用这种可见性。默认情况下,本地内存追踪(NMT)是关闭的。如果启用了摘要或详细模式,则可以随时从 jcmd 获取本地内存信息:

% jcmd process_id VM.native_memory summary

如果 JVM 使用 -XX:+PrintNMTStatistics 参数启动(默认为 false),程序退出时 JVM 将打印出分配信息。

这是 JVM 运行的摘要输出,初始堆大小为 512 MB,最大堆大小为 4 GB:

Native Memory Tracking:

 Total: reserved=5947420KB, committed=620432KB

虽然 JVM 分配了总计 5.9 GB 的内存,但实际使用的远远少于这个数值:只有 620 MB。这是相当典型的情况(也是不必特别关注 OS 工具中显示的进程虚拟大小的原因,因为那只反映了内存预留)。

内存使用情况分解如下。堆本身(不出意外地)是保留内存中最大的部分,达到了 4 GB。但由于堆的动态调整,实际只增长到了 268 MB(在这种情况下,堆的大小设置为 -Xms256m -Xmx4g,因此堆的实际使用量仅略有增加):

-                 Java Heap (reserved=4194304KB, committed=268288KB)
                            (mmap: reserved=4194304KB, committed=268288KB)

接下来是用于保存类元数据的本地内存。同样需要注意的是,JVM 预留了比程序中 24,316 个类所需更多的内存。这里的已分配大小将从 MetaspaceSize 标志的值开始,并根据需要增长,直到达到 MaxMetaspaceSize 标志的值:

-                     Class (reserved=1182305KB, committed=150497KB)
                            (classes #24316)
                            (malloc=2657KB #35368)
                            (mmap: reserved=1179648KB, committed=147840KB)

大约分配了 77 个线程栈,每个约为 1 MB:

-                    Thread (reserved=84455KB, committed=84455KB)
                            (thread #77)
                            (stack: reserved=79156KB, committed=79156KB)
                            (malloc=243KB, #314)
                            (arena=5056KB, #154)

接着是 JIT 代码缓存:24,316 个类并不多,因此代码缓存的使用只占了很小的一部分:

-                      Code (reserved=102581KB, committed=15221KB)
                            (malloc=2741KB, #4520)
                            (mmap: reserved=99840KB, committed=12480KB)

下面是 GC 算法用于处理的堆外区域。这个区域的大小取决于所使用的 GC 算法:(简单的) 串行收集器会保留的远远少于更复杂的 G1 GC 算法(尽管通常这里的数量不会很大):

-                        GC (reserved=199509KB, committed=53817KB)
                            (malloc=11093KB #18170)
                            (mmap: reserved=188416KB, committed=42724KB)

同样,这个区域被编译器用于其操作,而不是放置在代码缓存中的结果代码:

-                  Compiler (reserved=162KB, committed=162KB)
                            (malloc=63KB, #229)
                            (arena=99KB, #3)

JVM 的内部操作在这里表示。它们中的大部分 tend to be small,但一个重要的例外是直接字节缓冲区,它们是在这里分配的:

-                  Internal (reserved=10584KB, committed=10584KB)
                            (malloc=10552KB #32851)
                            (mmap: reserved=32KB, committed=32KB)

符号表引用(来自类文件的常量)保存在这里:

-                    Symbol (reserved=12093KB, committed=12093KB)
                            (malloc=10039KB, #110773)
                            (arena=2054KB, #1)

NMT 本身需要一些空间来进行操作(这也是它默认情况下不启用的原因之一):

-    Native Memory Tracking (reserved=7195KB, committed=7195KB)
                            (malloc=16KB #199)
                            (tracking overhead=7179KB)

最后,这里有一些 JVM 的小型簿记部分:

-               Arena Chunk (reserved=188KB, committed=188KB)
                            (malloc=188KB)
-                   Unknown (reserved=8192KB, committed=0KB)
                            (mmap: reserved=8192KB, committed=0KB)

总体而言,NMT 提供了两个关键信息:

总已提交大小

JVM 的总已提交大小(理想情况下)接近进程将消耗的物理内存量。反过来,这应该接近应用程序的 RSS(或工作集),但是这些由操作系统提供的测量值不包括已提交但已从进程分页的任何内存。实际上,如果进程的 RSS 小于已提交内存,那通常表明操作系统难以将所有 JVM 放入物理内存中。

各自的已提交大小

当需要调整堆、代码缓存和元空间的最大值时,了解 JVM 使用了多少内存会很有帮助。在这些区域分配过多的情况下,通常只会导致无害的内存预留,但是当保留的内存很重要时,NMT 可以帮助确定这些最大大小可以被缩减的位置。

另一方面,正如我在本节开头指出的,NMT 不提供对共享库的本地内存使用情况的可见性,因此在某些情况下,总进程大小将大于 JVM 数据结构的已提交大小。

NMT 随时间的变化

NMT 还允许您跟踪内存分配随时间的发生。在使用 NMT 启动 JVM 后,您可以使用此命令为内存使用情况建立基线:

% jcmd process_id VM.native_memory baseline

这导致 JVM 标记其当前内存分配。稍后,您可以将当前内存使用情况与该标记进行比较:

% jcmd process_id VM.native_memory summary.diff
Native Memory Tracking:

Total:  reserved=5896078KB  -3655KB, committed=2358357KB -448047KB

-             Java Heap (reserved=4194304KB, committed=1920512KB -444927KB)
                        (mmap: reserved=4194304KB, committed=1920512KB -444927KB)
....

在这种情况下,JVM 已保留了 5.8 GB 的内存,目前使用了 2.3 GB。与建立基线时相比,该已提交大小减少了 448 MB。类似地,堆使用的已提交内存减少了 444 MB(剩余的输出可以检查以查看内存使用减少的其他地方)。

这是一种随时间检查 JVM 占用空间的有用技术。

快速总结

  • 本地内存跟踪(NMT)提供了有关 JVM 的本地内存使用情况的详细信息。从操作系统的角度来看,这包括 JVM 堆(对于操作系统来说只是本地内存的一部分)。

  • NMT 的总结模式对大多数分析已足够,并允许您确定 JVM 已经承诺使用多少内存(以及该内存用于什么)。

共享库的本地内存

从架构角度来看,NMT 是 HotSpot 的一部分:这是运行您应用程序 Java 字节码的 C++引擎。这位于 JDK 本身之下,因此它不追踪 JDK 级别的任何分配。这些分配来自共享库(由System.loadLibrary()调用加载)。

共享库通常被视为 Java 的第三方扩展:例如,Oracle WebLogic Server 有几个本地库用于比 JDK 更有效地处理 I/O。¹ 但 JDK 本身也有几个本地库,像所有共享库一样,这些库在 NMT 的视野之外。

因此,本地内存泄漏——即应用程序的 RSS 或工作集随时间不断增长——通常不会被 NMT 检测到。NMT 监视的内存池通常都有一个上限(例如,最大堆大小)。NMT 对于告诉我们哪些池使用了大量内存是很有用的(因此需要调整为使用更少内存),但一个无限制地泄漏本地内存的应用程序通常是由于本地库中的问题。

没有 Java 级别的工具真正能帮助我们检测应用程序从共享库使用本地内存的位置。OS 级别的工具可以告诉我们进程的工作集不断增长,如果一个进程的工作集增长到有 10 GB,并且 NMT 告诉我们 JVM 只承诺了 6 GB 的内存,那么我们知道另外的 4 GB 内存必定来自本地库的分配。

弄清楚哪个本地库负责需要 OS 级别的工具而不是来自 JDK 的工具。各种调试版本的malloc可以用于此目的。虽然这些在某种程度上很有用,但通常本地内存是通过mmap调用分配的,大多数跟踪malloc调用的库会错过这些。

一个很好的选择是使用可以同时分析本地代码和 Java 代码的分析器。例如,在第三章中我们讨论了 Oracle Studio Profiler,这是一个混合语言的分析器。该分析器还有一个选项可以跟踪内存分配情况——唯一的限制是它只能跟踪本地代码的内存分配,而不能跟踪 Java 代码的内存分配,但在这种情况下这正是我们需要的。

图 8-1 显示了 Studio Profiler 中的本地分配视图。

本地内存分配的分析器快照。

图 8-1. 本地内存分析

此调用图显示我们,WebLogic 本地函数 mapFile 已使用 mmap 将约 150 GB 的本地内存分配到我们的进程中。这有点误导:该文件存在多个映射,分析器并不足够智能以意识到它们共享实际内存:例如,如果该 15 GB 文件有 100 个映射,则内存使用仅增加 15 GB。(坦率地说,我有意损坏了该文件使其变得如此巨大;这绝不反映实际用途。)尽管如此,本地分析器已指出了问题的位置。

在 JDK 本身,有两种常见操作可能导致大量的本地内存使用:使用 InflaterDeflater 对象,以及使用 NIO 缓冲区。即使没有进行分析,也有办法检测这些操作是否导致了本地内存的增长。

本地内存和压缩/解压器

InflaterDeflater 类执行各种压缩操作:zip、gzip 等。它们可以直接使用,也可以通过各种输入流间接使用。这些算法使用平台特定的本地库执行操作。这些库可能会分配大量的本地内存。

当使用这些类之一时,按照文档的说法,应在操作完成时调用 end() 方法。其中一件事是释放对象使用的本地内存。如果使用流,则应关闭流(流类将在其内部对象上调用 end() 方法)。

如果忘记调用 end() 方法,也不用担心。回想一下第七章中提到的情况:所有对象都有一个专门的清理机制来处理这种情况:finalize() 方法(在 JDK 8 中)或对象关联的 Cleaner(在 JDK 11 中)在收集 Inflater 对象时可以调用 end() 方法。因此,不会导致本地内存泄漏;最终对象将被收集和清理,本地内存将被释放。

尽管如此,这可能需要很长时间。Inflater 对象的大小相对较小,在很少进行完整 GC 的大堆应用程序中,这些对象很容易晋升到老年代并保持数小时。因此,即使技术上没有泄漏——当应用程序执行完整 GC 时,本地内存最终会被释放——但在这里未调用 end() 操作可能表现出本地内存泄漏的所有迹象。

更重要的是,如果 Inflater 对象本身在 Java 代码中出现泄漏,则实际上会泄漏本地内存。

因此,当大量本地内存泄漏时,有助于对应用程序进行堆转储,并查找这些InflaterDeflater对象。这些对象可能不会在堆本身中引起问题(它们对于堆来说太小了),但它们的大量存在将表明存在大量使用本地内存。

本地 NIO 缓冲区

如果通过ByteBuffer类的allocateDirect()方法或FileChannel类的map()方法创建 NIO 字节缓冲区,则会分配本地(非堆)内存。

本地字节缓冲区从性能的角度来看非常重要,因为它们允许本地代码和 Java 代码在不复制数据的情况下共享数据。用于文件系统和套接字操作的缓冲区是最常见的示例。将数据写入本地 NIO 缓冲区,然后将数据发送到通道(例如文件或套接字)不需要在 JVM 和用于传输数据的 C 库之间复制数据。如果使用堆字节缓冲区,则 JVM 必须复制缓冲区的内容。

allocateDirect()方法调用是昂贵的;应尽可能重用直接字节缓冲区。理想情况是,当线程独立运行时,每个线程可以将直接字节缓冲区保留为线程本地变量。如果许多线程需要变量大小的缓冲区,这有时会使用太多本地内存,因为最终每个线程将以可能的最大大小拥有一个缓冲区。对于这种情况或当线程本地缓冲区不适合应用程序设计时,直接字节缓冲区的对象池可能更有用。

字节缓冲区还可以通过切片进行管理。应用程序可以分配一个非常大的直接字节缓冲区,通过使用ByteBuffer类的slice()方法,可以从该缓冲区中分配部分数据。当切片大小不总是相同时,这种解决方案可能会变得难以管理:原始字节缓冲区可能会像分配和释放不同大小对象时堆一样变得碎片化。然而,与堆不同的是,字节缓冲区的各个切片无法压缩,因此此解决方案仅在所有切片大小均匀时才有效。

从调优的角度来看,需要意识到的一件事是,任何这些编程模型中,应用程序可以分配的直接字节缓冲区空间可能会受到 JVM 的限制。可以通过设置-XX:MaxDirectMemorySize=N标志来指定可以分配给直接字节缓冲区的内存总量。当前 JVM 中此标志的默认值为 0。该限制的含义已经经常发生变化,但在 Java 8 的后期版本(以及所有 Java 11 的版本)中,最大限制等于最大堆大小:如果最大堆大小为 4 GB,则也可以创建 4 GB 的直接和/或映射字节缓冲区的非堆内存。如果需要,可以增加该值超过最大堆值。

直接字节缓冲区分配的内存包含在 NMT 报告的Internal部分中;如果这个数字很大,几乎总是因为这些缓冲区。如果你想确切地知道缓冲区本身消耗了多少,MBeans 会跟踪这些信息。检查 MBean java.nio.BufferPool.direct.Attributesjava.nio.BufferPool.mapped.Attributes将显示每种类型已分配的内存量。图 8-2 显示了一个情况,我们映射了 10 个缓冲区,总共占用了 10 MB 的空间。

mmaped 缓冲区消耗的本地内存量。

图 8-2. 检查字节缓冲区本地内存

快速总结

  • 如果一个应用程序似乎使用了太多本地内存,很可能是来自本地库而不是 JVM 本身。

  • 本地配置文件可以有效地确定这些分配的来源。

  • 几个常见的 JDK 类通常会增加本地内存的使用量;确保正确使用这些类。

JVM 对操作系统的调优

JVM 可以使用几种调整来改善其使用操作系统内存的方式。

大页面

关于内存分配和交换的讨论是关于页面的术语。页面是操作系统用来管理物理内存的内存单位。这是操作系统的最小分配单位:当分配 1 字节时,操作系统必须分配整个页面。程序的进一步分配来自同一个页面,直到它填满为止,然后才会分配新的页面。

操作系统分配的页面比能够放入物理内存的页面多得多,这就是为什么有页面调度:地址空间的页面被移动到和从交换空间(或其他存储,具体取决于页面内容)中。这意味着这些页面和它们当前存储在计算机 RAM 中的位置之间必须有一些映射。这些映射通过两种方式处理。所有页面映射都保存在全局页表中(操作系统可以扫描以找到特定映射),并且最常用的映射保存在 TLB(翻译后备缓冲区)中。TLB 保存在快速缓存中,因此通过 TLB 条目访问页面比通过页表访问页面快得多。

计算机有限的 TLB 条目数,因此最大化 TLB 条目的命中率变得重要(它作为最近最少使用的缓存)。由于每个条目代表一个页面的内存,增加应用程序使用的页面大小通常是有利的。如果每个页面代表更多的内存,需要更少的 TLB 条目来包含整个程序,当需要时更可能在 TLB 中找到页面。这对于任何程序通常都是正确的,因此也适用于像 Java 应用服务器或其他具有适度大小堆的 Java 程序。

大页面必须在 Java 和操作系统两个层面上启用。在 Java 层面,-XX:+UseLargePages 标志启用大页面使用;默认情况下,此标志为 false。并非所有操作系统都支持大页面,而且启用它们的方式显然各不相同。

如果在不支持大页面的系统上启用了 UseLargePages 标志,则不会发出警告,并且 JVM 使用常规页面。如果在支持大页面但没有可用大页面的系统上启用了 UseLargePages 标志(因为它们已经全部在使用中或因为操作系统配置错误),则 JVM 将打印警告。

Linux 大页面

Linux 将大页面称为 大页面。Linux 上的大页面配置在每个发行版中略有不同;要获得最准确的说明,请查阅您的发行版文档。但是一般的过程如下:

  1. 确定内核支持哪些大页面大小。大小基于计算机的处理器和内核启动时给出的引导参数,但最常见的值是 2 MB:

    # grep Hugepagesize /proc/meminfo
    Hugepagesize:       2048 kB
    
    
  2. 确定需要多少个大页面。如果 JVM 将分配 4 GB 堆,并且系统有 2 MB 大页面,则该堆需要 2,048 个大页面。可以在 Linux 内核中全局定义可使用的大页面数量,因此对将要运行的所有 JVM(以及将使用大页面的任何其他程序)重复此过程。您应该将此值高估 10%,以考虑大页面的其他非堆使用(因此此示例使用 2,200 个大页面)。

  3. 将该值写入操作系统(以便立即生效):

    # echo 2200 > /proc/sys/vm/nr_hugepages
    
    
  4. 将该值保存在 /etc/sysctl.conf 中,以便在重新启动后保留该值:

    sys.nr_hugepages=2200
    
  5. 在许多 Linux 版本中,用户可以分配的大页面内存量是有限的。编辑 /etc/security/limits.conf 文件,并为运行您的 JVM 的用户添加 memlock 条目(例如,在示例中,用户为 appuser):

    appuser soft    memlock        4613734400
    appuser hard    memlock        4613734400
    

如果修改了 limits.conf 文件,则用户必须重新登录才能使该值生效。此时,JVM 应该能够分配所需的大页面。要验证它是否有效,请运行以下命令:

# java -Xms4G -Xmx4G -XX:+UseLargePages -version
java version "1.8.0_201"
Java(TM) SE Runtime Environment (build 1.8.0_201-b09)
Java HotSpot(TM) 64-Bit Server VM (build 25.201-b09, mixed mode)

成功执行该命令表示大页面已正确配置。如果大页面内存配置不正确,则会收到警告:

Java HotSpot(TM) 64-Bit Server VM warning:
Failed to reserve shared memory (errno = 22).

在这种情况下程序会运行;只是使用常规页面而不是大页面。

Linux 透明大页面

从 Linux 内核版本 2.6.32 开始支持 透明大页面。这些在理论上提供与传统大页面相同的性能优势,但它们与传统大页面有一些区别。

首先,传统大页面被锁定在内存中;它们永远不会被交换。对于 Java 来说,这是一个优势,因为正如我们所讨论的,交换堆的部分对 GC 性能是有害的。透明大页面可以被交换到磁盘,这对性能是不利的。

第二,透明大页的分配与传统大页也有显著不同。传统大页在内核启动时设置;它们始终可用。透明大页则按需分配:当应用程序请求 2 MB 页面时,内核将尝试在物理内存中找到 2 MB 的连续空间来存放页面。如果物理内存碎片化,内核可能会决定花时间重新排列页面,这类似于 Java 堆中内存紧缩的过程。这意味着分配页面的时间可能会显著增长,因为它等待内核完成为内存腾出空间的工作。

这影响所有程序,但对于 Java 来说,可能导致 GC 暂停时间很长。在 GC 期间,JVM 可能决定扩展堆并请求新页。如果页面分配需要几百毫秒甚至一秒钟,GC 时间会受到显著影响。

第三,透明大页在操作系统和 Java 级别有不同的配置。接下来是详细内容。

在操作系统级别,透明大页的配置是通过修改 /sys/kernel/mm/transparent_hugepage/enabled 的内容:

# cat /sys/kernel/mm/transparent_hugepage/enabled
always [madvise] never
# echo always > /sys/kernel/mm/transparent_hugepage/enabled
# cat /sys/kernel/mm/transparent_hugepage/enabled
[always] madvise never

这里有三种选择:

always

所有可能的情况下,所有程序都会得到大页。

madvise

请求大页的程序会得到它们;其他程序得到常规(4 KB)页。

never

没有程序会得到大页,即使它们请求了。

不同版本的 Linux 在该设置的默认值上有所不同(并且在未来版本中可能会更改)。例如,Ubuntu 18.04 LTS 将默认值设置为 madvise,但 CentOS 7(以及基于其的 Red Hat 和 Oracle Enterprise Linux 供应商版本)将其设置为 always。还要注意,在云机器上,OS 镜像的供应商可能已更改该值;我曾看到将该值设置为 always 的 Ubuntu 镜像。

如果值设置为 always,在 Java 级别不需要任何配置:JVM 将获得大页。事实上,系统上运行的所有程序都将在大页上运行。

如果值设置为 madvise 并且您希望 JVM 使用大页,请指定 UseTransparentHugePages 标志(默认情况下为 false)。然后,当 JVM 分配页面并且获得大页时,JVM 会进行适当的请求。

预测地,如果值设置为 never,则没有 Java 级别的参数允许 JVM 获取大页。不像传统的大页,如果您指定了 UseTransparentHugePages 标志但系统无法提供大页,则不会收到警告。

由于透明巨大页面在交换和分配方面的差异,通常不建议在 Java 中使用它们;然而,在默认启用它们的系统上,使用它们时通常会看到性能提升,就像广告中所宣传的那样。如果您希望确保使用大页面时获得最平稳的性能,那么最好只在需要时将系统设置为仅使用透明巨大页面,并为 JVM 配置传统大页面。

Windows 大页面

仅支持服务器版 Windows 版本的 大页面。这里提供了适用于 Windows 10 的详细说明;不同版本可能会有所不同:

  1. 启动 Microsoft 管理中心。单击“开始”按钮,在搜索框中键入 mmc

  2. 如果左侧面板未显示本地计算机策略图标,请从“文件”菜单中选择“添加/移除插件”,并添加组策略对象编辑器。如果不可用,则正在使用的 Windows 版本不支持大页面。

  3. 在左侧面板中,展开本地计算机策略 → 计算机配置 → Windows 设置 → 安全设置 → 本地策略,并点击“用户权限分配”文件夹。

  4. 在右侧面板中,双击“锁定内存中的页面”。

  5. 在弹出窗口中,添加用户或组。

  6. 点击“确定”。

  7. 退出 MMC。

  8. 重新启动。

此时,JVM 应能够分配所需的大页面。要验证其是否有效,请运行以下命令:

# java -Xms4G -Xmx4G -XX:+UseLargePages -version
java version "1.8.0_201"
Java(TM) SE Runtime Environment (build 1.8.0_201-b09)
Java HotSpot(TM) 64-Bit Server VM (build 25.201-b09, mixed mode)

如果命令像这样成功完成,大页面已正确设置。如果大内存配置不正确,将会收到警告:

Java HotSpot(TM) Server VM warning: JVM cannot use large page memory
because it does not have enough privilege to lock pages in memory.

请记住,在不支持大页面的 Windows 系统(如“家庭版”)上,该命令不会显示错误:一旦 JVM 发现操作系统不支持大页面,它会将 UseLargePages 标志设置为 false,而不管命令行设置如何。

快速摘要

  • 使用大页面通常会显著加快应用程序的速度。

  • 大页面支持在大多数操作系统中必须显式启用。

摘要

尽管 Java 堆是最受关注的内存区域,但 JVM 的整体占用对其性能至关重要,特别是与操作系统的关系。本章讨论的工具允许您随时间跟踪该占用量(并且至关重要的是,专注于 JVM 的已提交内存而不是保留内存)。

JVM 使用操作系统内存的某些方式,特别是大页面,也可以进行调整以提高性能。长时间运行的 JVM 几乎总是会从使用大页面中受益,特别是如果它们具有大堆。

¹ 这在很大程度上是历史文物:这些库在 NIO 之前开发,大部分功能与其重复。

第九章:线程和同步性能

从最初开始,Java 的一大吸引力就是它的多线程特性。即使在多核和多 CPU 系统成为标准之前,用 Java 编写多线程程序的能力也被视为其显著特征之一。

从性能角度来看,其吸引力是显而易见的:如果有两个 CPU 可用,应用程序可能能够完成两倍的工作,或者以两倍的速度完成相同数量的工作。这是基于任务可以分解为离散片段的假设,因为 Java 不是自动并行化语言,不会自动解决算法部分。幸运的是,今天的计算通常涉及到离散任务:服务器同时处理来自离散客户的请求,批处理作业对一系列数据执行相同操作,将算法分解为组成部分等等。

本章探讨如何在 Java 线程和同步设施中获得最大性能。

线程和硬件

回顾第一章中关于多核系统和超线程系统的讨论。在软件层面上进行线程处理使我们能够利用机器的多个核心和超线程。

在一台机器上加倍核心数,使我们能够将正确编写的应用程序性能提升一倍,尽管如我们在第一章讨论过的,向 CPU 添加超线程并不会使其性能翻倍。

本章的几乎所有示例都在一个有四个单线程 CPU 的机器上运行——例外情况是第一个示例,展示了超线程和非超线程 CPU 之间的差异。之后,我们将仅从单线程 CPU 核心的角度来看待扩展,以便更好地理解添加线程的性能影响。这并不是说超线程 CPU 不重要;从硬件线程中获得的额外 20%至 40%的性能提升肯定会改善应用程序的整体性能或吞吐量。从 Java 的角度来看,我们仍然应该将超线程视为实际的 CPU,并调整我们在一个四核八超线程机器上运行的应用程序,就好像它有八个 CPU 一样。但从测量的角度来看,我们应该只期望与单核相比提高五到六倍的性能。

线程池和 ThreadPoolExecutors

在 Java 中,线程可以由自定义代码管理,或者应用程序可以利用线程池。Java 服务器通常围绕一个或多个线程池的概念构建:服务器中的每次调用都由池中的一个(可能是不同的)线程处理。类似地,其他应用程序可以使用 Java 的ThreadPoolExecutor并行执行任务。

实际上,一些服务器框架使用ThreadPoolExecutor类的实例来管理它们的任务,尽管许多人都编写了自己的线程池(即使只是因为它们早于将ThreadPoolExecutor添加到 Java API 中)。虽然在这些情况下池的实现可能有所不同,但基本概念是相同的,并且这两者都在本节中讨论。

使用线程池的关键因素在于调整池的大小对于获得最佳性能至关重要。线程池的性能因对线程池大小的基本选择而异,且在某些情况下,超大型线程池会对性能产生不利影响。

所有的线程池工作原理基本相同。任务被提交到一个队列(可能有多个队列,但概念是相同的)。然后,一定数量的线程从队列中获取任务并执行它们。任务的结果可以发送回客户端(例如,在服务器的情况下),存储在数据库中,存储在内部数据结构中,或者其他操作。但在完成任务后,线程将返回到任务队列以获取另一个要执行的作业(如果没有更多任务可执行,则线程将等待任务)。

线程池有最小和最大线程数。最小线程数会保留在周围,等待分配任务给它们。因为创建线程是一个相当昂贵的操作,当任务被提交时,这会加快整体操作:预期已经存在的线程可以接管它。另一方面,线程需要系统资源,包括用于它们的堆栈的本机内存,过多的空闲线程可能会消耗其他进程可以使用的资源。最大线程数还作为一个必要的节流阀,防止过多的任务一次执行。

ThreadPoolExecutor及其相关类的术语略有不同。这些类引用核心池大小和最大池大小,这些术语的含义取决于池是如何构建的。有时核心池大小是最小池大小,有时是最大池大小,有时则完全被忽略。同样,有时最大池大小是最大大小,但有时也会被忽略。

在本节的结尾提供了详细信息,但为了简化问题,我们将为我们的测试设置核心和最大大小相同,并且仅提到最大大小。因此,示例中的线程池始终具有给定数量的线程。

设置最大线程数

首先让我们讨论最大线程数:在给定硬件上的给定工作负载的最佳最大线程数是多少?这并没有简单的答案;它取决于工作负载的特性和运行它的硬件。特别是,最佳线程数取决于每个单独任务多频繁地会被阻塞。

我们将使用一台具有四个单线程 CPU 的机器进行讨论。请注意,如果系统只有四个核心,如果系统有 128 个核心但您只想利用其中的四个,或者如果您有一个将 CPU 使用限制为四个的 Docker 容器:目标是最大化这四个核心的使用。

显然,最大线程数必须至少设置为四个。当然,JVM 中的一些线程除了处理这些任务之外还会做其他事情,但这些线程几乎永远不会需要整个核心。唯一的例外是如果使用了并发模式垃圾收集器,如第五章中讨论的背景线程必须具有足够的 CPU(核心)才能运行,否则它们将在处理堆时落后。

是否有超过四个线程有帮助?这就涉及到工作负载特性的问题。以简单情况为例,如果任务全部是计算密集型的:它们不进行外部网络调用(例如访问数据库),也没有内部锁的显著争用。股票价格历史批处理程序就是这样的应用程序(使用模拟实体管理器时):可以完全并行计算实体的数据。

表 9-1 展示了在一个四核机器上,使用给定数量的线程池计算 10,000 个模拟股票实体历史的性能。当线程池中只有一个线程时,需要 55.2 秒来计算数据集;使用四个线程时,仅需 13.9 秒。随着线程数量的增加,稍微多一点时间将被需要,因为线程需要在任务队列中进行协调。

表 9-1. 计算 10,000 个模拟价格历史所需时间

线程数 所需秒数 基准百分比
1 55.2 ± 0.6 100%
2 28.3 ± 0.3 51.2%
4 13.9 ± 0.6 25.1%
8 14.3 ± 0.2 25.9%
16 14.5 ± 0.3 26.2%

如果应用程序中的任务完全并行,那么“基准百分比”列在两个线程时将显示 50%,在四个线程时将显示 25%。由于多种原因,这种完全线性的扩展是不可能实现的:除此之外,线程必须相互协调以从运行队列中选择任务(通常情况下,线程之间的同步更多)。当使用四个线程时,系统正在消耗 100%的可用 CPU,尽管可能没有其他用户级应用程序在运行,但各种系统级进程会启动并使用一些 CPU,从而阻止 JVM 利用所有 100%的周期。尽管如此,该应用程序在扩展方面表现良好,即使线程池中的线程数被高估,我们也只需支付很小的性能损失。

在其他情况下,线程过多可能会造成更大的惩罚。在股票历史计算器的 REST 版本中,线程过多会产生更大的影响,正如表 9-3 所示。应用服务器被配置为具有给定数量的线程,并且负载生成器正在向服务器发送 16 个并发请求。

表 9-3. 通过 REST 服务器模拟股票价格的操作每秒

| 线程数量 | 计算模拟股票价格历史的平均响应时间 |
| --- | --- | --- |
| 1 | 46.4 | 27% |
| 4 | 169.5 | 100% |
| 8 | 165.2 | 97% |
| 16 | 162.2 | 95% |

鉴于 REST 服务器有四个可用的 CPU 核心,池中有那么多线程才能实现最大吞吐量。

第一章讨论了在调查性能问题时需要确定瓶颈所在的必要性。在这个例子中,瓶颈显然是 CPU:在四个 CPU 核心的情况下,CPU 利用率达到 100%。但在这种情况下,增加更多线程的惩罚相对较小,至少直到线程数多出四倍为止。

但是如果瓶颈在其他地方呢?这个例子也有点不同寻常,因为任务完全受 CPU 限制:它们不进行 I/O 操作。通常情况下,线程可能会被期望调用数据库或将其输出写入某个位置,甚至与另一个资源进行会合。在这种情况下,CPU 不一定是瓶颈:可能是外部资源。

当这种情况发生时,向线程池添加线程是有害的。尽管我在第一章(也只是半开玩笑地)说过数据库总是瓶颈,瓶颈可以是任何外部资源。

例如,考虑一下股票 REST 服务器,角色发生了逆转:如果目标是充分利用负载生成机器(毕竟只是运行一个线程化的 Java 程序)呢?

在典型的使用情况下,如果 REST 应用在一个具有四个 CPU 的服务器上运行,并且只有一个客户端请求数据,那么 REST 服务器将约占用 25%的资源,而客户端机器几乎处于空闲状态。如果负载增加到四个并发客户端,则服务器将达到 100%的资源利用率,而客户端机器可能只有 20%的资源被使用。

仅仅看客户端,很容易得出结论,因为客户端有大量空闲 CPU 资源,应该能够增加更多线程以提高其吞吐量。然而,表 9-4 显示了这种假设是多么错误:当向客户端添加线程时,性能受到了严重影响。

表 9-4. 添加线程计算平均响应时间

客户端线程数量 平均响应时间 基准的百分比
1 0.022 second 100%
2 0.022 second 100%
4 0.024 second 109%
8 0.046 second 209%
16 0.093 second 422%
32 0.187 second 885%

一旦在这个例子中 REST 服务器成为瓶颈(即在四个客户端线程处),增加服务器的负载会非常有害。

这个例子可能看起来有些牵强。当服务器已经是 CPU 密集型时,谁会增加更多客户端线程呢?但我之所以使用这个例子,仅仅是因为它易于理解,并且只使用了 Java 程序。你可以自己运行它来理解它的工作原理,而不需要设置数据库连接和模式等等。

关键在于相同的原理在这里也适用于将请求发送到 CPU 或 I/O 瓶颈的数据库的 REST 服务器。你可能只看服务器的 CPU 使用情况,看到它远低于 100%,还有额外的请求需要处理,就会认为增加服务器线程数量是个好主意。这将导致一个大惊喜,因为在这种情况下增加线程数量实际上会降低总吞吐量(可能显著降低),就像在仅有 Java 的例子中增加客户端线程一样。

这也是了解系统中实际瓶颈位置的重要原因之一:如果增加负载到瓶颈,性能会显著下降。相反,如果减少当前瓶颈的负载,则性能可能会增加。

这也是为什么线程池的自调整很困难的原因。线程池通常可以看到它们待处理的工作量,甚至可能了解到机器上的 CPU 使用情况,但通常无法看到它们执行的整个环境的其他方面。因此,在有待处理工作时增加线程——这是许多自调整线程池的关键特性(以及ThreadPoolExecutor的某些配置)——通常恰恰是错误的做法。

在表格 9-4 中,REST 服务器的默认配置是在四核机器上创建 16 个线程。在一般默认情况下,这是有道理的,因为可以预期这些线程会进行外部调用。当这些调用因等待响应而阻塞时,其他任务可以运行,服务器将需要多于四个线程来执行这些任务。因此,默认情况下创建稍多的线程是一个合理的折衷方案:对于主要是 CPU 密集型的任务会有轻微的惩罚,但对于运行多个执行阻塞 I/O 的任务会提高吞吐量。其他服务器可能默认创建了 32 个线程,这对于我们的 CPU 密集型测试来说会有更大的惩罚,但对于处理主要是 I/O 密集型负载的情况则有更大的优势。

不幸的是,这也是为什么设置线程池的最大大小通常更像是一门艺术而不是科学的原因。在现实世界中,自调整的线程池可能使您在测试系统的可能性下获得 80%到 90%的性能,并且在池中估计所需的线程数量可能只会导致小小的惩罚。但是当这种大小设置出现问题时,问题可能会很严重。在这方面进行充分的测试仍然是一个关键要求。

设置最小线程数

一旦确定了线程池中的最大线程数,就该确定所需的最小线程数了。简单地说,在几乎所有情况下,设置最小线程数与最大线程数相同通常都没什么关系,也比较简单。

将最小线程数设置为另一个值(例如 1)的论点是,它可以防止系统创建过多线程,从而节省系统资源。确实,每个线程都需要一定量的内存,特别是用于其堆栈(稍后在本章讨论)。但是同样遵循第二章中的一般规则,系统需要调整大小以处理预期的最大吞吐量,此时系统将需要创建所有这些线程。如果系统无法处理最大数量的线程,选择少量的最小线程数并没有真正帮助:如果系统达到需要最大数量线程的条件(且无法处理),那么系统肯定会遇到问题。最好创建所有可能最终需要的线程,并确保系统能够处理预期的最大负载。

另一方面,指定最小线程数的缺点相当小。这种缺点发生在第一次有多个任务需要执行时:此时池将需要创建一个新线程。创建线程对性能有害——这也是为什么首先需要线程池的原因——但是对于创建线程的这一次成本,只要线程留在池中,可能不会被注意到。

在 在批处理应用程序中,线程是在池创建时分配还是按需分配(如果将最小和最大线程数设置为相同,则会发生前者),对于执行应用程序所需的时间来说是无关紧要的。在其他应用程序中,新线程可能会在预热期间分配(同样,分配线程的总时间相同);这对应用程序的性能影响微乎其微。即使线程创建发生在测量周期内,只要线程创建受限,通常不会被注意到。

这里适用的另一种调优是线程的空闲时间。假设池的大小被设置为一个线程的最小值和四个线程的最大值。现在假设通常有一个线程在执行任务,然后应用程序开始一个循环,在该循环中,每隔 15 秒,工作负载平均有两个任务需要执行。在通过该循环的第一次时,池将创建第二个线程——现在有理由让第二个线程在池中至少停留一段时间。你希望避免这样的情况:第二个线程被创建,完成其任务需要 5 秒钟,空闲了 5 秒钟,然后退出——因为 5 秒钟后,下一个任务将需要第二个线程。一般来说,在池中为一个最小大小创建线程后,应该让它至少保持几分钟以处理负载的任何激增。在你有到达率的良好模型的程度上,你可以根据这个空闲时间来计划。否则,计划空闲时间应该以分钟为单位,至少在 10 到 30 分钟之间。

保持空闲线程通常对应用程序影响不大。通常,线程对象本身并不占用大量堆空间。唯一的例外是如果线程保持大量的线程本地存储,或者如果通过线程的可运行对象引用了大量内存。在这两种情况下,释放线程可以在堆中剩余的活跃数据方面带来显著的节省(这反过来影响 GC 的效率)。

然而,线程池中这种情况确实不应该发生。当池中的线程空闲时,它不应再引用任何可运行的对象(如果引用了,说明某处存在 bug)。根据池的实现方式,线程本地变量可能会保留在原地——但是,虽然线程本地变量在某些情况下可以有效地促进对象的重用(见第七章),但这些线程本地对象占用的内存总量应该是有限的。

这个规则的一个重要例外是那些可能会增长到非常大(因此运行在非常大机器上)的线程池。假设一个线程池的任务队列平均预期有 20 个任务;那么 20 就是池的一个良好的最小大小。现在假设该池在一个非常大的机器上运行,并且设计成能处理 2,000 个任务的高峰。在这个池中保持 2,000 个空闲线程会影响其性能,当它只运行 20 个任务时,该池的吞吐量可能会减少多达 50%,因为当池中有 1,980 个空闲线程时,与仅有核心的 20 个繁忙线程时相比。通常情况下,线程池不会遇到这种大小问题,但是当它们出现时,现在是确保它们有一个良好的最小值的好时机。

线程池任务大小

线程池中待处理的任务存储在队列或列表中;当池中的线程可以执行任务时,它会从队列中取出一个任务。这可能导致不平衡,因为队列中的任务数量可能会非常大。如果队列太大,队列中的任务将不得不等待很长时间,直到它们前面的任务完成执行。想象一个负载过重的 Web 服务器:如果一个任务被添加到队列中并且在 3 秒内未执行,用户很可能已经转移到另一页。

因此,线程池通常会限制待处理任务队列的大小。ThreadPoolExecutor 根据配置的数据结构以不同方式实现此功能(下一节将详细介绍);服务器通常有一个调整参数来调整这个值。

与线程池的最大大小一样,并没有普遍适用的规则表明应该如何调整这个值。一个服务器如果在队列中有 30,000 个项目并且有四个可用 CPU,如果每个任务执行仅需 50 毫秒(假设这段时间内没有新任务到达),则可以在 6 分钟内清除队列。这可能是可以接受的,但如果每个任务需要 1 秒钟才能执行,则需要 2 小时才能清空队列。再次强调,测量实际应用程序是确保得到所需性能的唯一方法。

在任何情况下,当达到队列限制时,尝试向队列添加任务将失败。ThreadPoolExecutor 有一个 rejectedExecution() 方法来处理这种情况(默认情况下会抛出 RejectedExecutionException,但您可以覆盖此行为)。应用服务器应向用户返回合理的响应(包含说明发生了什么的消息),而 REST 服务器应返回状态码 429(请求过多)或 503(服务不可用)。

调整 ThreadPoolExecutor 的大小

线程池的一般行为是从最小线程数开始,并且如果所有现有线程都忙于执行任务时有新任务到达,则启动新线程(最多线程数),并立即执行任务。如果已经启动了最大数量的线程但它们都忙于工作,则任务将被排队,除非已有许多任务在排队,否则该任务将被拒绝。虽然这是线程池的典型行为,但 ThreadPoolExecutor 的行为可能略有不同。

ThreadPoolExecutor 根据用于保存任务的队列类型决定何时启动新线程。有三种可能性:

同步队列

当执行器使用SynchronousQueue时,线程池的行为与线程数量的预期行为一致:如果所有现有线程都忙于工作,并且池的线程数少于最大线程数,则新任务将启动新线程。然而,这个队列无法持有待处理的任务:如果一个任务到达并且最大线程数已经全部忙碌,那么该任务将被拒绝。因此,这种选择适合管理少量任务,但在其他情况下可能不合适。该类的文档建议为最大线程数指定一个非常大的数值——如果任务完全是 I/O 绑定的话可能是可以接受的,但正如我们所见,在其他情况下可能会产生反效果。另一方面,如果需要一个可以轻松调整线程数量的线程池,这是更好的选择。

在这种情况下,核心值是最小池大小:即使处于空闲状态,也会保持运行的线程数。最大值是池中的最大线程数。

这是Executors类的newCachedThreadPool()方法返回的具有无界最大线程值的线程池类型。

无界队列

当执行器使用无界队列(例如LinkedBlockingQueue)时,永远不会拒绝任何任务(因为队列大小是无限的)。在这种情况下,执行器将最多使用由核心线程池大小指定的线程数:忽略最大池大小。这本质上模仿了传统线程池,其中核心大小被解释为最大池大小,尽管由于队列是无界的,如果任务提交得比可以运行的更快,则存在消耗过多内存的风险。

这是Executors类的newFixedThreadPool()newSingleThreadScheduledExecutor()方法返回的线程池类型。在第一种情况下,核心(或最大)池大小是构建池时传递的参数;在第二种情况下,核心池大小为 1。

有界队列

使用有界队列(例如ArrayBlockingQueue)的执行器采用复杂的算法来确定何时启动新线程。例如,假设池的核心大小为 4,最大大小为 8,而ArrayBlockingQueue的最大大小为 10。随着任务的到达并放置在队列中,池将运行最多 4 个线程(核心池大小)。即使队列完全填满——以至于它持有 10 个待处理任务——执行器也将利用 4 个线程。

只有在队列满时并且向队列添加新任务时,才会启动额外的线程。与其拒绝任务(因为队列已满),执行器会启动一个新线程。新线程运行队列中的第一个任务,为待处理的任务腾出空间。

在此示例中,池子最终会拥有 8 个线程(其指定的最大值),只有在有 7 个任务在进行中,10 个任务在队列中,并且向队列添加了一个新任务时才会发生。

此算法的理念在于,即使有一定数量的任务排队等待执行,池子大部分时间只会运行核心线程(四个)。这样池子就能充当节流阀的作用(这是有利的)。如果请求积压过多,池子会试图运行更多线程以清除积压(受第二个节流阀的限制,即最大线程数)。

如果系统中没有外部瓶颈并且有 CPU 周期可用,这里的一切都会顺利进行:添加新线程将更快地处理队列,并可能将其恢复到期望的大小。因此,在适当的情况下,这种算法肯定是有效的。

另一方面,此算法不知道队列大小为何增加。如果是外部积压引起的,增加线程是错误的做法。如果池子在 CPU 受限的机器上运行,增加线程也是错误的做法。只有在额外负载进入系统(例如更多客户端开始发出 HTTP 请求)导致积压时,增加线程才是合理的。然而,如果是这种情况,为何要等到队列大小达到某个界限再添加线程呢?如果有额外资源可供使用额外线程,早点添加会提升系统的整体性能。

关于每个选择有很多支持和反对的论点,但在试图最大化性能时,这是应用 KISS 原则的时刻:保持简单,愚蠢。如常,应用程序的需求可能有所不同,但作为一般建议,不要使用Executors类提供的默认、无界的线程池,因为这样无法控制应用程序的内存使用。相反,构建自己的ThreadPoolExecutor,它具有相同数量的核心和最大线程,并利用ArrayBlockingQueue限制可在内存中等待执行的请求数量。

快速总结

  • 线程池是对象池化的一个典型案例:初始化线程代价高昂,而线程池允许系统中线程数量轻松受控。

  • 线程池必须小心调整。盲目向池中添加新线程在某些情况下可能降低性能。

  • 使用简化的选项配置ThreadPoolExecutor通常能提供最佳(也是最可预测的)性能。

ForkJoinPool

除了通用的ThreadPoolExecutor外,Java 还提供了一种略具特殊用途的池:ForkJoinPool类。该类看起来就像任何其他线程池;与ThreadPoolExecutor类似,它实现了ExecutorExecutorService接口。当使用这些接口时,ForkJoinPool使用一个内部无界任务列表,这些任务将由其构造函数中指定的线程数运行。如果构造函数没有传递参数,则池将根据机器上可用的 CPU 数量(或适用的 Docker 容器可用的 CPU 数量)自动调整大小。

ForkJoinPool类设计用于处理分治算法:这些算法将一个任务递归地分解为子集。这些子集可以并行处理,然后将每个子集的结果合并为单个结果。其中一个经典示例是快速排序算法。

分治算法的重要一点是它们创建了许多任务,这些任务必须由相对较少的线程管理。假设我们要对 1000 万元素的数组进行排序。我们首先创建单独的任务执行三个操作:对包含前 500 万元素的子数组进行排序,对包含后 500 万元素的子数组进行排序,然后合并这两个子数组。

对 500 万元素数组的排序通过对 250 万元素的子数组进行排序并合并这些数组来类似地完成。这种递归一直持续下去,直到某个时候(例如,当子数组有 47 个元素时),使用直接在数组上使用插入排序更加高效。图 9-1 展示了所有这些如何运作。

最终,我们将有 262,144 个任务来对叶子数组进行排序,每个数组将有 47 个(或更少)元素。(这个数字—47—依赖于算法,并且是大量分析的对象,但 Java 在快速排序中使用这个数字。)

jp2e 0901

图 9-1。递归快速排序中的任务

还需要额外的 131,072 个任务来合并这些排序数组,另外还需要 65,536 个任务来合并下一组排序数组,依此类推。最终,将会有 524,287 个任务。

这里的重要一点是,直到它们生成的任务也完成之前,没有一个任务能够完成。必须首先完成直接排序少于 47 个元素数组的任务,然后任务才能合并它们创建的两个小数组,依此类推:一切都向上合并,直到整个数组合并为其最终排序值。

使用 ThreadPoolExecutor 无法高效地执行该算法,因为父任务必须等待其子任务完成。线程池执行器中的线程无法将另一个任务添加到队列然后等待它完成:一旦线程等待,它就无法用来执行其中一个子任务。另一方面,ForkJoinPool 允许其线程创建新任务,然后挂起它们当前的任务。在任务挂起期间,线程可以执行其他待处理的任务。

让我们举一个简单的例子:假设我们有一个双精度数组,目标是计算数组中小于 0.5 的值的数量。顺序扫描数组非常简单(并且可能是有利的,稍后在本节中会看到)——但现在,将数组分成子数组并并行扫描它们(模拟更复杂的快速排序和其他分治算法)是很有教育意义的。这里是使用 ForkJoinPool 实现这一目标的代码大纲:

private class ForkJoinTask extends RecursiveTask<Integer> {
    private int first;
    private int last;

    public ForkJoinTask(int first, int last) {
        this.first = first;
        this.last = last;
    }

    protected Integer compute() {
        int subCount;
        if (last - first < 10) {
            subCount = 0;
            for (int i = first; i <= last; i++) {
                if (d[i] < 0.5)
                    subCount++;
            }
        }
        else {
            int mid = (first + last) >>> 1;
            ForkJoinTask left = new ForkJoinTask(first, mid);
            left.fork();
            ForkJoinTask right = new ForkJoinTask(mid + 1, last);
            right.fork();
            subCount = left.join();
            subCount += right.join();
        }
        return subCount;
    }
}

这里的 fork()join() 方法是关键:如果没有这些方法(在 ThreadPoolExecutor 执行的任务中不可用),我们很难实现这种递归。这些方法使用一系列内部的每个线程队列来操作任务,并在执行一个任务后将线程切换到执行另一个任务。虽然开发者对细节是透明的,但如果你对算法感兴趣,代码读起来会很有趣。我们这里的重点是性能:ForkJoinPoolThreadPoolExecutor 类之间存在哪些权衡?

首要的是,由 fork/join 范式实现的挂起允许仅由少数线程执行所有任务。使用此示例代码在一个包含 200 万元素的数组中计数双值会创建超过 400 万个任务,但这些任务可以轻松地由少数线程(如果这对运行测试的机器有意义的话,甚至一个线程)执行。使用 ThreadPoolExecutor 运行类似的算法将需要超过 400 万个线程,因为每个线程必须等待其子任务完成,而这些子任务只有在线程池中有额外线程可用时才能完成。因此,fork/join 挂起允许我们使用否则无法使用的算法,这是性能的胜利。

另一方面,像这样简单的算法并不特别适合于实际使用 fork-join 池。这个池子最适合以下情况:

  • 算法的合并部分执行一些有趣的工作(而不仅仅是像这个例子中简单地加两个数字)。

  • 算法中的叶子计算足以抵消任务的创建。

在缺少这两个标准的情况下,将数组分成块并使用 ThreadPoolExecutor 让多个线程扫描数组非常容易:

public class ThreadPoolTest {
    private double[] d;

    private class ThreadPoolExecutorTask implements Callable<Integer> {
        private int first;
        private int last;

        public ThreadPoolExecutorTask(int first, int last) {
            this.first = first;
            this.last = last;
        }

        public Integer call() {
            int subCount = 0;
            for (int i = first; i <= last; i++) {
                if (d[i] < 0.5) {
                    subCount++;
                }
            }
            return subCount;
        }
    }

    public static void main(String[] args) {
        d = createArrayOfRandomDoubles();
        ThreadPoolExecutor tpe = new ThreadPoolExecutor(4, 4,
                                        Long.MAX_VALUE,
                                        TimeUnit.SECONDS,
                                	new LinkedBlockingQueue());
        Future[] f = new Future[4];
        int size = d.length / 4;
        for (int i = 0; i < 3; i++) {
            f[i] = tpe.submit(
                       new ThreadPoolExecutorTask(i * size, (i + 1) * size - 1);
        }
        f[3] = tpe.submit(new ThreadPoolExecutorTask(3 * size, d.length - 1);
        int n = 0;
        for (int i = 0; i < 4; i++) {
            n += f.get();
        }
        System.out.println("Found " + n + " values");
    }
}

在四核 CPU 上,此代码将充分利用所有可用的 CPU,在并行处理数组的同时避免创建和排队使用 fork/join 示例中的 400 万个任务。性能可预见地更快,如表 9-5 所示。

表 9-5. 计算包含 200 万元素数组所需时间

线程数 ForkJoinPool ThreadPoolExecutor
1 125 ± 1 毫秒 1.731 ± 0.001 毫秒
4 37.7 ± 1 毫秒 0.55 ± 0.002 毫秒

两个测试在 GC 时间上有所不同,但真正的差异来自分治算法,特别是在叶值为 10 时。创建和管理 400 万个任务对象的开销阻碍了ForkJoinPool的性能。当有类似的替代方案时,至少在这种简单情况下,它可能会更快。

或者,我们可以通过更早地结束递归来减少任务数量。在一个极端情况下,当子数组有 500,000 元素时结束递归,这将任务划分为四个任务,与线程池示例相同。在这种情况下,测试的性能将是相同的(尽管如果工作很容易划分,那么首先为什么要使用分治算法)。

为了说明目的,我们可以通过在任务的叶计算阶段添加工作轻松地缓解我们标准中的第二点:

for (int i = first; i <= last; i++) {
    if (d[i] < 0.5) {
        subCount++;
    }
    for (int j = 0; j < 500; j++) {
        d[i] *= d[i];
    }
}

现在测试将被计算d[i]所主导。但是因为算法的合并部分没有进行任何重要工作,创建所有任务仍然会带来惩罚,正如我们在表 9-6 中所见。

表 9-6. 增加工作量后计算包含 200 万元素数组所需时间

线程数 ForkJoinPool ThreadPoolExecutor
4 271 ± 3 毫秒 258 ± 1 毫秒

现在测试时间主要由实际计算占据,与分区相比,fork-join pool 的性能并不差。但是,创建任务的时间仍然显著,当任务仅需简单分区时(即合并阶段没有重要工作时),简单线程池的速度会更快。

工作窃取

使用此池的一个规则是确保分割任务是有意义的。但是,ForkJoinPool的第二个特性使其更加强大:它实现了工作窃取。这基本上是一个实现细节;这意味着池中的每个线程都有自己的任务队列。线程优先处理来自自己队列的任务,但如果队列为空,它们将从其他线程的队列中窃取任务。结果是,即使其中一个 400 万个任务执行时间较长,ForkJoinPool中的其他线程仍然可以完成任何和所有剩余任务。ThreadPoolExecutor则不然:如果其中一个任务需要很长时间,其他线程就无法接手额外的工作。

当我们在原始示例中增加工作量时,每个值的工作量是恒定的。如果这个工作量根据数组中项的位置而变化呢?

for (int i = first; i <= last; i++) {
    if (d[i] < 0.5) {
        subCount++;
    }
    for (int j = 0; j < i; j++) {
        d[i] += j;
    }
}

因为外部循环(由 j 索引)是基于数组中元素的位置的,所以计算需要花费的时间与元素位置成比例:计算 d[0] 的值将非常快,而计算 d[d.length - 1] 的值将需要更长时间。

现在,ThreadPoolExecutor 测试的简单分区将处于不利地位。计算数组第一个分区的线程将花费很长时间才能完成,比最后一个分区上操作的第四个线程花费的时间要长得多。一旦第四个线程完成,它将保持空闲状态:一切都必须等待第一个线程完成其长时间的任务。

ForkJoinPool 中 400 万个任务的粒度意味着虽然一个线程将卡住在对数组前 10 个元素进行非常长时间的计算上,但其余线程仍然有工作要做,并且 CPU 将在测试的大部分时间内保持忙碌状态。这种差异显示在 表 9-7 中。

表 9-7. 处理包含 2,000,000 个元素的数组的时间,带有不平衡的工作负载

线程数量 ForkJoinPool ThreadPoolExecutor
1 22.0 ± 0.01 秒 21.7 ± 0.1 秒
4 5.6 ± 0.01 秒 9.7 ± 0.1 秒

当线程池只有一个线程时,计算所需时间基本相同。这很合理:无论线程池的实现方式如何,计算的次数都是相同的,而且由于这些计算从未并行进行,可以预期它们所需的时间相同(尽管为创建 400 万个任务存在一些小的开销)。但是,当线程池包含四个线程时,ForkJoinPool 中任务的粒度赋予它明显的优势:它能够几乎在整个测试期间让 CPU 保持忙碌状态。

这种情况被称为不平衡,因为某些任务花费的时间比其他任务长(因此前一个示例中的任务被称为平衡)。一般而言,这导致了一个建议:当任务可以轻松分割为平衡集时,使用带分区的 ThreadPoolExecutor 将提供更好的性能,而当任务不平衡时,ForkJoinPool 将提供更好的性能。

这里还有一个更微妙的性能建议:仔细考虑 fork/join 范例的递归应该在何时结束。在这个例子中,我们任意选择当数组大小小于 10 时结束递归。在平衡情况下,我们已经讨论过将递归在 500,000 处结束将是最优的。

另一方面,不平衡情况下的递归对较小的叶值表现出更好的性能。代表性数据点显示在 表 9-8 中。

表 9-8. 使用不同叶子值处理包含 2,000,000 个元素的数组所需的时间

目标叶子数组大小 ForkJoinPool
500,000 9,842 ± 5 ms
50,000 6,029 ± 100 ms
10,000 5,764 ± 55 ms
1,000 5,657 ± 56 ms
100 5,598 ± 20 ms
10 5,601 ± 15 ms

当叶子大小为 500,000 时,我们复制了线程池执行器的情况。随着叶子大小的减少,我们从测试的不平衡性中受益,直到在 1,000 到 10,000 之间,性能趋于稳定。

在这类算法中通常会调整叶子值的调整。正如您在本节前面看到的,Java 在其快速排序算法的实现中使用 47 作为叶子值:对于该算法而言,这是创建任务的开销超过分治方法优势的点。

自动并行化

Java 具有自动并行化特定类型代码的能力。这种并行化依赖于 ForkJoinPool 类的使用。JVM 将为此目的创建一个公共的 fork-join 池;这是 ForkJoinPoolClass 的静态元素,默认大小为目标机器上的处理器数量。

这种并行化出现在 Arrays 类的许多方法中:使用并行快速排序对数组进行排序的方法,对数组中每个单独元素进行操作的方法等。它还在流特性中使用,允许在集合中的每个元素上执行操作(无论是顺序执行还是并行执行)。流的基本性能影响在第十二章中讨论;在本节中,我们将看看如何自动并行处理流。

给定包含一系列整数的集合,以下代码将计算与给定整数对应的股票价格历史数据:

List<String> symbolList = ...;
Stream<String> stream = symbolList.parallelStream();
stream.forEach(s -> {
    StockPriceHistory sph = new StockPriceHistoryImpl(s, startDate,
                                     endDate, entityManager);
    blackhole.consume(sph);
});

这段代码将并行计算模拟价格历史数据:forEach() 方法会为数组列表中的每个元素创建一个任务,每个任务都会由公共的 ForkJoinTask 池来处理。这与本章开头的测试基本相当,该测试使用线程池来并行计算历史数据,但这段代码比显式处理线程池要简单得多。

设置公共的 ForkJoinTask 池的大小和设置任何其他线程池的大小一样重要。默认情况下,公共池将拥有与目标机器 CPU 数量相同的线程。如果在同一台机器上运行多个 JVM,限制该数字是有意义的,以避免 JVM 之间相互竞争 CPU。同样地,如果服务器将并行执行其他请求,并且您希望确保 CPU 为这些其他任务保留,考虑减小公共池的大小。另一方面,如果公共池中的任务会阻塞等待 I/O 或其他数据,可能需要增加公共池的大小。

可以通过设置系统属性-Djava.util.concurrent.ForkJoinPool.common.parallelism=N来设置大小。在 Java 8 更新版本 192 之前的 Docker 容器中,应手动设置。

本章前面提到的表 9-1 展示了池大小对并行股票历史计算性能的影响。表 9-9 将该数据与使用通用ForkJoinPool(并设置parallelism系统属性为给定值)的forEach()构造函数进行了比较。

表 9-9. 计算 10,000 个模拟价格历史所需的时间

线程数 ThreadPoolExecutor ForkJoinPool
1 40 ± 0.1 秒 20.2 ± 0.2 秒
2 20.1 ± 0.07 秒 15.1 ± 0.05 秒
4 10.1 ± 0.02 秒 11.7 ± 0.1 秒
8 10.2 ± 0.3 秒 10.5 ± 0.1 秒
16 10.3 ± 0.03 秒 10.3 ± 0.7 秒

默认情况下,通用池将有四个线程(在我们通常的四核 CPU 机器上),所以表中的第三行是常见情况。对于共享池大小为一和二的结果完全不符合预期的情况,这可能使性能工程师感到困扰:ForkJoinPool的表现远远超出预期。

当测试结果如此偏离正常时,最常见的原因是测试错误。然而,在这种情况下,forEach()方法进行了一些巧妙的处理:它使用执行语句的线程和来自流的数据处理通用池中的线程。尽管第一个测试中的通用池配置为单线程,但实际上使用了两个线程来计算结果。因此,具有两个线程的ThreadPoolExecutor和一个线程的ForkJoinPool的时间基本相同。

如果在使用并行流结构和其他自动并行功能时需要调整共享池的大小,请考虑将期望值减少一。

快速总结

  • 递归的分而治之算法应使用ForkJoinPool类。此类不适用于可以通过简单分区处理的情况。

  • 努力确定算法中任务递归应停止的最佳点。创建太多任务会影响性能,但如果任务执行时间不一致,任务过少同样会影响性能。

  • 使用自动并行化功能的特性将使用ForkJoinPool类的共享实例。您可能需要调整该共享实例的默认大小。

线程同步

在理想世界或书中的示例中,线程可以相对容易地避免同步需求。但在现实世界中,情况可能并非如此简单。

同步成本

代码的同步区域影响性能有两种方式。首先,应用程序在同步块中花费的时间影响应用程序的可扩展性。其次,获取同步锁需要 CPU 周期,因此也影响性能。

同步和可扩展性

首先要明确的是:当一个应用程序分割成多个线程运行时,它所看到的加速效果由一个称为Amdahl 定律的方程定义:

S p e e d u p = 1 (1-P)+P N

P是并行运行的程序比例,N是所使用的线程数(假设每个线程始终有可用的 CPU)。因此,如果代码中有 20%存在于序列化块中(即P为 80%),那么可以预期代码在有八个可用 CPU 时仅会运行 3.33 倍速。

对于这个方程的一个关键事实是,随着P的减少——即随着更多代码位于序列化块中——多线程带来的性能优势也在减少。这就是为什么限制位于序列化块中的代码量如此重要。在这个例子中,当有八个 CPU 可用时,我们本可以希望速度增加八倍。但当代码仅有 20%位于序列化块中时,多线程的好处减少了超过 50%(即增加仅为 3.3 倍)。

锁定对象的成本

除了对可扩展性的影响之外,同步操作还带来两个基本成本。

首先,我们有获取同步锁的成本。如果锁是非竞争的——意味着两个线程不同时尝试访问锁——这个成本是很小的。在synchronized关键字和基于 CAS 的构造之间存在一些差异。非竞争的synchronized锁称为未膨胀的锁,获取未膨胀的锁的成本大约是几百纳秒的量级。非竞争的 CAS 代码将看到更小的性能惩罚。(参见第十二章中的一个例子以了解差异。)

竞争构造造成的成本更高。当第二个线程尝试访问一个synchronized锁时,该锁变得(可预见地)膨胀。这略微增加了获取锁的时间,但真正的影响在于第二个线程必须等待第一个线程释放锁。当然,这个等待时间依赖于应用程序本身。

使用 CAS 指令的代码中争用操作的成本是不可预测的。使用 CAS 原语的类基于乐观策略:线程设置一个值,执行代码,然后确保初始值没有更改。如果已更改,则 CAS-based 代码必须重新执行代码。在最坏的情况下,两个线程可能陷入无限循环,因为每个线程修改了 CAS 保护的值,只是看到另一个线程同时修改了它。实际上,两个线程不会像那样陷入无限循环,但随着争用 CAS-based 值的线程数增加,重试次数也会增加。

同步的第二个成本是特定于 Java 并且取决于 Java 内存模型。Java 与诸如 C++和 C 等语言不同,在同步周围有关内存语义的严格保证,该保证适用于 CAS-based 保护、传统同步和volatile关键字。

同步的目的是保护内存中值(或变量)的访问。如第四章所述,变量可以临时存储在寄存器中,这比直接访问主内存效率高得多。寄存器的值对其他线程不可见;修改寄存器中的值的线程必须在某个时刻将其刷新到主内存,以便其他线程能够看到该值。必须刷新寄存器值的时间由线程同步规定。

语义可能相当复杂,但最简单的思考方式是,当一个线程离开同步块时,它必须将任何修改的变量刷新到主内存。这意味着进入同步块的其他线程将看到最近更新的值。同样,CAS-based 结构确保在操作期间修改的变量被刷新到主内存,并且标记为volatile的变量在每次更改时都会在主内存中得到一致更新。

在第一章中,我提到过,即使看起来可能是“过早优化”代码,你也应该学会避免在 Java 中使用非高效的代码结构(实际上并不是)。一个有趣的案例和一个真实的例子来自于这个循环:

Vector v;
for (int i = 0; i < v.size(); i++) {
    process(v.get(i));
}

在生产中,发现这个循环花费了令人惊讶的时间,逻辑推断是process()方法是罪魁祸首。但事实并非如此,问题也不在于编译器已经内联的size()get()方法调用本身。Vector类的get()size()方法是同步的,结果发现,所有这些调用所需的寄存器刷新是一个巨大的性能问题。¹

对于其他原因,这不是理想的代码。特别是,向量的状态可能会在一个线程调用size()方法和调用get()方法之间发生变化。如果第二个线程在第一个线程进行的两次调用之间从向量中删除最后一个元素,则get()方法将抛出ArrayIndexOutOfBoundsException异常。除了代码中的语义问题外,在这里选择细粒度同步是一个糟糕的选择。

一种避免这种情况的方法是在同步块内包装大量连续的、细粒度的同步调用:

synchronized(v) {
    for (int i = 0; i < v.size(); i++) {
        process(v.get(i));
    }
}

如果process()方法执行时间较长,则这不适合,因为向量不再能够并行处理。或者,可能需要复制和分割向量,以便在副本内部可以并行处理其元素,而其他线程仍然可以修改原始向量。

寄存器刷新的效果也取决于程序运行的处理器类型;具有大量线程寄存器的处理器将需要比简单处理器更多的刷新。事实上,这段代码在成千上万个环境中长时间运行而没有问题。只有在尝试在具有许多线程寄存器的大型 SPARC 机器上运行时才成为问题。

这是否意味着在较小的环境中,你不太可能看到有关寄存器刷新的问题?也许是。但是正如多核 CPU 已成为简单笔记本电脑的常态一样,具有更多缓存和寄存器的更复杂的 CPU 也变得越来越普遍,这将暴露出像这样的隐藏性能问题。

快速总结

  • 线程同步有两个性能成本:限制应用程序的可伸缩性,并且需要获取锁。

  • 同步的内存语义、基于 CAS 的工具以及volatile关键字可能会对性能产生负面影响,特别是在具有多个寄存器的大型机器上。

避免同步

如果完全可以避免同步,锁定开销将不会影响应用程序的性能。可以使用两种一般方法来实现这一点。

第一种方法是在每个线程中使用不同的对象,以便对对象的访问不受争用。许多 Java 对象是同步的,以使它们线程安全,但不一定需要共享。Random类属于这一类;第十二章展示了 JDK 中使用线程本地技术开发新类以避免该类中同步的示例。

另一方面,许多 Java 对象创建成本高,或者使用大量内存。例如,NumberFormat类:该类的实例不是线程安全的,而且为了创建实例所需的国际化使得构造新对象变得昂贵。程序可以通过使用单个共享的全局NumberFormat实例来解决,但需要对该共享对象的访问进行同步。

相反,更好的模式是使用 ThreadLocal 对象:

public class Thermometer {
    private static ThreadLocal<NumberFormat> nfLocal = new ThreadLocal<>() {
        public NumberFormat initialValue() {
            NumberFormat nf = NumberFormat.getInstance();
            nf.setMinumumIntegerDigits(2);
            return nf;
        }
    }
    public String toString() {
        NumberFormat nf = nfLocal.get();
        nf.format(...);
    }
}

通过使用线程本地变量,对象的总数是有限的(最小化对 GC 的影响),并且每个对象永远不会遭受线程争用的影响。

避免同步的第二种方式是使用基于 CAS 的替代方案。在某种意义上,这并不是完全避免同步,而是以不同的方式解决问题。但在这种情况下,通过减少同步的惩罚,其效果与完全避免同步效果相同。

基于 CAS 保护与传统同步之间的性能差异似乎是使用微基准测试的理想情况:可以轻松编写代码来比较基于 CAS 的操作与传统的同步方法。例如,JDK 提供了一种简单的方法来使用基于 CAS 的保护来保持计数器:AtomicLong 和类似的类。微基准测试可以比较使用基于 CAS 的保护和传统同步的代码。例如,假设一个线程需要获取全局索引并以原子方式递增它(以便下一个线程获取下一个索引)。使用基于 CAS 的操作,可以这样做:

AtomicLong al = new AtomicLong(0);
public long doOperation() {
    return al.getAndIncrement();
}

该操作的传统同步版本如下:

private long al = 0;
public synchronized doOperation() {
    return al++;
}

这两种实现之间的差异结果在微基准测试中无法测量。如果只有一个线程(因此不存在争用的可能性),使用此代码的微基准测试可以产生对在非争用环境中使用这两种方法的成本的合理估计(并且该测试的结果在 第十二章 中引用)。但这并不提供任何关于在有争用环境下发生的情况的信息(如果代码永远不会争用,则首先不需要在线程安全上保护)。

在围绕这些代码片段构建的微基准测试中,仅使用两个线程运行,将存在大量的共享资源争用。这也不是现实的:在真实的应用程序中,不太可能总是有两个线程同时访问共享资源。添加更多线程只会增加不真实的竞争。

如 第二章 讨论的那样,微基准测试往往会极大地夸大同步瓶颈对所测试的影响。这个讨论理想地阐明了这一点。如果在实际应用中使用本节中的代码,将会得到一个更加真实的权衡图片。

通常情况下,以下准则适用于基于 CAS 的工具性能与传统同步性能的比较:

  • 如果对资源的访问没有争用,基于 CAS 的保护将比传统同步略快。如果访问总是没有争用,那么根本不保护将会更快,而且将避免 Vector 类中刚刚看到的寄存器刷新之类的边界情况。

  • 如果对资源的访问轻度或中度竞争,基于 CAS 的保护将比传统同步更快(通常快得多)。

  • 随着对资源的访问变得竞争激烈,传统的同步方法在某些情况下会成为更有效的选择。实际上,这种情况仅发生在运行许多线程的非常大的机器上。

  • 基于 CAS 的保护在读取而不是写入值时不会受到竞争的影响。

最终,没有什么可以替代在实际生产环境中进行广泛测试的:只有在那种情况下,才能明确地说明哪种方法实现更好。即使在这种情况下,明确的陈述也仅适用于那些条件。

快速总结

  • 避免为同步对象争用是减少它们性能影响的一种有用方法。

  • 线程本地变量从不受到争用的影响;它们非常适合持有不需要在线程之间共享的同步对象。

  • 基于 CAS 的实用程序是避免为需要共享的对象进行传统同步的一种方式。

伪共享

同步操作的一个不太被讨论的性能影响是伪共享(也称为缓存行共享)。在多核机器变得普遍,并且其他更明显的同步性能问题得到解决的情况下,它曾经是多线程程序的一个相对隐秘的现象,但现在变得越来越重要。

伪共享发生是因为 CPU 处理它们的缓存的方式。考虑这个简单类中的数据:

public class DataHolder {
    public volatile long l1;
    public volatile long l2;
    public volatile long l3;
    public volatile long l4;
}

每个long值存储在相邻的内存中;例如,l1可以存储在内存位置 0xF20。然后,l2将存储在内存中的 0xF28,l3存储在 0xF2C 等等。当程序需要操作l2时,它将从位置 0xF00 到 0xF80 加载一个相对较大的内存块(例如 128 字节)到一个 CPU 的一个核心的缓存行中。希望操作l3的第二个线程将该相同的内存块加载到另一个核心的缓存行中。

像这样加载附近的值在大多数情况下是有道理的:如果应用程序访问对象中的一个特定实例变量,它可能会访问附近的实例变量。如果它们已经加载到核心的缓存中,那么内存访问会非常快,这是一个很大的性能优势。

这种方案的缺点在于,每当程序更新其本地缓存中的值时,该核心必须通知所有其他核心,指示已更改相关内存。其他核心必须使其缓存行无效,并重新从内存中加载数据。

让我们看看如果DataHolder类被多个线程大量使用会发生什么:

@State(Scope.Benchmark)
@BenchmarkMode(Mode.AverageTime)
public class ContendedTest {
    private static class DataHolder {
	private volatile long l1 = 0;
	private volatile long l2 = 0;
	private volatile long l3 = 0;
	private volatile long l4 = 0;
    }
    private static DataHolder dh = new DataHolder();

    Thread[] threads;

    @Setup(Level.Invocation)
    public void setup() {
	threads = new Thread[4];
	threads[0] = new Thread(() -> {
		for (long i = 0; i < nLoops; i++) {
			dh.l1 += i;
		}
	});
	threads[1] = new Thread(() -> {
		for (long i = 0; i < nLoops; i++) {
			dh.l2 += i;
		}
	});
	//...similar for 2 and 3...
    }

    @Benchmark
    public void test(Blackhole bh) throws InterruptedException {
        for (int i = 0; i < 4; i++) {
	    threads[i].start();
	}
        for (int i = 0; i < 4; i++) {
	    threads[i].join();
	}
   }
}

我们有四个单独的线程,它们不共享任何变量:每个线程仅访问DataHolder类的单个成员。从同步的角度来看,没有争用,并且我们可以合理地期望这段代码在我们的四核机器上,无论是一个线程还是四个线程运行时,都将在相同的时间内执行。

结果并非如此。当一个特定线程在其循环中写入volatile值时,每个其他线程的缓存行都将无效,并且必须重新加载内存值。表 9-10 显示了结果:随着增加的线程数,性能变差。

表 9-10. 带有虚假共享的 100,000 个值求和所需时间

线程数 经过时间
1 0.8 ± 0.001 毫秒
2 5.7 ± 0.3 毫秒
3 10.4 ± 0.6 毫秒
4 15.5 ± 0.8 毫秒

这个测试用例是为了展示虚假共享的最严重惩罚:几乎每次写操作都会使所有其他缓存行无效,并且性能是串行的。

严格来说,虚假共享不一定涉及同步(或volatile)变量:每当 CPU 缓存中的任何数据值被写入时,持有相同数据范围的其他缓存必须无效。然而,请记住,Java 内存模型要求数据必须仅在同步原语的末尾(包括 CAS 和volatile构造)时写入主内存。因此,在这种情况下,虚假共享会最频繁地遇到。例如,在此示例中,如果long变量不是volatile,编译器将在寄存器中保存这些值,并且无论涉及的线程数如何,测试都将在大约 0.7 毫秒内执行。

这显然是一个极端的例子,但它引出了如何检测和修正虚假共享的问题。不幸的是,答案模糊不清且不完整。在讨论的标准工具集中,没有任何工具可以解决虚假共享问题,因为它需要对处理器架构有特定的了解。

如果你很幸运,你的应用程序目标处理器的供应商可能会有工具,用于诊断虚假共享。例如,Intel 公司有一个名为 VTune Amplifier 的程序,可以通过检查缓存未命中事件来帮助检测虚假共享。某些本地分析器可以提供关于给定代码行的每指令时钟周期数(CPI)的信息;在循环内部简单指令的高 CPI 可能表明代码在等待将目标内存重新加载到 CPU 缓存中。

否则,检测虚假共享需要直觉和实验。如果普通的性能分析表明某个特定循环花费了意外的长时间,请检查是否多个线程可能在循环内访问未共享的变量。(在性能调优被视为一门艺术而非科学的领域中,即使是英特尔 VTune 放大器手册也指出,“避免虚假共享的主要方法是通过代码检查。”)

防止虚假共享需要代码更改。理想情况是,涉及的变量可以较少频繁写入。在前面的示例中,可以使用局部变量进行计算,只有最终结果被写回DataHolder变量。即使四个线程在循环结束时同时更新它们的结果,由于写入的数量非常少,不太可能因为竞争缓存行而影响性能。

第二种可能性涉及填充变量,使其不会加载到同一缓存行。如果目标 CPU 的缓存行大小为 128 字节,像这样填充可能会奏效(但也可能不会):

public class DataHolder {
    public volatile long l1;
    pubilc long[] dummy1 = new long[128 / 8];
    public volatile long l2;
    pubilc long[] dummy2 = new long[128 / 8];
    public volatile long l3;
    pubilc long[] dummy3 = new long[128 / 8];
    public volatile long l4;
}

使用类似这样的数组是不太可能奏效的,因为 JVM 可能会重新排列这些实例变量的布局,使得所有数组彼此相邻,然后所有的long变量仍然会相邻。使用基本值来填充结构更有可能奏效,尽管由于所需变量的数量而可能不切实际。

当使用填充来防止虚假共享时,我们需要考虑其他问题。填充的大小难以预测,因为不同的 CPU 将具有不同的缓存大小。显然,填充显著增加了相关实例的大小,这将影响垃圾收集器的性能(当然,这取决于所需实例的数量)。尽管缺乏一种算法解决方案,但数据的填充有时确实可以提供显著优势。

快速总结

  • 虚假共享会显著减慢频繁修改volatile变量或退出同步块的性能代码。

  • 虚假共享很难检测。当一个循环似乎花费了太长时间时,请检查代码,看看是否符合虚假共享可能发生的模式。

  • 最好通过将数据移到局部变量并稍后存储来避免虚假共享。或者,填充有时可以用来将冲突变量移到不同的缓存行。

JVM 线程调优

JVM 有一些杂项调优选项,会影响线程和同步的性能。这些调优会对应用程序的性能产生较小的影响。

调整线程堆栈大小

当空间紧缺时,可以调整线程使用的内存。每个线程都有一个本地堆栈,操作系统在其中存储线程的调用堆栈信息(例如,main() 方法调用了 calculate() 方法,后者又调用了 add() 方法)。

该本地堆栈的大小为 1 MB(32 位 Windows JVM 除外,其大小为 320 KB)。在 64 位 JVM 中,通常没有理由设置此值,除非机器对物理内存非常紧张,并且较小的堆栈大小将防止应用程序耗尽本地内存。特别是在内存受限的 Docker 容器内部运行时更为如此。

作为一个实用的规则,许多程序可以使用 256 KB 的堆栈大小运行,很少需要完整的 1 MB。将此值设置得太小的潜在缺点是,具有极大调用堆栈的线程可能会抛出StackOverflowError

要更改线程的堆栈大小,使用-Xss=N 标志(例如,-Xss=256k)。

快速总结

  • 线程栈大小可以在内存稀缺的机器上进行减小。

偏向锁定

当锁定争用时,JVM(和操作系统)有关于如何分配锁定的选择。锁定可以公平授予,意味着每个线程将以轮询方式获得锁定。或者,锁定可以偏向最近访问锁定的线程。

偏向锁定背后的理论是,如果线程最近使用了锁定,处理器的缓存更有可能仍然包含线程在下次执行由同一锁定保护的代码时将需要的数据。如果线程被优先重新获取锁定,缓存命中的概率增加。当这种情况发生时,性能会得到改善。但由于偏向锁定需要簿记,有时它对性能的影响可能更为不利。

特别是使用线程池的应用程序,包括一些应用程序和 REST 服务器,在启用偏向锁定时通常表现不佳。在这种编程模型中,不同的线程同样有可能访问争用的锁定。对于这些类型的应用程序,可以通过禁用-XX:-UseBiasedLocking选项来获得轻微的性能改进。默认情况下启用偏向锁定。

线程优先级

每个 Java 线程都有一个开发者定义的优先级,这是一个关于程序认为特定线程重要性的提示给操作系统的信息。如果有不同的线程执行不同的任务,你可能会认为可以利用线程优先级来提高某些任务的性能,牺牲在低优先级线程上运行的其他任务。不幸的是,事实并非如此。

操作系统为机器上运行的每个线程计算一个当前优先级。当前优先级考虑了 Java 分配的优先级,但也包括许多其他因素,其中最重要的因素是线程上次运行时间。这确保了所有线程都有机会在某个时刻运行。无论其优先级如何,都不会有线程“饿死”等待访问 CPU。

这两个因素之间的平衡在操作系统之间有所不同。在基于 Unix 的系统上,计算整体优先级主要受到线程上次运行时间的影响——线程的 Java 级别优先级影响较小。在 Windows 上,具有较高 Java 优先级的线程 tend to run more than 具有较低优先级的线程,但是甚至低优先级的线程也会获得相当数量的 CPU 时间。

在任何情况下,您都不能依赖线程的优先级来影响其运行频率。如果某些任务比其他任务更重要,则必须使用应用程序逻辑对它们进行优先级排序。

监控线程和锁

在分析应用程序的线程和同步效率时,我们应该注意两件事:线程的总数(确保既不太高也不太低)以及线程等待锁或其他资源的时间。

线程可见性

几乎每个 JVM 监控工具都提供有关线程数量(以及它们正在做什么)的信息。像jconsole这样的交互式工具显示了 JVM 中线程的状态。在jconsole线程面板上,您可以实时查看程序执行期间线程数量的增加和减少。图 9-2 显示了一个示例。

jp2e 0902

图 9-2. 在jconsole中查看活动线程

有一段时间,应用程序(NetBeans)使用了最多 45 个线程。在图表的开头,我们可以看到应用程序最多使用了 38 个线程,但最终使用了 30 至 31 个线程。jconsole还可以打印单个线程堆栈;如图所示,Java2D Disposer 线程目前正在等待引用队列锁。

阻塞线程可见性

实时线程监控对于了解应用程序中运行的线程非常有用,但实际上并不提供这些线程正在做什么的任何数据。确定线程正在消耗 CPU 周期的位置需要使用分析器,如第三章中讨论的那样。分析器可以很好地显示正在执行的线程,通常还可以指导您找到可以加快整体执行速度的代码区域和更好的算法选择。

尽管这些信息通常在应用程序的整体执行中更为重要,尤其是如果该代码在多 CPU 系统上运行且未利用所有可用 CPU 时,诊断被阻塞的线程更为困难。有三种方法可以用来进行此诊断。一种方法是再次使用分析器,因为大多数分析工具将提供线程执行时间线,允许您查看线程被阻塞时的点。在第 3 章中已经提供了一个示例。

阻塞线程和 JFR

迄今为止,了解线程何时被阻塞的最佳方法是使用能够查看 JVM 并在低级别知道线程何时被阻塞的工具。其中一种工具是 Java Flight Recorder,在第 3 章中介绍过。我们可以深入分析 JFR 捕获的事件,并寻找导致线程阻塞的事件。通常要查找的事件是等待获取监视器的线程,但如果观察到对套接字进行长时间读取(很少情况下是长时间写入),它们可能也被阻塞。

可以在 Java Mission Control 的直方图面板上轻松查看这些事件,如图 9-3 所示。

在此示例中,sun.awt.AppCon⁠text.get() 方法中与 HashMap 关联的锁被争用了 163 次(持续时间超过 66 秒),导致正在测量的请求响应时间平均增加了 31 毫秒。堆栈跟踪指出,争用是由 JSP 写入 java.util.Date 对象的方式引起的。为改善此代码的可伸缩性,可以使用线程本地日期格式化程序,而不仅仅是调用日期的 toString() 方法。

jp2e 0903

图 9-3. JFR 中由监视器阻塞的线程

此过程——从直方图中选择阻塞事件并检查调用代码——适用于任何类型的阻塞事件;这得益于工具与 JVM 的紧密集成。

阻塞线程和 JStack

如果程序没有 JFR 记录,另一种方法是从程序中获取大量线程堆栈并检查这些堆栈。jstackjcmd 和其他工具可以提供有关 VM 中每个线程状态的信息,包括线程是否正在运行、等待锁、等待 I/O 等。这对于确定应用程序中正在发生的情况非常有用,只要不过多期望输出。

查看线程堆栈的第一个注意事项是,JVM 只能在安全点转储线程堆栈。其次,堆栈是逐个线程转储的,因此可能从中获取冲突信息:两个线程可能显示持有同一把锁,或者一个线程显示在等待没有其他线程持有的锁。

线程堆栈可以显示线程被阻塞的程度(因为被阻塞的线程已经处于安全点)。如果连续的线程转储显示许多线程被阻塞在锁上,可以得出结论,所讨论的锁有显著的争用。如果连续的线程转储显示许多线程被阻塞等待 I/O,可以得出结论,它们正在读取的任何 I/O 需要进行调整(例如,如果它们正在进行数据库调用,则需要调整它们执行的 SQL 或数据库本身需要调整)。

本书的在线示例具有一个简单的 jstack 输出解析器,可以总结来自一个或多个线程转储的所有线程的状态。jstack 输出的一个问题是它可能因发布而改变,因此开发健壮的解析器可能很困难。不能保证在线示例中的解析器不需要根据您的特定 JVM 进行调整。

jstack 解析器的基本输出如下所示:

% jstack pid > jstack.out
% java ParseJStack jstack.out
[Partial output...]
Threads in start Running
    8 threads in java.lang.Throwable.getStackTraceElement(Native
Total Running Threads: 8

Threads in state Blocked by Locks
    41 threads running in
    	com.sun.enterprise.loader.EJBClassLoader.getResourceAsStream
	(EJBClassLoader.java:801)
Total Blocked by Locks Threads: 41

Threads in state Waiting for notify
    39 threads running in
    	com.sun.enterprise.web.connector.grizzly.LinkedListPipeline.getTask
	(LinkedListPipeline.java:294)
    18 threads running in System Thread
Total Waiting for notify Threads: 74

Threads in state Waiting for I/O read
    14 threads running in com.acme.MyServlet.doGet(MyServlet.java:603)
Total Waiting for I/O read Threads: 14

解析器聚合所有线程并显示各种状态的数量。目前有八个线程在运行(它们碰巧正在执行堆栈跟踪——这是一个昂贵的操作,最好避免)。

由于锁定,四十一个线程被阻塞。堆栈跟踪中报告的方法是第一个非 JDK 方法,在这个例子中是 GlassFish 方法 EJBClassLoader.getResourceAsStream()。下一步是查看堆栈跟踪,搜索该方法,看看线程被阻塞在哪个资源上。

在本例中,所有线程都被阻塞等待读取同一个 JAR 文件,并且这些线程的堆栈跟踪显示,所有调用都来自实例化新的 Simple API for XML (SAX) 解析器。结果发现 SAX 解析器可以通过在应用程序的 JAR 文件的清单文件中列出资源来动态定义,这意味着 JDK 必须搜索整个类路径以找到应用程序想要使用的条目(或者直到它找不到任何内容并回退到系统解析器)。因为读取 JAR 文件需要同步锁定,所有试图创建解析器的线程最终竞争相同的锁定,严重影响应用程序的吞吐量。(要克服这种情况,请设置 -Djavax.xml.parsers.SAXParserFactory 属性以避免这些查找。)

更重要的是,大量阻塞的线程会降低性能。无论阻塞的原因是什么,都需要修改配置或应用程序以避免这种情况。

那些等待通知的线程怎么样?这些线程正在等待其他事件发生。通常它们在池中等待通知,以便某个任务已经就绪(例如,前述输出中的getTask()方法正在等待请求)。系统线程正在执行如 RMI 分布式 GC 或 JMX 监控等任务,它们在jstack输出中显示为只有 JDK 类的线程。这些情况并不一定表明性能问题;它们等待通知是正常的。

另一个问题出现在等待 I/O 读取的线程中:这些线程正在进行阻塞的 I/O 调用(通常是socketRead0()方法)。这也限制了吞吐量:线程正在等待后端资源来响应其请求。这就是开始检查数据库或其他后端资源性能的时候了。

快速摘要

  • 系统线程的基本可见性提供了运行线程数量的概述。

  • 线程可见性使我们能够确定线程为何被阻塞:是因为它们在等待资源还是在等待 I/O。

  • Java Flight Recorder 提供了检查导致线程阻塞的事件的简便方法。

  • jstack 提供了查看线程阻塞在哪些资源上的可见性水平。

摘要

理解线程如何操作可以带来重要的性能收益。然而,线程性能并不仅仅是调整——相对较少的 JVM 标志可以调整,而这些标志的效果有限。

相反,良好的线程性能在于遵循管理线程数量和限制同步效果的最佳实践准则。通过适当的分析和锁定分析工具,可以检查和修改应用程序,以确保线程和锁定问题不会对性能产生负面影响。

¹ 虽然现代代码会使用不同的集合类,但通过synchronizedCollection()方法包装集合或者通过任何其他具有过多寄存器刷新的循环,示例相同。

第十章:Java 服务器

本章探讨了围绕 Java 服务器技术的主题。在它们的核心,这些技术都是关于如何在客户端和服务器之间传输数据,通常是通过 HTTP。因此,本章的主要重点是通用服务器技术中的常见主题:如何使用不同的线程模型扩展服务器,异步响应,异步请求以及高效处理 JSON 数据。

扩展服务器主要涉及线程的有效使用,并且该使用要求事件驱动、非阻塞 I/O。传统的 Java/Jakarta EE 服务器,如 Apache Tomcat、IBM WebSphere 应用服务器和 Oracle WebLogic 服务器,已经使用 Java NIO API 长时间实现了这一点。当前的服务器框架如 Netty 和 Eclipse Vert.x 将 Java NIO API 的复杂性隔离开来,以提供易于使用的构建模块,用于构建更小的占用空间的服务器,而像 Spring WebFlux 和 Helidon 这样的服务器则是基于这些框架构建的(两者都使用 Netty 框架),以提供可扩展的 Java 服务器。

这些较新的框架提供基于响应式编程的编程模型。在其核心,响应式编程 是基于使用事件驱动范式处理异步数据流。尽管响应式编程是一种看待事件的不同方式,但对于我们的目的来说,无论是响应式编程还是异步编程都提供了相同的性能优势:能够将程序(特别是 I/O)扩展到许多连接或数据源。

Java NIO 概述

如果您熟悉非阻塞 I/O 的工作原理,可以跳过到下一节。如果不熟悉,这里是它如何工作及其作为本章基础的重要性的简要概述。

在 Java 的早期版本中,所有 I/O 都是阻塞的。试图从套接字读取数据的线程会等待(阻塞),直到至少有一些数据可用或读取超时。更重要的是,没有办法知道套接字上是否有数据可用,而不试图从套接字读取数据。因此,希望处理客户端连接上的数据的线程必须发出读取数据的请求,阻塞直到数据可用,处理请求并发送回响应,然后返回到套接字上的阻塞读取。这导致了 图 10-1 中描述的情况。

线程在从客户端进行 I/O 读取时阻塞

第 10-1 图。线程在从客户端进行 I/O 读取时阻塞。

阻塞 I/O 要求服务器在客户端连接和服务器线程之间有一对一的对应关系;每个线程只能处理单个连接。这对于希望使用 HTTP keepalive 避免每个请求创建新套接字的客户端尤为重要。假设有 100 个客户端发送请求,请求之间平均有 30 秒的思考时间,并且服务器处理一个请求需要 500 毫秒。在这种情况下,任何时候进行中的请求平均少于两个,但服务器需要 100 个线程来处理所有客户端。这是非常低效的。

因此,当 Java 引入了非阻塞的 NIO API 时,服务器框架迁移到了该模型来处理其客户端。这导致了图 10-2 所示的情况。

处理非阻塞 I/O 的线程

图 10-2. 具有读取事件通知的线程

现在,与每个客户端相关联的套接字在服务器中被注册到选择器中(这里的选择器是Selector类的一个实例,并处理与操作系统的接口,当套接字上有数据可读时会收到通知)。当客户端发送请求时,选择器从操作系统获取事件,然后通知服务器线程池中的一个线程,说明特定客户端的 I/O 可以被读取。该线程将从客户端读取数据,处理请求,发送响应,然后回到等待下一个请求的状态。¹ 虽然图中仍然有N个客户端,但它们使用M个线程进行处理。

现在客户端不再与特定的服务器线程绑定,服务器线程池可以调整以处理我们期望服务器处理的并发请求数量。在前面的示例中,具有大小为两个的线程池就足以处理所有 100 个客户端的负载。如果请求可能以非均匀的方式到达,但仍在 30 秒的一般参数内思考,我们可能需要五到六个线程来处理同时请求的数量。非阻塞 I/O 的使用使得我们可以使用比客户端数量少得多的线程,这是巨大的效率收益。

服务器容器

在服务器性能中,跨多个客户端扩展服务器连接是第一个障碍,这取决于服务器使用非阻塞 I/O 来处理基本连接。服务器是否在其他操作中使用非阻塞 API 也很重要,这将在本章后面讨论,但现在我们将专注于调优基本连接处理。

调优服务器线程池

因此,在当前的服务器中,来自客户端的请求由服务器线程池中的任意线程处理。因此,调优该线程池变得非常重要。

如前一节所述,服务器框架在管理连接和相关线程池的方式上各不相同。在那里描述的基本模型是有一个或多个充当选择器的线程:这些线程在 I/O 可用时通知系统调用,并称为选择器线程。然后,一个单独的工作线程池处理选择器通知他们客户端的 I/O 挂起后的实际请求/响应。

选择器和工作线程可以以各种方式设置:

  • 选择器和工作线程池可以是分开的。选择器等待所有套接字的通知并将请求交给工作线程池。

  • 或者,当选择器通知有关 I/O 时,它会读取(也许只是部分)I/O 以确定有关请求的信息。然后,根据请求类型,选择器将请求转发到不同的服务器线程池。

  • 选择器池在ServerSocket上接受新连接,但在连接建立后,所有工作都在工作线程池中处理。工作线程池中的线程有时会使用Selector类等待现有连接的挂起 I/O,并且有时会处理来自工作线程的客户端 I/O 挂起的通知(例如,执行客户端的请求/响应)。

  • 根本不需要区分充当选择器的线程和处理请求的线程。通知套接字上的 I/O 可用的线程可以处理整个请求。同时,池中的其他线程收到其他套接字上的 I/O 通知,并处理这些其他套接字上的请求。

尽管有这些差异,当调整服务器线程池时,我们应该牢记两个基本点。首先(也是最重要的)是,我们需要足够的工作线程来处理服务器可以处理的同时请求的数量(而不是同时连接)。正如在第九章中讨论的那样,这部分取决于这些请求本身是否执行 CPU 密集型代码或将进行其他阻塞调用。在这种情况下的另一个考虑是,如果服务器进行了额外的非阻塞调用,会发生什么。

考虑一个仅执行 CPU 密集型计算的 REST 服务器。然后,与所有 CPU 绑定的情况一样,不需要比服务器或容器上的虚拟 CPU 更多的线程:我们永远无法运行超过那个数量的线程。

如果 REST 服务器反过来对另一个资源进行出站调用——比如另一个 REST 服务器或数据库,那会怎么样?现在取决于这些调用是阻塞还是非阻塞。暂时假设这些调用是阻塞的。现在,我们将需要为每个同时的出站阻塞调用使用一个线程。这可能会将我们的服务器重新转变为低效的一个线程对应一个客户端模型。

假设为了满足特定客户请求,工作线程必须花费 900 ms 从数据库检索数据,并花费 100 ms 设置该数据库调用并将数据处理成客户端响应。在一个有两个非超线程 CPU 的系统上,该服务器有足够的 CPU 处理每秒 20 个请求。如果每 30 秒有一个请求来自每个客户端,服务器可以处理 600 个客户端。由于客户端连接处理是非阻塞的,我们不需要在工作线程池中使用 600 个线程,但也不能仅用 2 个线程(每个 CPU 一个)。平均而言,会有 20 个请求被阻塞,因此我们至少需要这么多线程在工作线程池中。

现在假设出站请求也是非阻塞的,因此在数据库花费 900 ms 返回答案期间,进行数据库调用的线程可以处理其他请求。现在我们只需要两个工作线程:它们可以花费所有时间处理数据库数据所需的 100 ms 部分,使 CPU 充分忙碌,服务器的吞吐量达到最大值。

像往常一样,这些讨论有所简化:我们需要时间来读取和设置请求等。但基本规则仍然适用:您需要与将同时执行代码并同时阻塞在其他资源上的线程数量相同的工作线程池。

这里的另一个调整考虑因素是任何给定时间需要充当选择器的线程数量。您需要多于一个。选择器线程执行 select() 调用以查找哪些套接字有 I/O 可用。然后它必须花时间处理这些数据:至少通知其他工作线程有哪些客户端请求需要处理。然后它可以返回并再次调用 select() 方法。但在处理 select() 调用结果的同时,另一个线程应该执行 select() 调用以查看其他套接字何时有可用数据。

因此,在具有独立的选择器线程池的框架中,您需要确保该池至少有几个线程(通常,默认值为三个)。在同一线程池处理选择和处理的框架中,根据我们刚刚讨论的工作指南,您需要增加几个额外的线程。

异步 REST 服务器

调整服务器请求线程池的另一种方法是将工作推迟到另一个线程池。这是 JAX-RS 的异步服务器实现以及 Netty 的事件执行器任务(专为长时间运行的任务设计)和其他框架采取的方法。

让我们从 JAX-RS 的角度来看待这个问题。在一个简单的 REST 服务器中,请求和响应都在同一个线程上处理。这限制了服务器的并发性能。例如,在一个八核 CPU 的 Helidon 服务器上,默认线程池为 32。考虑以下端点:

    @GET
    @Path("/sleep")
    @Produces(MediaType.APPLICATION_JSON)
    public String sleepEndpoint(
        @DefaultValue("100") @QueryParam("delay") long delay
        ) throws ParseException {
        try { Thread.sleep(delay); } catch (InterruptedException ie) {}
        return "{\"sleepTime\": \"" + delay + "\"}";
    }

在这个例子中,睡眠的目的仅用于测试:假设该睡眠正在进行远程数据库调用或调用另一个 REST 服务器,而那个远程调用需要 100 毫秒。如果我在一个带有默认配置的 Helidon 服务器上运行该测试,它将处理 32 个同时请求。具有并发性为 32 的负载生成器将报告每个请求需要 100 毫秒(加上 1–2 毫秒的处理时间)。具有并发性为 64 的负载生成器将报告每个请求需要 200 毫秒,因为每个请求都必须等待另一个请求完成后才能开始处理。

其他服务器将有不同的配置,但效果是一样的:基于请求线程池的大小将有一些限制。通常这是一件好事:如果在这个例子中,那 100 毫秒是作为活跃的 CPU 时间(而不是睡眠),那么除非它运行在一个非常大的机器上,否则服务器实际上不会能够同时处理 32 个请求。

不过,在这种情况下,机器甚至没有接近 CPU 负载;当没有处理需要进行时,它可能只需要单个核心的 20%–30%来处理负载(如果那些 100 毫秒的时间间隔只是对另一个服务的远程调用的话,处理负载时也是同样的情况)。因此,我们可以通过更改默认线程池的配置来增加该机器上的并发性能,以处理更多的调用。这里的限制将基于远程系统的并发性;我们仍然希望限制调用这些系统,以免超负荷。

JAX-RS 提供了第二种增加并发性的方式,那就是利用异步响应。异步响应允许我们将业务逻辑处理延迟到不同的线程池中进行:

    ThreadPoolExecutor tpe = Executors.newFixedThreadPool(64);
    @GET
    @Path("/asyncsleep")
    @Produces(MediaType.APPLICATION_JSON)
    public void sleepAsyncEndpoint(
        @DefaultValue("100") @QueryParam("delay") long delay,
        @Suspended final AsyncResponse ar
        ) throws ParseException {
        tpe.execute(() -> {
            try { Thread.sleep(delay); } catch (InterruptedException ie) {}
            ar.resume("{\"sleepTime\": \"" + delay + "\"}");
        });
    }

在这个例子中,初始请求在服务器的默认线程池上进行。该请求设置了一个调用,用于在一个单独的线程池(称为异步线程池)中执行业务逻辑,然后sleepAsyncEndpoint()方法立即返回。这释放了默认线程池中的线程,使其能够立即处理另一个请求。与此同时,异步响应(标有@Suspended标签)正在等待逻辑完成;完成后,它将恢复并将响应发送回用户。

这使得我们可以在请求开始积压之前运行 64(或我们传递给线程池的任何参数)个并行请求。但坦率地说,我们并没有从将默认线程池调整为 64 实现任何不同。事实上,在这种情况下,我们的响应会稍差一些,因为请求被发送到不同的线程进行处理,这将花费几毫秒。

使用异步响应的三个原因:

  • 为了将更多并行性引入业务逻辑。假设我们的代码不是睡眠 100 毫秒,而是需要进行三次(无关的)JDBC 调用来获取响应所需的数据。使用异步响应使得代码能够在异步线程池中的每个线程中并行处理每个调用。

  • 限制活动线程的数量。

  • 为了正确限制服务器的流量。

在大多数 REST 服务器中,如果仅仅限制请求线程池,新请求将等待它们的轮次,并且线程池的队列将增长。通常,这个队列是无界的(或者至少具有非常大的限制),因此请求的总数最终将变得难以管理。在线程池队列中等待很长时间的请求通常会被放弃,即使它们没有被放弃,长时间的响应时间也会降低系统的总吞吐量。

更好的方法是在排队响应之前查看异步线程池的状态,并在系统过载时拒绝请求。

    @GET
    @Path("/asyncreject")
    @Produces(MediaType.APPLICATION_JSON)
    public void sleepAsyncRejectEndpoint(
        @DefaultValue("100") @QueryParam("delay") long delay,
        @Suspended final AsyncResponse ar
        ) throws ParseException {
        if (tpe.getActiveCount() == 64) {
            ar.cancel();
            return;
        }
        tpe.execute(() -> {
            // Simulate processing delay using sleep
            try { Thread.sleep(delay); } catch (InterruptedException ie) {}
            ar.resume("{\"sleepTime\": \"" + delay + "\"}");
        });
    }

这可以通过多种方式实现,但在这个简单的例子中,我们将关注池中运行的活动计数。如果计数等于池大小,则立即取消响应。(更复杂的例子会为池设置有界队列,并在线程池的拒绝执行处理程序中取消请求。)这里的效果是调用者将立即收到 HTTP 503 服务不可用状态,表明此时无法处理请求。这是在 REST 世界中处理过载服务器的首选方式,立即返回此状态将减少负载,从而最终实现更好的整体性能。

快速摘要

  • 使用 Java 的 NIO API 进行非阻塞 I/O 允许服务器通过减少处理多个客户端所需的线程数量来扩展。

  • 此技术意味着服务器将需要一个或多个线程池来处理客户端请求。这个池应该根据服务器应该处理的最大同时请求数进行调整。

  • 然后需要一些额外的线程来处理选择器(无论是作为工作线程池的一部分还是作为依赖于服务器框架的单独线程池的一部分)。

  • 服务器框架通常有一种机制,可以将长时间的请求推迟到不同的线程池中,从而更加稳健地处理主线程池上的请求。

异步出站调用

前面的章节给出了一个具有两个 CPU 的服务器的示例,它需要一个包含 20 个线程的池来获得其最大吞吐量。这是因为这些线程在向其他资源发出出站调用时,90% 的时间都被阻塞在 I/O 上。

非阻塞 I/O 在这种情况下也很有帮助:如果这些出站的 HTTP 或 JDBC 调用是非阻塞的,我们就不需要专门为每个调用分配一个线程,可以相应减少线程池的大小。

异步 HTTP

HTTP 客户端是处理向服务器发出的 HTTP 请求的类(毫不奇怪)。有许多客户端,它们都具有不同的功能和性能特性。在本节中,我们将研究它们在常见用例中的性能特征。

Java 8 提供了一个基本的 HTTP 客户端,即 java.net.HttpURLConnection 类(对于安全连接,还有子类 java.net.HttpsURLConnection)。Java 11 添加了一个新的客户端:java.net.http.HttpClient 类(同时处理 HTTPS)。其他包中的 HTTP 客户端类包括 Apache 基金会的 org.apache.http.cli⁠ent​.HttpClient,建立在 Netty 项目之上的 org​.asynchttpclient.AsyncHttpClient,以及 Eclipse 基金会的 org.eclipse.jetty.client.HttpClient

虽然可以使用 HttpURLConnection 类执行基本操作,但大多数 REST 调用都使用诸如 JAX-RS 等框架进行。因此,大多数 HTTP 客户端直接实现这些 API(或稍有变化),但 JAX-RS 的默认实现也提供了最受欢迎的 HTTP 客户端的连接器。因此,可以使用 JAX-RS 与提供最佳性能的底层 HTTP 客户端。JAX-RS 和底层 HTTP 客户端包含两个基本的性能考虑因素。

首先,JAX-RS 连接器提供了一个名为 Client 的对象,用于进行 REST 调用;当直接使用客户端时,它们类似地提供了一个名为 HttpClient 的客户端对象(HttpURLConnection 类是个例外;它不能被重用)。典型的客户端将如下创建和使用:

private static Client client;
static {
    ClientConfig cc = new ClientConfig();
    cc.connectorProvider(new JettyConnectorProvider());
    client = ClientBuilder.newClient(cc);
}

public Message getMessage() {
    Message m = client.target(URI.create(url)
                  .request(MediaType.APPLICATION_JSON)
                  .get(Message.class);
    return m;
}

在这个示例中的关键是 client 对象是一个静态的、共享的对象。所有的 client 对象都是线程安全的,且创建开销很大,因此你希望应用程序中只有很少的数量(比如一个)。

第二个性能考虑因素是确保 HTTP 客户端正确地池化连接并使用 keepalive 来保持连接开放。对于 HTTP 通信来说,打开一个 socket 是昂贵的操作,特别是如果协议是 HTTPS 并且客户端和服务器必须执行 SSL 握手。像 JDBC 连接一样,HTTP(S) 连接也应该被重复使用。

所有 HTTP 客户端都提供了池化机制,尽管在HttpURLConnection类中的池化机制经常被误解。默认情况下,该类将池化五个连接(每个服务器)。然而,与传统的连接池不同,该类中的池不会限制连接:如果请求第六个连接,将会创建一个新连接,而你使用完毕后会被销毁。这种短暂连接在传统连接池中是看不到的。所以在HttpURLConnection类的默认配置中,很容易看到大量的短暂连接,并且会误以为连接没有被池化(Javadoc 在这方面也不够明确;它从未提及池化功能,尽管行为在其他地方有文档记录)。

你可以通过设置系统属性-Dhttp.maxConnections=*N*来更改池的大小,默认为 5。尽管名称如此,此属性也适用于 HTTPS 连接。但没有方法使该类限制连接。

在 JDK 11 的新HttpClient类中,池遵循类似的思路,但有两个重要的区别。首先,默认池大小是无界的,尽管可以通过设置-Djdk.httpclient.connectionPoolSize=*N*系统属性来设置。该属性仍然不会作为限制;如果请求超过配置的连接数,它们将在需要时被创建,然后在完成时被销毁。其次,该池是每个HttpClient对象的,因此如果不重用该对象,则不会进行连接池化。

在 JAX-RS 中,经常建议使用与默认不同的连接器以获取连接池。因为默认连接器使用HttpURLConnection类,这是不正确的:除非你想限制连接,你可以调整该类的连接大小,正如我们刚刚讨论的那样。其他流行的连接器也将池化连接。

表 10-1。调整流行客户端的 HTTP 连接池

连接器 HTTP 客户端类 池化机制
默认 java.net.HttpURLConnection 设置maxConnections系统属性
Apache org.apache.http.​cli⁠ent.HttpClient 创建一个PoolingHttpClientConnectionManager
Grizzly com.ning.http.client.​Asyn⁠cHttpClient 默认池化;可以修改配置
Jetty org.eclipse.jetty.​cli⁠ent.HttpClient 默认池化;可以修改配置

在 JAX-RS 中,Grizzly 连接管理器使用com.ning.http.client​.Asyn⁠cHttpClient客户端。该客户端已经更名为org​.asyn⁠chttpclient.AsyncHttpClient,它是基于 Netty 构建的异步客户端。

异步 HTTP 客户端

异步(async)HTTP 客户端,如异步 HTTP 服务器,允许更好地管理应用程序中的线程。发出异步调用的线程将请求发送到远程服务器,并在请求可用时安排不同(后台)线程来处理请求。

这个声明(“安排已经做好”)在这里故意模糊,因为在不同的 HTTP 客户端之间实现这一机制的方式是非常不同的。但从性能的角度来看,使用异步客户端增加了性能,因为它将响应处理推迟到另一个线程,允许更多的事情并行运行。

异步 HTTP 客户端是 JAX-RS 2.0 的一个特性,尽管大多数独立的 HTTP 客户端也直接支持异步特性。事实上,您可能已经注意到我们查看的一些客户端的名称中包含 async:它们默认是异步的。虽然它们也有同步模式,但这发生在同步方法的实现中:这些方法发出异步调用,等待响应完成,然后将响应(同步地)返回给调用者。

这种异步模式由 JAX-RS 2.0 实现支持,包括参考 Jersey 实现中的实现。该实现包括几个可以异步使用的连接器,尽管并非所有这些连接器都是真正的异步。在所有情况下,响应处理都推迟到另一个线程,但它可以以两种基本方式运行。在一种情况下,另一个线程可以简单地使用标准的阻塞 Java I/O。在这种情况下,后台线程池需要为每个要同时处理的请求提供一个线程。这与异步服务器相同:通过添加大量其他线程,我们实现了并发性。

在第二种情况下,HTTP 客户端使用非阻塞 I/O。对于这种类型的处理,后台线程需要一些(至少一个,但通常更多)线程来处理 NIO 键选择,然后一些线程来处理随时到来的响应。在许多情况下,这些 HTTP 客户端总体上使用较少的线程。NIO 是经典的事件驱动编程:当套接字连接上的数据可供读取时,会通知一个线程(通常来自池)。该线程读取数据,处理数据(或将数据传递给另一个线程以进行处理),然后返回到池中。

异步编程通常被认为是事件驱动的,因此严格来说,使用阻塞 I/O(并将线程固定在整个请求期间)的异步 HTTP 客户端并不是异步的。即使 API 给出了异步行为的假象,线程的可扩展性也不会如我们所期望的那样。

从性能的角度来看,异步客户端给我们带来了与异步服务器类似的好处:我们可以增加请求的并发性,使其执行更快,并且可以通过利用不同的线程池更好地管理(和限制)请求。

让我们以异步示例的常见情况为例:一个作为来自另外三个 REST 服务的信息聚合器的 REST 服务。这样一个服务的伪代码概述如下:

public class TestResource {
    public static class MultiCallback extends InvocationCallback<Message> {
        private AsyncResponse ar;
        private AtomicDouble total = new AtomicDouble(0);
        private AtomicInteger pendingResponses;
        public MultiCallback(AsyncResponse ar, int targetCount) {
            this.ar = ar;
            pendingResponse = new AtomicInteger(targetCount);
        }
        public void completed(Message m) {
            double d = total.getAndIncrement(Message.getValue());
            if (targetCount.decrementAndGet() == 0) {
                ar.resume("{\"total\": \"" + d + "\"}");
            }
        }
    }

    @GET
    @Path("/aggregate")
    @Produces(MediaType.APPLICATION_JSON)
    public void aggregate(@Suspended final AsyncResponse ar)
                    throws ParseException {
        MultiCallback callback = new MultiCallback(ar, 3);
        target1.request().async().get(callback);
        target2.request().async().get(callback);
        target3.request().async().get(callback);
    }
}

注意,在这个示例中,我们也使用了异步响应,但是不需要像之前那样使用单独的线程池:请求将在处理响应的一个线程中恢复。

这为该操作引入了所需的并发性,但让我们更仔细地看一下线程使用情况。图 10-3 展示了执行此示例时 Helidon 服务器的显着线程使用情况。

异步客户端线程使用

图 10-3. 异步 HTTP 客户端的简单线程使用

在时间 T1,请求进入并开始在 Helidon 请求线程上执行。线程设置了三个远程调用;每个调用实际上都是由异步客户端池中的一个线程发送的。(在图表中,三个调用由同一个线程发送,但这取决于时间:它们可能在三个不同的线程上执行,这取决于请求的快速执行和发送数据的时间。)与这些调用相关联的三个套接字也在由 NIO 轮询线程处理的事件队列上注册。请求线程在时间 T2 结束处理。

在时间 T3,NIO 轮询线程收到一个套接字有数据的事件,因此设置 HTTP 客户端线程 #1 读取和处理该数据。该处理将持续到时间 T5。同时,在时间 T4,NIO 轮询线程收到另一个套接字有数据可读的事件,然后由 HTTP 客户端线程 #2 读取和处理(这需要到时间 T7)。然后在时间 T5,第三个套接字准备好被处理。由于 HTTP 客户端线程 #1 空闲,它可以读取和处理该请求,该请求在时间 T8 完成(在那时,resume() 方法被调用,响应对象被传递给客户端)。

这里的关键是客户端线程的处理时间。如果处理非常快,且响应之间有很好的间隔,一个线程就可以处理所有响应。如果处理时间很长或者响应被打包,我们将需要一个线程处理一个请求。在这个示例中,我们处于一个中间地带:我们使用的线程比一个线程处理一个请求的模型少,但比一个线程多。这是 REST 服务器与像 nginx 服务器一样的静态内容之间的一个关键区别:最终,即使在完全异步的实现中,业务逻辑的 CPU 需求也将需要相当数量的线程以获得良好的并发性。

此示例假定 HTTP 客户端正在使用 NIO。如果客户端使用传统的 NIO,那么图示将略有不同。当进行第一个异步客户端线程调用时,该调用将持续到时间 T7。对异步客户端的第二个调用将需要一个新线程;该请求将持续到时间 T8。第三个异步客户端线程将运行到时间 T5(客户端不会按照它们启动的顺序完成)。Figure 10-4 显示了区别。

无论哪种情况,对于最终用户来说,结果都是相同的:三个请求并行处理,性能得到了预期的提升。但是,线程使用情况(因此整体系统效率)将有很大的不同。

阻塞式客户端线程使用情况

图 10-4. 阻塞式 HTTP 客户端的简单线程使用情况

异步 HTTP 客户端和线程使用情况

这些后台线程池将充当节流阀,并且通常必须像往常一样进行调整,使其足够大以处理应用程序所需的并发性,但不能太大以至于压倒对后端资源的请求。通常,默认设置就足够了,但如果您需要进一步研究 JAX-RS 的参考实现中的不同连接器及其后台池,请参阅每个连接器的其他信息。

默认连接器

默认连接器使用阻塞式 I/O。Jersey(JAX-RS 的参考实现)中的单个异步客户端线程池将处理所有请求;此线程池中的线程以jersey-client-async-executor开头命名。该池将需要一个线程来处理每个同时进行的请求,正如 Figure 10-4 所示。默认情况下,该池大小是无限的;您可以在配置客户端时通过设置此属性来设置上限:

    ClientConfig cc = new ClientConfig();
    cc.property(ClientProperties.ASYNC_THREADPOOL_SIZE, 128);
    client = ClientBuilder.newClient(cc);

Apache 连接器

尽管 Apache 库具有真正的异步客户端(使用 NIO 读取响应而不需要专用线程),但 Jersey 中的 Apache 连接器使用传统的阻塞式 I/O Apache 客户端。关于线程池,它的行为和配置方式与默认连接器完全相同。

Grizzly 连接器

Grizzly 连接器使用的 HTTP 客户端是异步的,遵循 Figure 10-3 中的模型。涉及多个池:一个池(grizzly-ahc-kernel)用于写请求,一个池(nioEventLoopGroup)用于等待 NIO 事件,以及一个池(pool-N)用于读取和处理响应。后一个池对于吞吐量/节流而言非常重要,并且其大小是无限的;可以使用ASYNC_THREADPOOL_SIZE属性进行节流。

Jetty 连接器

Jetty 使用异步客户端。请求由同一个线程池发送和读取(并且事件轮询也在该池中发生)。在 Jersey 中,该池也使用ASYNC_THREADPOOL_SIZE属性进行配置,尽管使用 Jetty 的服务器有两个后端线程池:处理杂项簿记的jersey-client-async-executor线程池,以及处理 Jetty 客户端的线程池(这些线程以HttpClient开头命名)。如果未设置该属性,则HttpClient池的大小为 200。

快速总结

  • 确保 HTTP 客户端的连接池设置正确。

  • 异步 HTTP 客户端可以通过将工作分配给多个线程来提高性能,增加并发性。

  • 使用 NIO 构建的异步 HTTP 客户端将比使用传统 I/O 构建的客户端需要更少的线程,但是一个 REST 服务器仍然需要大量线程来处理异步请求。

异步数据库调用

如果涉及的出站调用是对关系型数据库的调用,使其真正异步是很困难的。标准的 JDBC API 不适合使用非阻塞 I/O,因此一个通用的解决方案将需要一个新的 API 或者新的技术。围绕这样一个 API 的各种提案已经被提出并被拒绝,目前的希望是一种名为fibers的新轻量级任务模型将使现有的同步 API 能够在不需要异步编程的情况下良好扩展。Fibers是 OpenJDK Loom 项目的一部分,但截至本文写作时尚未设定目标发布日期。

异步 JDBC 包装器的提案(和实现)通常将 JDBC 工作推迟到一个单独的线程池中。这与前一节中的默认 Jersey 异步 HTTP 客户端类似:从程序的角度来看,API 看起来是异步的。但是在实现中,后台线程在 I/O 通道上被阻塞,因此我们不会通过这种方式获得可伸缩性。

JDK 之外的各种项目可以填补这一空白。最广泛使用的是 Spring 项目的 Spring Data R2DBC。这需要使用不同的 API,并且仅适用于某些数据库的驱动程序。但是,对于关系型数据库的非阻塞访问来说,这是目前最佳的选择。

对于 NoSQL 数据库,情况有些类似。另一方面,Java 没有用于首次访问 NoSQL 数据库的标准,因此您的编程依赖于数据库专有的 API。因此,用于反应式 NoSQL 数据库的 Spring 项目可以用于真正的异步访问。

JSON 处理

现在我们已经看过 Java 服务器中数据发送的机制,让我们深入了解数据本身。在本节中,我们将主要关注 JSON 处理。旧版 Java 程序通常使用 XML(JSON 和 XML 之间的处理权衡几乎相同);还有像 Apache Avro 和 Google 的协议缓冲区这样的新格式。

解析和编组概述

给定一系列 JSON 字符串,程序必须将这些字符串转换为适合 Java 处理的数据。这称为编组解析,取决于上下文和生成的输出。如果输出是 Java 对象,则该过程称为编组;如果数据在读取时被处理,则该过程称为解析。从其他数据生成 JSON 字符串的反向过程称为解编组

我们可以使用三种一般技术来处理 JSON 数据:

拉取解析器

输入数据与解析器关联,并且程序从解析器请求(或拉取)一系列令牌。

文档模型

输入数据转换为文档样式对象,应用程序随后可以遍历该对象以查找数据片段。这里的接口是以通用文档对象为基础的。

对象表示

输入数据通过使用一组预定义类转换为一个或多个 Java 对象,这些类反映了数据的结构(例如,预定义的Person类用于表示个体数据)。这些通常被称为普通的旧 Java 对象(POJOs)。

这些技术按照从最快到最慢的粗略顺序列出,但是它们之间的功能差异比性能差异更重要。简单扫描是解析器所能做的所有工作,因此它们并不理想地适合必须按随机顺序访问或多次检查的数据。为了处理这些情况,只使用简单解析器的程序需要构建一个内部数据结构,这只是一个简单的编程问题。但文档和 Java 对象模型已经提供了结构化数据,通常比自行定义新结构更容易。

实际上,这是使用解析器和使用数据编组器之间的真正区别。列表中的第一项是解析器,应用程序逻辑要根据解析器提供的数据来处理数据。接下来的两项是数据编组器:它们必须使用解析器来处理数据,但它们提供了更复杂程序可以在其逻辑中使用的数据表示。

因此,关于使用哪种技术的主要选择取决于应用程序需要如何编写。如果程序需要对数据进行简单的一次遍历,仅使用最快的解析器即可。如果数据要保存在简单的应用程序定义结构中,直接使用解析器也是适当的;例如,样本数据中项目的价格可以保存到 ArrayList 中,这对其他应用程序逻辑来说很容易处理。

当数据格式至关重要时,使用文档模型更为合适。如果必须保留数据的格式,文档格式是简单的:可以将数据读入文档格式,进行某种方式的修改,然后可以将文档格式简单地写入新的数据流。

对于最大的灵活性,对象模型提供了数据的 Java 语言级表示。可以在对象及其属性的熟悉术语中操作数据。尽管编组时的额外复杂性(大部分)对开发人员来说是透明的,可能会使应用程序的某些部分变慢,但在与代码工作中的生产力改进方面可以抵消这个问题。

JSON 对象

JSON 数据有两种对象表示形式。第一种是通用的:简单的 JSON 对象。这些对象通过通用接口操作:JsonObjectJsonArray 等。它们提供了一种构建或检查 JSON 文档的方式,而无需创建数据的特定类表示。

第二种 JSON 对象表示形式将 JSON 数据绑定到一个成熟的 Java 类上,使用 JSON 绑定(JSON-B)生成 POJO。例如,我们样本 JSON 数据中的项目数据将由一个 Item 类表示,该类具有其字段的属性。

这两种对象表示形式的区别在于,第一种是通用的,不需要类。假设我们有一个代表样本数据中项目的 JsonObject,那么项目的标题将如下所示:

JsonObject jo;
String title = jo.getString("title");

在 JSON-B 中,项目的标题可以通过更直观的 getter 和 setter 方法获得:

Item i;
String title = i.getTitle();

无论哪种情况,对象本身都是使用底层解析器创建的,因此配置解析器以获得最佳性能非常重要。但除了解析数据外,对象实现还允许我们从对象生成 JSON 字符串(即反编组对象)。表 10-2 显示了这些操作的性能。

表 10-2. JSON 对象模型的性能

对象模型 编组性能
JSON 对象 2318 ± 51 微秒
JSON-B 类 7071 ± 71 微秒
Jackson 映射器 1549 ± 40 微秒

生成简单的 JSON 对象比生成自定义 Java 类要快得多,尽管从编程角度来看,后者更容易使用。

本表中的 Jackson 映射器是另一种方法,目前几乎已经超越了其他用途。尽管 Jackson 提供了标准 JSON 解析(JSON-P)API 的实现,但他们还有一种备用实现,该实现将 JSON 数据编组和解编组为 Java 对象,但不遵循 JSON-B。该实现是建立在 Jackson 提供的 ObjectMapper 类上的。将数据编组为对象的 JSON-B 代码如下:

Jsonb jsonb = JsonbBuilder.create();
FindItemsByKeywordsResponse f =
    jsonb.fromJson(inputStream, FindItemsByKeywordsResponse.class);

ObjectMapper 的代码略有不同:

ObjectMapper mapper = new ObjectMapper();
FindItemsByKeywordsResponse f =
    mapper.readValue(inputStream, FindItemsByKeywordsResponse.class);

从性能角度来看,ObjectMapper 的使用存在一些缺陷。由于 JSON 数据被编组,mapper 会创建许多用于生成最终 POJO 的代理类。这本身在首次使用类时会耗费一些时间。为了克服这个问题——也是第二个性能问题——是创建大量的映射器对象(例如,每个执行编组的类静态一个)。这往往会导致内存压力、过多的 GC 循环,甚至 OutOfMemory 错误。一个应用程序中只需要一个 ObjectMapper 对象,这有助于 CPU 和内存的使用。即便如此,数据的对象模型表示将为这些对象消耗内存。

JSON 解析

直接解析 JSON 数据有两个优点。首先,如果 JSON 对象模型对您的应用程序来说占用内存过多,直接解析 JSON 并处理它将节省内存。其次,如果您处理的 JSON 包含大量数据(或希望以某种方式过滤的数据),直接解析将更高效。

所有 JSON 解析器都是拉取解析器,通过按需从流中检索数据来操作。本节测试中的基本拉取解析器的主要逻辑是这个循环:

parser = factory.createParser(inputStream);
int idCount = 0;
while (parser.hasNext()) {
    Event event = parser.next();
    switch (event) {
        case KEY_NAME:
            String s = parser.getString();
            if (ID.equals(s)) {
                isID = true;
            }
            break;
        case VALUE_STRING:
            if (isID) {
                if (addId(parser.getString())) {
                    idCount++;
                    return;
                }
                isID = false;
            }
            continue;
        default:
            continue;
    }
}

此代码从解析器中提取标记。在代码中,大多数标记只是丢弃的。当找到起始标记时,代码会检查该标记是否为项 ID。如果是,下一个字符标记将是应用程序想要保存的 ID。

该测试还允许我们过滤数据;在这种情况下,我们正在过滤以仅读取 JSON 数据中的前 10 个项。这是在我们处理 ID 时完成的:通过 addItemId() 方法保存该 ID,如果已存储所需数量的 ID,则该循环可以直接返回而不处理输入流中的剩余数据。

这些解析器实际上是如何执行的?表格 10-3 展示了解析样本文档所需的平均微秒数,假设在处理完 10 个项后停止解析,并处理整个文档。可预见的是,解析少 90% 的项会使性能提升 90%。

表格 10-3. 拉取解析器的性能

项数 默认解析器 Jackson 解析器
10 159 ± 2 us 86 ± 5 μs
100 1662 ± 46 us 770 ± 4 μs

长期以来,Jackson 解析器在这里表现出色,但两者都比读取实际对象快得多。

快速摘要

  • 处理 JSON 有两种选项:创建 POJO 对象和直接解析。

  • 选择取决于应用需求,但直接解析提供了过滤和通用性能机会。当对象较大时,创建 JSON 对象往往会导致 GC 问题。

  • Jackson 解析器通常是最快的解析器;应优先选择它而不是默认实现。

摘要

非阻塞 I/O 构成了有效服务器扩展的基础,因为它允许服务器使用相对较少的线程处理大量连接。传统服务器利用这一点来处理基本的客户端连接,而新型服务器框架可以将非阻塞特性扩展到其他应用程序。

¹ 这种方案有许多细微的变化;你将在下一节看到其中一些。

第十一章:数据库性能最佳实践

本章调查了由 Java 驱动的数据库应用程序的性能。访问数据库的应用程序受非 Java 性能问题的影响:如果数据库受 I/O 限制,或者执行需要全表扫描的 SQL 查询(因为缺少索引),那么无论多少 Java 调优或应用编码都无法解决性能问题。在处理数据库技术时,准备好从其他来源学习如何调优和编程数据库。

这并不意味着使用数据库的应用程序的性能对 JVM 和所使用的 Java 技术下的事物不敏感。相反,为了良好的性能,有必要确保数据库和应用程序都正确调优并执行最佳代码。

本章首先从 JDBC 驱动程序开始,因为这些驱动程序会影响与关系数据库对话的数据框架。许多框架都会抽象出 JDBC 的细节,包括 JPA 和 Spring 数据模块。

样例数据库

本章的示例使用了一个样例数据库设置,用于存储 256 个股票实体在一年期间的数据。该年有 261 个工作日。

单个股票的价格存储在名为 STOCKPRICE 的表中,该表具有股票符号和日期的主键。该表中有 66,816 行(256 × 261)。

每支股票都有一组五个相关的期权,这些期权也是按日定价的。STOCKOPTIONPRICE 表保存了具有符号、日期和表示期权号码的整数的数据,该表中有 334,080 行(256 × 261 × 5)。

JDBC

本章介绍了从 JPA 版本 2.x的角度来看数据库性能。然而,JPA 在内部使用 JDBC,并且许多开发人员仍然直接向 JDBC API 编写应用程序,因此重要的是要查看 JDBC 的最重要性能方面。即使是使用 JPA(或类似于 Spring Data 的其他数据库框架)的应用程序,了解 JDBC 性能也将有助于从框架中获得更好的性能。

JDBC 驱动程序

JDBC 驱动程序是数据库应用程序性能的最重要因素。数据库配备了各自的 JDBC 驱动程序,并且大多数流行的数据库都有备用的 JDBC 驱动程序可用。这些备用驱动程序通常被认为提供更好的性能。

不可能裁决所有数据库驱动程序的性能声明,但在评估驱动程序时需要考虑以下几点。

工作执行地点

JDBC 驱动程序可以编写以在 Java 应用程序(数据库客户端)中执行更多工作,或者在数据库服务器上执行更多工作。最好的例子是 Oracle 数据库的轻量和重量驱动程序。轻量驱动程序 的设计使其在 Java 应用程序内占用的空间相对较小:它依赖于数据库服务器执行更多处理。重量驱动程序 则相反:它从数据库卸载工作,但需要在 Java 客户端上进行更多处理和占用更多内存。大多数数据库都可以做出这种权衡。

竞争性声明对于哪种模型提供更好性能存在分歧。事实是,这两种模型都没有固有优势——提供最佳性能的驱动程序取决于其运行环境的具体情况。例如,如果一个应用主机是一台小型双核机器连接到一个巨大而经过调整的数据库,那么应用主机的 CPU 在数据库承受任何重要负载之前可能会饱和。在这种情况下,轻量驱动程序将提供更好的性能。相反,如果一个企业有 100 个部门访问单个 HR 数据库,则如果保留数据库资源并且客户端部署了重量驱动程序,则将获得最佳性能。¹

在涉及 JDBC 驱动程序时,任何性能声明都值得怀疑:很容易选择一款适合特定环境的驱动程序,并展示它优于另一家供应商在完全相同设置下表现不佳的驱动程序。像往常一样,在您自己的环境中进行测试,并确保该环境与您将要部署的环境相匹配。

JDBC 驱动程序类型

JDBC 驱动程序有四种类型(1–4)。目前广泛使用的驱动程序类型是类型 2(使用本地代码)和类型 4(纯 Java)。

类型 1 驱动程序提供了 ODBC 和 JBDC 之间的桥梁。如果应用程序必须使用 ODBC 与数据库通信,则必须使用此驱动程序。类型 1 驱动程序通常性能较差;只有在必须通过 ODBC 协议与传统数据库通信时才会选择它。

类型 3 驱动程序与类型 4 驱动程序一样,完全由 Java 编写,但它们设计用于一种特定架构,其中一个中间件(有时,尽管通常不是应用服务器)提供中介翻译。在此架构中,一个 JDBC 客户端(通常是一个独立程序,尽管理论上可以是应用服务器)向中间件发送 JDBC 协议,中间件将请求翻译成数据库特定协议,并将请求转发到数据库(并对响应执行反向翻译)。

在某些情况下,这种架构是必需的:中间件可以位于网络的去军事化区域(DMZ),并为与数据库的连接提供额外的安全性。从性能的角度来看,存在潜在的优势和劣势。中间件可以自由缓存数据库信息,从而减轻数据库的负担(使其更快),并更早地将数据返回给客户端(减少请求的延迟)。但是,如果没有进行缓存,性能将会受到影响,因为现在需要执行两个往返网络请求来执行数据库操作。在理想情况下,这些将会达到平衡(或者缓存将更快)。

但实际情况是,这种架构实际上并没有被广泛采纳。通常更容易将服务器本身放在中间层(包括需要时放在 DMZ 中)。服务器然后可以执行数据库操作,但无需为客户端提供 JDBC 接口:最好是提供 servlet 接口、Web 服务接口等,将客户端与数据库的任何知识隔离开来。

这留下了 Type 2 和 Type 4 驱动器,两者都非常受欢迎,也没有一个在性能上有固有的优势。

Type 2 驱动器使用本地库访问数据库。这些驱动器在某些数据库供应商中很受欢迎,因为它们允许 Java 驱动器利用多年来用于编写其他程序访问数据库的 C 库的工作。由于依赖本地库,部署更加困难:数据库供应商必须为驱动器提供特定平台的本地库,并且 Java 应用程序必须设置环境变量来使用该库。尽管如此,考虑到供应商已经投入到 C 库的工作,Type 2 驱动器往往表现非常出色。

Type 4 驱动器是纯 Java 驱动器,实现了数据库供应商为访问其数据库定义的协议。因为它们完全由 Java 编写,所以部署非常简单:应用程序只需将一个 JAR 文件添加到其类路径中。Type 4 驱动器通常与 type 2 驱动器一样性能良好,因为它们都使用相同的协议。从供应商的角度来看,Type 4 驱动器可能是额外的编码工作,但从用户的角度来看,它们通常是最容易使用的。

不要将驱动程序类型(2 或 4)与前面讨论的驱动程序被视为厚或薄混为一谈。确实,Type 2 驱动器倾向于较厚,而 Type 4 驱动器倾向于较薄,但这不是必须的。最终,Type 2 或 Type 4 驱动器哪个更好取决于环境和具体的驱动程序。没有一种先验方法可以知道哪种表现更好。

快速总结

  • 花些时间评估应用程序最适合的 JDBC 驱动器。

  • 最佳驱动程序通常会根据特定的部署情况而异。同一应用程序可能在一个部署中使用一种 JDBC 驱动程序,在另一个部署中使用不同的 JDBC 驱动程序。

  • 如果可以选择,应避免使用 ODBC 和类型 1 的 JDBC 驱动程序。

JDBC 连接池

数据库连接的建立非常耗时,因此在 Java 中,JDBC 连接是另一个应该重复使用的典型对象。

在大多数服务器环境中,所有 JDBC 连接都来自服务器的连接池。在具有 JPA 的 Java SE 环境中,大多数 JPA 提供程序将透明地使用连接池,并且您可以在persistence.xml文件中配置连接池。在独立的 Java SE 环境中,连接必须由应用程序管理。为了处理最后一种情况,可以使用许多来源提供的几个连接池库之一。但通常情况下,对于独立应用程序中的每个线程,更容易创建一个连接并将其存储在线程本地变量中。

通常情况下,重要的是在池化对象占用的内存和池化操作会触发的额外 GC 量之间达到正确的平衡。这一点尤为重要,因为我们将在下一节中检查的准备语句缓存存在。

在这种情况下,达到正确的平衡也适用于数据库。每个连接到数据库都需要数据库资源(除了应用程序中持有的内存)。随着连接被添加到数据库中,数据库需要更多资源:它将为 JDBC 驱动程序使用的每个准备语句分配额外的内存。如果应用程序服务器有太多打开的连接,可能会对数据库性能造成不利影响。

连接池的一般经验法则是每个应用程序线程一个连接。在服务器上,首先将线程池的大小和连接池的大小设置为相同。在独立应用程序中,根据应用程序创建的线程数量设置连接池的大小。通常情况下,这将提供最佳性能:程序中的任何线程都不必等待数据库连接可用,而且数据库通常有足够的资源来处理应用程序施加的负载。

然而,如果数据库成为瓶颈,这个规则可能会适得其反。连接过多到一个容量不足的数据库,是将负载注入繁忙系统降低性能的另一个例子。在这种情况下,使用连接池来控制发送到容量不足数据库的工作量是提高性能的方法。应用程序线程可能需要等待空闲连接,但如果不过度负担数据库,系统的总吞吐量将会最大化。

快速摘要

  • 连接是昂贵的初始化对象;它们在 Java 中经常被池化——要么在 JDBC 驱动程序本身内,要么在 JPA 和其他框架内。

  • 与其他对象池一样,调整连接池以避免对垃圾收集器产生不利影响非常重要。在这种情况下,还需要调整连接池以避免对数据库性能产生不利影响。

预处理语句和语句池

在大多数情况下,代码应该使用PreparedStatement而不是Statement进行 JDBC 调用。这有助于性能:预处理语句允许数据库重复使用正在执行的 SQL 的信息。这样可以节省数据库在后续执行预处理语句时的工作量。预处理语句还具有安全性和编程优势,特别是在指定调用参数时。

这里的关键词是重用:预处理语句的第一次使用需要更多时间来执行,因为它必须设置和保存信息。如果语句仅被使用一次,那么这些工作将被浪费;在这种情况下最好使用常规语句。

当只有少量数据库调用时,Statement接口将使应用程序更快完成。但即使是面向批处理的程序也可能对同几条 SQL 语句进行数百或数千次的 JDBC 调用;本章的后续示例将使用批处理程序将其 400,896 条记录加载到数据库中。具有许多 JDBC 调用的批处理程序——以及将服务于许多请求的服务器——最好使用PreparedStatement接口(数据库框架将自动执行此操作)。

当预处理语句对象被重复使用时,它们提供性能优势——即池化时。为了正确池化,必须考虑两件事:JDBC 连接池和 JDBC 驱动程序配置。² 这些配置选项适用于任何直接或通过框架使用 JDBC 的程序。

设置语句池

准备好的语句池是基于每个连接操作的。如果程序中的一个线程从池中取出一个 JDBC 连接,并在该连接上使用一个准备好的语句,那么与该语句相关的信息仅对该连接有效。使用第二个连接的第二个线程最终将建立第二个语句池的实例。最终,每个连接对象将在应用程序中拥有自己的所有准备好的语句的池(假设它们在应用程序的生命周期内都被使用)。

这也是独立的 JDBC 应用程序应该使用连接池的一个原因。这也意味着连接池的大小对 JDBC 和 JPA 程序都很重要。特别是在程序的早期执行阶段,当尚未使用特定准备好的语句的连接被使用时,第一个请求会稍微慢一些。

连接池的大小也很重要,因为它正在缓存那些准备好的语句,这些语句占用堆空间(通常是大量的堆空间)。在这种情况下,对象重用当然是一件好事,但你必须意识到这些可重用对象占用了多少空间,并确保它不会对 GC 时间产生负面影响。

管理语句池

关于准备好的语句池的第二个考虑是哪段代码实际上将创建和管理该池。这是通过使用ConnectionPoolDataSource类的setMaxStatements()方法来启用或禁用语句池来完成的。如果传递给setMaxStatements()方法的值为 0,则禁用语句池。该接口明确不定义语句池应该发生在何处——无论是在 JDBC 驱动程序中还是在另一层,比如应用服务器中。对于某些需要额外配置的 JDBC 驱动程序来说,这单一接口是不够的。

因此,在编写直接使用 JDBC 调用的 Java SE 应用程序时,我们有两个选择:要么配置 JDBC 驱动程序以创建和管理语句池,要么在应用程序代码中创建和管理池。在使用框架时,语句池通常由框架管理。

棘手的问题在于在这个领域中不存在标准。一些 JDBC 驱动程序根本不提供池语句的机制;它们期望仅在执行语句池的应用服务器中使用,并且希望提供一个更简单的驱动程序。一些应用服务器不提供和管理池;它们期望 JDBC 驱动程序处理该任务,并且不希望复杂化其代码。这两种观点都有其优点(尽管一个不提供语句池的 JDBC 驱动程序会给独立应用程序的开发者带来负担)。最终,你将不得不筛选这个景观,并确保语句池在某处被创建。

由于没有标准,您可能会遇到这样一种情况:JDBC 驱动程序和数据层框架都能够管理准备好的语句池。在这种情况下,重要的是只配置其中一个来执行此操作。从性能的角度来看,更好的选择将再次取决于驱动程序和服务器的确切组合。作为一般规则,您可以期望 JDBC 驱动程序执行更好的语句池。由于驱动程序(通常)是针对特定数据库的,因此可以预期它会为该数据库进行更好的优化,而不像更通用的应用服务器代码那样。

要为特定的 JDBC 驱动程序启用语句池(或缓存),请参阅该驱动程序的文档。在许多情况下,您只需设置驱动程序,以便 maxStatements 属性设置为所需的值(即语句池的大小)。其他驱动程序可能需要其他设置:例如,Oracle JDBC 驱动程序要求设置特定属性以告知其是否使用隐式或显式语句缓存,并且 MySQL 驱动程序要求您设置一个属性以启用语句缓存。

快速总结

  • Java 应用程序通常会重复执行相同的 SQL 语句。在这些情况下,重用准备好的语句将显著提高性能。

  • 准备好的语句必须基于每个连接进行池化。大多数 JDBC 驱动程序和数据框架可以自动执行此操作。

  • 准备好的语句可能会消耗大量堆内存。必须仔细调整语句池的大小,以防止过多的非常大的对象引发 GC 问题。

事务

应用程序具有最终决定如何处理事务的正确性要求。需要可重复读语义的事务将比仅需要读取已提交语义的事务慢,但是对于不能容忍非可重复读的应用程序来说,了解这一点并没有太大的实际好处。因此,虽然本节讨论了如何为应用程序使用最不具侵入性的隔离语义,但是不要让速度的愿望克服应用程序的正确性。

数据库事务有两个性能惩罚。首先,数据库设置和提交事务需要时间。这涉及确保对数据库的更改完全存储在磁盘上,数据库事务日志是一致的等等。其次,在数据库事务期间,常常会为特定数据集(不一定是行,但我将在此处使用它作为示例)获取锁。如果两个事务竞争对同一数据库行的锁定,应用程序的可扩展性将会受到影响。从 Java 的角度来看,这与第九章中关于有争议和无争议锁定的讨论完全类似。

为了实现最佳性能,考虑这两个问题:如何编写事务使得事务本身高效,以及如何在事务期间在数据库上持有锁,以便整个应用程序可以扩展。

JDBC 事务控制

JDBC 和 JPA 应用程序中均存在事务,但 JPA 以不同的方式管理事务(这些细节将在本章后面讨论)。对于 JDBC,事务的开始和结束取决于如何使用 Connection 对象。

在基本的 JDBC 使用中,连接具有自动提交模式(通过setAutoCommit()方法设置)。如果自动提交被打开(对于大多数 JDBC 驱动程序来说,这是默认的),JDBC 程序中的每个语句都是其自身的事务。在这种情况下,程序不需要执行任何操作来提交事务(事实上,如果调用commit()方法,性能通常会受到影响)。

如果关闭了自动提交,当连接对象的第一次调用(例如通过调用executeQuery()方法)时,事务会隐式开始。事务将持续到调用commit()方法(或rollback()方法)。在下次数据库调用时,连接将开始新的事务。

提交事务的成本很高,因此一个目标是尽可能多地在一个事务中执行工作。不幸的是,这个原则与另一个目标完全对立:因为事务可能持有锁,所以事务应该尽可能短暂。这里肯定存在一个平衡点,而找到这个平衡将取决于应用程序及其锁定需求。下一节将详细讨论事务隔离和锁定,首先让我们看看优化事务处理本身的选项。

考虑一些插入数据到数据库供股票应用程序使用的示例代码。对于每天的有效数据,必须向 STOCKPRICE 表插入一行数据,并向 STOCKOPTIONPRICE 表插入五行数据。一个完成这些操作的基本循环如下所示:

try (Connection c = DriverManager.getConnection(URL, p)) {
    try (PreparedStatement ps = c.prepareStatement(insertStockSQL);
         PreparedStatement ps2 = c.prepareStatement(insertOptionSQL)) {
        for (StockPrice sp : stockPrices) {
            String symbol = sp.getSymbol();
            ps.clearParameters();
            ps.setBigDecimal(1, sp.getClosingPrice());
	    ... set other parameters ...
            ps.executeUpdate();
            for (int j = 0; j < 5; j++) {
                ps2.clearParameters();
                ps2.setBigDecimal(1,
                    sp.getClosingPrice().multiply(
                        new BigDecimal(1 + j / 100.)));
                ... set other parameters ...
                ps2.executeUpdate();
            }
        }
    }
}

在完整的代码中,价格预先计算到stockPrices数组中。如果该数组表示 2019 年的数据,这个循环将通过第一次调用executeUpdate()方法向 STOCKPRICE 表中插入 261 行,并通过for循环向 STOCKOPTIONPRICE 表中插入 1,305 行。在默认的自动提交模式下,这意味着 1,566 个单独的事务,这将是非常昂贵的。

如果禁用自动提交模式并在循环结束时执行显式提交,将实现更好的性能:

try (Connection c = DriverManager.getConnection(URL, p)) {
    c.setAutoCommit(false);
    try (PreparedStatement ps = c.prepareStatement(insertStockSQL);
         PreparedStatement ps2 = c.prepareStatement(insertOptionSQL)) {
        ... same code as before ....
    }
    c.commit();
}

从逻辑上讲,这也可能是有道理的:数据库最终将具有整年的数据或者没有数据。

如果此循环为多个股票重复进行,则可以选择一次性提交所有数据或一次性提交一个符号的所有数据:

try (Connection c = DriverManager.getConnection(URL, p)) {
    c.setAutoCommit(false);
    String lastSymbol = null;
    try (PreparedStatement ps = c.prepareStatement(insertStockSQL);
         PreparedStatement ps2 = c.prepareStatement(insertOptionSQL)) {
        for (StockPrice sp : stockPrices) {
	    String symbol = sp.getSymbol();
	    if (lastSymbol != null && !symbol.equals(lastSymbol)) {
		// We are processing a new symbol; commit the previous symbol
	        c.commit();
	    }
	}
    }
    c.commit();
}

将所有数据一次性提交提供了最快的性能。然而,在这个例子中,应用程序语义可能要求每年的数据都单独提交。有时,其他要求可能会干扰到尝试获得最佳性能的努力。

在前述代码中每次执行 executeUpdate() 方法时,都会向数据库发出远程调用并执行相应的工作。此外,在进行更新时会发生锁定(至少可以确保另一个事务不能在相同的符号和日期上插入记录)。在这种情况下,通过批量插入可以进一步优化事务处理。当插入被批处理时,JDBC 驱动程序会将它们保持,直到批次完成;然后所有语句一起通过远程 JDBC 调用传输。

这里是批处理的实现方式:

try (Connection c = DriverManager.getConnection(URL, p)) {
    try (PreparedStatement ps = c.prepareStatement(insertStockSQL);
         PreparedStatement ps2 = c.prepareStatement(insertOptionSQL)) {
        for (StockPrice sp : stockPrices) {
            String symbol = sp.getSymbol();
            ps.clearParameters();
            ps.setBigDecimal(1, sp.getClosingPrice());
	    ... set other parameters ...
            ps.addBatch();
            for (int j = 0; j < 5; j++) {
                ps2.clearParameters();
                ps2.setBigDecimal(1,
                    sp.getClosingPrice().multiply(
                        new BigDecimal(1 + j / 100.)));
                ... set other parameters ...
                ps2.addBatch();
            }
        }
	ps.executeBatch();
	ps2.executeBatch();
    }
}

代码同样可以选择按每支股票的方式执行每个批处理(类似于我们在每个股票符号更改后提交的方式)。某些 JDBC 驱动程序对它们可以批处理的语句数量有限制(并且批次在应用程序中会消耗内存),因此即使在整个操作结束时数据被提交,批次可能仍然需要更频繁地执行。

这些优化可以大幅提升性能。表 11-1 显示了为 256 支股票插入一年数据所需的时间(共计 400,896 次插入)。

表 11-1. 插入 256 支股票数据所需的秒数

编程模式 所需时间 数据库调用 数据库提交
启用自动提交,无批处理 537 ± 2 秒 400,896 400,896
每支股票 1 提交 57 ± 4 秒 400,896 256
所有数据 1 批次/1 提交 56 ± 14 秒 400,448 1
每支股票 1 批次/每提交 1 次 4.6 ± 2 秒 256 256
每支股票 1 批次/1 提交 3.9 ± 0.7 秒 256 1
全部数据 1 批次/1 提交 3.8 ± 1 秒 1 1

这张表中有一个有趣的事实并不立即显而易见:第 1 行和第 2 行的区别在于自动提交已关闭,并且代码在每个 while 循环结束时显式调用了 commit() 方法。第 1 行和第 4 行之间的区别在于语句被批量处理,但是自动提交仍然是启用状态。一个批次被视为一个事务,这就是为什么数据库调用和提交之间存在一对一的对应关系(并且消除超过 400,000 次调用可以带来显著的加速)。

值得注意的是,提交数据的调用次数从 400,896 次到 256 次之间的差异是数量级的差别,但是在 1 次和 256 次提交之间的差异并不显著(即第 2 行和第 3 行之间的差异,或第 5 行和第 6 行之间的差异)。提交数据的速度足够快,以至于当调用次数为 256 次时,额外开销只是噪音;但当调用次数达到 400,896 次时,这些开销将累积起来。³

事务隔离和锁定

影响事务性能的第二个因素涉及数据库的可伸缩性,因为事务内的数据被锁定。锁定保护数据完整性;在数据库术语中,它允许一个事务与其他事务隔离。JDBC 和 JPA 支持数据库的四种主要事务隔离模式,尽管它们在实现方式上有所不同。

隔离模式在此简要介绍,尽管正确选择隔离模式并非 Java 特定问题,建议查阅数据库编程书籍以获取更多信息。

这里是基本的事务隔离模式(按从昂贵到廉价的顺序):

TRANSACTION_SERIALIZABLE

这是最昂贵的事务模式;它要求事务期间访问的所有数据都被锁定。这适用于通过主键访问的数据和通过WHERE子句访问的数据——当存在WHERE子句时,表被锁定,以使不能添加满足子句的新记录至事务结束。序列化事务每次发出查询时都将看到相同的数据。

TRANSACTION_REPEATABLE_READ

这要求事务期间所有访问的数据都被锁定。然而,其他事务可以随时向表中插入新行。这种模式可能导致幻读:重新执行带有WHERE子句的查询时,事务可能会获得不同的数据。

TRANSACTION_READ_COMMITTED

此模式仅锁定事务期间写入的行。这导致不可重复读:事务中某一时间点读取的数据可能与事务中另一时间点读取的数据不同。

TRANSACTION_READ_UNCOMMITTED

这是最经济的事务模式。不涉及锁定,因此一个事务可以读取另一个事务中已写入但未提交的数据。这称为脏读;问题在于第一个事务可能会回滚(意味着写入实际上没有发生),因此第二个事务操作的是不正确的数据。

数据库以事务隔离的默认模式运行:MySQL 默认使用TRANSACTION_REPEATABLE_READ;Oracle 和 IBM Db2 默认使用TRANSACTION_READ_COMMITTED;等等。这里有很多特定于数据库的变体。Db2 将其默认事务模式称为CS(代表游标稳定性),并为其他三种 JDBC 模式使用了不同的名称。Oracle 不支持TRANSACTION_READ_UNCOMMITTEDTRANSACTION_REPEATABLE_READ这两种模式。

当执行 JDBC 语句时,它使用数据库的默认隔离模式。另外,可以调用 JDBC 连接上的 setTransaction() 方法来让数据库提供必要的事务隔离级别(如果数据库不支持给定级别,则 JDBC 驱动程序将抛出异常或者悄悄地将隔离级别升级为它支持的下一个最严格的级别)。

对于简单的 JDBC 程序,这就足够了。更常见的做法是——尤其是在与 JPA 结合使用时——程序可能需要在事务中混合使用数据的隔离级别。在一个查询我的员工信息以便最终给我加薪的应用程序中,必须保护对我的员工记录的访问:该数据需要被视为 TRANSACTION_REPEATABLE_READ。但该事务还可能访问其他表中的数据,比如保存我的办公室 ID 的表。在事务期间锁定那些数据没有真正的理由,因此对该行的访问肯定可以作为 TRANSACTION_READ_COMMITTED 运行(甚至可能更低)。

JPA 允许您按照每个实体的基础指定锁定级别(当然,实体至少通常是数据库中的一行)。因为正确设置这些锁定级别可能很困难,所以使用 JPA 比在 JDBC 语句中执行锁定要容易得多。尽管如此,在 JDBC 应用程序中使用不同的锁定级别是可能的,它使用与 JPA 相同的悲观和乐观锁定语义(如果您不熟悉这些语义,这个例子应该是一个很好的介绍)。

在 JDBC 层面,基本的方法是将连接的隔离级别设置为 TRANSACTION_READ_UNCOMMITTED,然后仅在事务期间显式锁定需要锁定的数据:

try (Connection c = DriverManager.getConnection(URL, p)) {
    c.setAutoCommit(false);
    c.setTransactionIsolation(TRANSACTION_READ_UNCOMMITTED);
    try (PreparedStatement ps1 = c.prepareStatement(
        "SELECT * FROM employee WHERE e_id = ? FOR UPDATE")) {
        ... process info from ps1 ...
    }
    try (PreparedStatement ps2 = c.prepareStatement(
           "SELECT * FROM office WHERE office_id = ?")) {
        ... process info from ps2 ...
    }
    c.commit();
}

ps1 语句在员工数据表上建立了显式锁定:在此事务期间,其他事务将无法访问该行。实现这一目标的 SQL 语法是非标准的。您必须查阅数据库供应商的文档,了解如何实现所需的锁定级别,但常见的语法是包含 FOR UPDATE 子句。这种类型的锁定称为 悲观锁定。它积极地阻止其他事务访问相关数据。

通过使用乐观锁定,锁定性能通常可以得到改善。如果数据访问没有竞争,这将是一个显著的性能提升。然而,如果数据甚至有轻微的竞争,编程会变得更加困难。

在数据库中,乐观并发是通过版本列实现的。当从行中选择数据时,选择必须包括所需数据以及版本列。要选择关于我的信息,我可以执行以下 SQL:

SELECT first_name, last_name, version FROM employee WHERE e_id = 5058;

这个查询将返回我的姓名(斯科特和奥克斯),以及当前版本号(比如,1012)。当完成交易时,事务会更新版本列:

UPDATE employee SET version = 1013 WHERE e_id = 5058 AND version = 1012;

如果涉及的行需要可重复读或串行化语义,即使在事务期间仅读取数据,也必须执行此更新——这些隔离级别要求锁定事务中使用的只读数据。对于读提交语义,只有在行中的其他数据也更新时,才需要更新版本列。

根据此方案,如果两个事务同时使用我的员工记录,每个事务将读取版本号 1012。首个完成的事务将成功将版本号更新为 1013 并继续。第二个事务将无法更新员工记录——不再存在版本号为 1012 的记录,因此 SQL 更新语句将失败。该事务将收到异常并回滚。

这突显了数据库中乐观锁定与 Java 原子基元之间的主要区别:在数据库编程中,当事务收到异常时,不能透明地重试。如果您直接编程到 JDBC,则commit()方法将获得SQLException;在 JPA 中,当事务提交时,应用程序将获得OptimisticLockException

根据您的角度不同,这或许是好事,也或许是坏事。在第九章中,我们讨论了使用 CAS 基础特性的原子实用程序的性能,以避免显式同步。这些实用程序基本上使用乐观并发性,具有无限的自动重试。在高度竞争的情况下,性能会受到影响,因为大量重试会消耗大量 CPU 资源,尽管在实践中这通常不是问题。在数据库中,情况要严重得多,因为在事务中执行的代码比简单增加内存位置中的值要复杂得多。在数据库中重试失败的乐观事务可能导致无休止的重试螺旋,这种潜力要远远大于在内存中的情况。此外,自动确定要重试的操作(或操作)通常是不可行的。

因此,不透明地重试是一件好事(而且通常是唯一可能的解决方案),但另一方面,这意味着应用现在负责处理异常。应用程序可以选择重试事务(也许只重试一次或两次),可以选择提示用户获取不同的数据,或者可以简单地通知用户操作失败。没有一种适合所有情况的答案。

乐观锁定在两个源之间几乎不会发生碰撞时效果最佳。想象一个联合支票账户:我丈夫和我在同一时间可能在城市的不同地方从我们的支票账户中取款,有一点点可能性会导致乐观锁定异常。即使确实发生了这种情况,要求其中一个再试一次也不会太繁重,现在乐观锁定异常的几率几乎为零(或者我希望如此;我们不要讨论我们多频繁地进行 ATM 取款)。将这种情况与涉及样本股票应用程序的情况进行对比。在现实世界中,这些数据更新频率如此之高,以至于乐观锁定将是适得其反的。事实上,由于变化量巨大,实际股票应用程序在可能的情况下经常不使用锁定,尽管实际交易更新可能需要一些锁定。

快速总结

  • 事务以两种方式影响应用程序的速度:事务提交的成本很高,并且与事务相关的锁定可能阻止数据库扩展。

  • 这两个效果是对立的:等待提交事务的时间过长会增加持有事务相关锁定的时间。特别是对于使用更严格语义的事务,应该更倾向于更频繁地提交而不是保持锁定更长时间。

  • 为了在 JDBC 中对事务进行精细控制,使用默认的 TRANSACTION_READ_UNCOMMITTED 级别,并根据需要显式锁定数据。

结果集处理

典型的数据库应用程序将处理一系列数据。例如,股票应用程序处理个股的价格历史。该历史通过单个 SELECT 语句加载:

SELECT * FROM stockprice WHERE symbol = 'TPKS' AND
	pricedate >= '2019-01-01' AND pricedate <= '2019-12-31';

该语句返回了 261 行数据。如果还需要股票的期权价格,则执行类似的查询,将检索到五倍数量的数据。检索示例数据库中所有数据(涵盖一年的 256 只股票)的 SQL 将检索到 400,896 行数据:

SELECT * FROM stockprice s, stockoptionprice o WHERE
	o.symbol = s.symbol AND s.pricedate >= '2019-01-01'
	AND s.pricedate <= '2019-12-31';

要使用这些数据,代码必须滚动浏览结果集:

try (PreparedStatement ps = c.prepareStatement(...)) {
    try (ResultSet rs = ps.executeQuery()) {
        while (rs.next()) {
            ... read the current row ...
        }
    }
}

这里的问题是 400,896 行数据的数据存放在哪里。如果在 executeQuery() 调用期间返回整个数据集,应用程序将在其堆中拥有非常大的活动数据块,可能导致 GC 和其他问题。相反,如果从 next() 方法调用返回了一行数据,则应用程序与数据库之间将发生大量来回的流量,因为结果集正在处理中。

通常情况下,这里没有正确的答案;在某些情况下,将大部分数据保留在数据库中,并根据需要检索数据可能更有效率,而在其他情况下,当执行查询时一次性加载所有数据可能更有效率。为了控制这一点,在PreparedStatement对象上使用setFetchSize()方法,让 JDBC 驱动程序知道每次应传输多少行数据。

这个默认值因 JDBC 驱动程序而异;例如,在 Oracle 的 JDBC 驱动程序中,默认值为 10。当在先前显示的循环中调用executeQuery()方法时,数据库将返回 10 行数据,并由 JDBC 驱动程序在内部缓冲。前 10 次调用next()方法将处理这些缓冲行中的一行。第 11 次调用将返回数据库以检索另外 10 行数据,依此类推。

虽然值会有所不同,但是 JDBC 驱动程序通常会将默认的 fetch size 设置为一个相当小的数值。这种方法在大多数情况下是合理的;特别是在应用程序内部不太可能导致任何内存问题时更为合理。如果next()方法的性能(或结果集上第一个 getter 方法的性能)偶尔特别慢,请考虑增加 fetch size。

快速总结

  • 处理查询中大量数据的应用程序应考虑更改数据的 fetch size。

  • 在应用程序中加载过多数据(增加垃圾收集器的压力)与频繁地从数据库检索一组数据之间存在权衡。

JPA

JPA 的性能直接受底层 JDBC 驱动程序性能的影响,大多数关于 JDBC 驱动程序的性能考虑也适用于 JPA。JPA 还有额外的性能考虑。

JPA 通过修改实体类的字节码实现了许多性能增强。在大多数服务器框架中,这是透明进行的。在 Java SE 环境中,确保字节码处理设置正确非常重要。否则,JPA 应用程序的性能将无法预测:预期按需加载的字段可能被急切加载,保存到数据库的数据可能是冗余的,应该在 JPA 缓存中的数据可能需要重新从数据库获取,等等。

没有 JPA 定义的方法来处理字节码。通常,这是作为编译的一部分来完成——在实体类编译完成之后(并在加载到 JAR 文件或 JVM 运行之前),它们会通过一个实现特定的后处理器“增强”字节码,生成一个带有所需优化的改变后的类文件。例如,Hibernate 通过 Maven 或 Gradle 插件在编译过程中完成此操作。

一些 JPA 实现还提供了一种在类加载到 JVM 时动态增强字节码的方法。这需要在 JVM 中运行一个代理,当类加载时被通知;代理介入类加载并在类被用于定义之前修改字节。代理在应用程序的命令行中指定;例如,对于 EclipseLink,你包括 -javaagent:path_to/eclipselink.jar 参数。

优化 JPA 写入

在 JDBC 中,我们研究了两个关键的性能技术:重用准备好的语句和批量执行更新。可以通过 JPA 完成这两种优化,但其实现方式取决于所使用的 JPA 实现;在 JPA API 内部没有调用来完成这些操作。对于 Java SE,这些优化通常需要在应用程序的 persistence.xml 文件中设置特定的属性。

例如,使用 JPA EclipseLink 参考实现,可以通过在 persistence.xml 文件中添加以下属性来启用语句重用:

      <property name="eclipselink.jdbc.cache-statements" value="true" />

注意,这可以在 EclipseLink 实现中实现语句重用。如果 JDBC 驱动能够提供语句池,则通常更倾向于在驱动程序中启用语句缓存,并且不在 JPA 配置中设置此属性。

JPA 参考实现中的语句批处理是通过添加以下属性实现的:

      <property name="eclipselink.jdbc.batch-writing" value="JDBC" />
      <property name="eclipselink.jdbc.batch-writing.size" value="10000" />

JDBC 驱动程序无法自动实现语句批处理,因此在所有情况下设置此属性非常有用。批处理大小可以通过两种方式控制:首先,可以设置 size 属性,就像本例中所做的那样。其次,应用程序可以周期性地调用实体管理器的 flush() 方法,这将导致所有批处理语句立即执行。

表 11-2 显示了语句重用和批处理对创建和写入数据库的股票实体的影响。

表 11-2. 通过 JPA 插入 256 只股票所需的秒数

编程模式 所需时间
无批处理,无语句池 83 ± 3 秒
无批处理,语句池 64 ± 5 秒
批处理,无语句池 10 ± 0.4 秒
批处理,语句池 10 ± 0.3 秒

快速总结

  • JPA 应用程序和 JDBC 应用程序一样,可以通过限制向数据库的写入调用次数来获益(可能需要权衡事务锁的持有)。

  • 语句缓存可以在 JPA 层或 JDBC 层实现。首先应该先探索在 JDBC 层进行缓存。

  • 批处理 JPA 更新可以通过声明方式(在 persistence.xml 文件中)或者通过调用 flush() 方法来编程实现。

优化 JPA 读取

优化 JPA 从数据库中读取数据的时间和方式比看起来要复杂得多,因为 JPA 将缓存数据,希望可以用来满足将来的请求。这通常对性能是一个好事,但这意味着 JPA 生成的用于读取数据的 SQL 看起来可能并不是最优的。数据检索被优化以满足 JPA 缓存的需求,而不是为了正在进行的特定请求而优化。

缓存的详细信息将在下一节中介绍。现在,让我们看一下如何将数据库读取优化应用于 JPA 的基本方法。JPA 从数据库中读取数据有三种情况:当调用EntityManagerfind()方法时,当执行 JPA 查询时,以及当代码使用现有实体的关系导航到新实体时。在股票类中,后一种情况意味着在Stock实体上调用getOptions()方法。

调用find()方法是最简单的情况:只涉及单行数据,并且(至少)从数据库中读取了该单行数据。唯一可以控制的是检索的数据量。JPA 可以仅检索行中的一些字段,也可以检索整行,或者可以预提取与正在检索的行相关的其他实体。这些优化同样适用于查询。

有两种可能的路径可供选择:读取更少的数据(因为数据不会被需要)或一次性读取更多数据(因为将来绝对需要该数据)。

读取更少的数据

要读取更少的数据,请指定所讨论的字段是按需加载的。当检索实体时,带有延迟注解的字段将被排除在用于加载数据的 SQL 之外。如果该字段的 getter 被执行,这将意味着另一次访问数据库以检索该数据片段。

很少使用该注解来简单列出基本类型的简单列,但是如果实体包含基于大型 BLOB 或 CLOB 的对象,请考虑使用它:

@Lob
@Column(name = "IMAGEDATA")
@Basic(fetch = FetchType.LAZY)
private byte[] imageData;

在这种情况下,实体映射到存储二进制图像数据的表中。二进制数据很大,本例假设除非需要,否则不应加载。在这种情况下不加载不必要的数据有两个目的:当检索实体时,这样做可以使 SQL 更快,并且节省大量内存,从而减少 GC 压力。

还要注意,延迟注解最终只是对 JPA 实现的提示。JPA 实现可以自由地要求数据库急切地提供该数据。

另一方面,也许应该预加载其他数据,例如,当获取一个实体时,应该返回其他(相关的)实体的数据。这称为急切加载,并且具有类似的注解:

@OneToMany(mappedBy="stock", fetch=FetchType.EAGER)
private Collection<StockOptionPriceImpl> optionsPrices;

默认情况下,如果关系类型是@OneToOne@ManyToOne(因此可以对它们应用相反的优化:如果它们几乎从不使用,则标记它们为FetchType.LAZY),则相关实体已经被急切地获取。

这也只是对 JPA 实现的一个提示,但它基本上表示每次检索股票价格时,确保还检索所有相关的期权价格。在这里要注意:关于急切关系获取的一个常见期望是它将在生成的 SQL 中使用JOIN。在典型的 JPA 提供程序中,情况并非如此:它们将发出一个 SQL 查询来获取主对象,然后发出一个或多个 SQL 命令来获取任何其他相关对象。从一个简单的find()方法中,对此没有控制:如果需要一个JOIN语句,您将不得不使用一个查询并将JOIN编程到查询中。

在查询中使用 JOIN

JPA 查询语言(JPQL)不允许您指定要检索的对象的字段。接下来看一个 JPQL 查询:

Query q = em.createQuery("SELECT s FROM StockPriceImpl s");

那个查询将始终产生这个 SQL 语句:

SELECT <enumerated list of non-LAZY fields> FROM StockPriceTable

如果要在生成的 SQL 中检索较少的字段,您除了将它们标记为 lazy 之外别无选择。同样,对于标记为 lazy 的字段,在查询中获取它们也没有真正的选择。

如果实体之间存在关系,则可以在 JPQL 查询中显式加入实体,这将一次检索初始实体及其相关实体。例如,在股票实体中,可以发出以下查询:

Query q = em.createQuery("SELECT s FROM StockOptionImpl s " +
			 "JOIN FETCH s.optionsPrices");

这将导致类似于这样的 SQL 语句:

SELECT t1.<fields>, t0.<fields> FROM StockOptionPrice t0, StockPrice t1
WHERE ((t0.SYMBOL = t1.SYMBOL) AND (t0.PRICEDATE = t1.PRICEDATE))

在 JPA 提供程序之间确切的 SQL 将有所不同(此示例来自 EclipseLink),但这是一般过程。

无论实体关系是否被注释为急切或懒惰,联接获取都是有效的。如果在懒惰关系上发出连接,则仍然会从数据库中检索满足查询的懒惰注释实体,如果稍后使用这些实体,则不需要额外的数据库访问。

当使用 join fetch 的查询返回的所有数据都将被使用时,join fetch 通常会显著提高性能。但是,join fetch 也会以意想不到的方式与 JPA 缓存交互。示例在“JPA 缓存”中显示;在编写使用 join fetch 的自定义查询之前,请确保您了解这些影响。

批处理和查询

JPA 查询被处理为像 JDBC 查询一样产生一个结果集:JPA 实现可以选择一次性获取所有结果,当应用程序遍历查询结果时一次获取一个结果,或者一次获取少量结果(类似于 JDBC 中的抓取大小工作原理)。

没有标准方法来控制这一点,但 JPA 供应商有专有的机制来设置抓取大小。在 EclipseLink 中,查询上的一个提示指定了抓取大小:

q.setHint("eclipselink.JDBC_FETCH_SIZE", "100000");

Hibernate 提供了一个定制的@BatchSize注解。

如果正在处理非常大的数据集,则代码可能需要浏览查询返回的列表。这与数据在网页上如何显示有自然的关系:显示数据的子集(例如 100 行),以及用于浏览数据的上一页和下一页链接(页面)。

这是通过在查询上设置一个范围来实现的:

Query q = em.createNamedQuery("selectAll");
query.setFirstResult(101);
query.setMaxResults(100);
List<? implements StockPrice>  = q.getResultList();

这返回一个适合在 Web 应用程序的第二页上显示的列表:项目 101-200. 仅检索所需数据范围将比检索 200 行并丢弃前 100 行更有效。

请注意,此示例使用了命名查询(createNamedQuery() 方法),而不是临时查询(createQuery() 方法)。在许多 JPA 实现中,命名查询更快:JPA 实现几乎总是使用带绑定参数的预编译语句,利用语句缓存池。虽然 JPA 实现可以使用类似的逻辑处理未命名的临时查询,但实现起来更加困难,JPA 实现可能简单地默认创建新的语句(即Statement对象)每次执行。

快速总结

  • JPA 可以执行多种优化以限制(或增加)单次操作中读取的数据量。

  • 不经常使用的大字段(例如 BLOBs)应该在 JPA 实体中懒惰地加载。

  • 当 JPA 实体之间存在关系时,相关项的数据可以被急切地或懒惰地加载。选择取决于应用程序的需求。

  • 在急切加载关系时,可以使用命名查询来发出使用 JOIN 语句的单个 SQL 语句。请注意,这会影响 JPA 缓存;这并不总是最佳选择(如下一节所讨论的)。

  • 通过命名查询读取数据通常比普通查询快,因为 JPA 实现更容易使用 PreparedStatement 来处理命名查询。

JPA 缓存

Java 的一个经典与性能相关的用例是为客户端提供一个中间层,该中间层从后端数据库资源缓存数据。Java 中间层执行架构上有用的功能(例如防止客户端直接访问数据库)。从性能角度来看,在 Java 中间层缓存经常使用的数据可以极大地加快响应时间。

JPA 被设计以考虑这种架构。JPA 中存在两种缓存:每个实体管理器实例都是其自己的缓存:它将在事务期间检索的数据本地缓存。它还将在事务期间写入的数据本地缓存;只有在事务提交时才将数据发送到数据库。程序可能有许多实体管理器实例,每个执行不同的事务,并且每个具有自己的本地缓存。(特别是,注入到 Java 服务器中的实体管理器是不同的实例。)

当实体管理器提交事务时,所有本地缓存中的数据可以合并到全局缓存中。全局缓存在应用程序中的所有实体管理器之间共享。全局缓存也称为第二级缓存 (L2 缓存)二级缓存;实体管理器中的缓存称为第一级 (L1)L1 缓存

在实体管理器事务缓存(L1 缓存)中很少有调整空间,并且所有 JPA 实现都启用了 L1 缓存。L2 缓存不同:大多数 JPA 实现提供了 L2 缓存,但并非所有实现都默认启用它(例如,Hibernate 不启用,但 EclipseLink 启用)。一旦启用,调整和使用 L2 缓存的方式可以显著影响性能。

JPA 缓存仅在通过其主键访问的实体上运行,即从 find() 方法调用或访问(或急切加载)相关实体检索的项目。当实体管理器尝试通过其主键或关系映射查找对象时,它可以查看 L2 缓存,并在那里找到对象时返回它们,从而节省了对数据库的访问。

通过查询检索的项目不存储在 L2 缓存中。一些 JPA 实现确实具有特定于供应商的机制来缓存查询的结果,但只有在重新执行完全相同的查询时才重复使用这些结果。即使 JPA 实现支持查询缓存,实体本身也不存储在 L2 缓存中,并且不能在后续调用 find() 方法时返回。

L2 缓存、查询和对象加载之间的连接以多种方式影响性能。为了检查它们,将使用基于以下循环的代码:

EntityManager em = emf.createEntityManager();
Query q = em.createNamedQuery(queryName);
List<StockPrice> l = q.getResultList(); ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-perf-2e/img/1.png)
for (StockPrice sp : l) {
    ... process sp ...
    if (processOptions) {
        Collection<? extends StockOptionPrice> options = sp.getOptions(); ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-perf-2e/img/2.png)
        for (StockOptionPrice sop : options) {
	    ... process sop ...
        }
    }
}
em.close();

1

SQL 调用站点 1

2

SQL 调用站点 2

由于 L2 缓存,第一次执行此循环时将以一种方式执行,而在后续执行中将以另一种(通常更快)方式执行。具体的性能差异取决于查询和实体关系的各种细节。接下来的几个小节详细解释了结果。

此示例中的差异在某些情况下基于不同的 JPA 配置,但也因为某些测试在不遍历 StockStockOptions 类之间的关系时执行。在那些没有遍历关系的测试中,循环中的 processOptions 值为 false;实际上只使用了 StockPrice 对象。

默认缓存(延迟加载)

在示例代码中,股票价格通过命名查询加载。在默认情况下,执行此简单查询以加载股票数据:

@NamedQuery(name="findAll",
    query="SELECT s FROM StockPriceImpl s ORDER BY s.id.symbol")

StockPrice 类与 StockOptionPrice 类具有 @OneToMany 关系,使用 optionsPrices 实例变量:

@OneToMany(mappedBy="stock")
private Collection<StockOptionPrice> optionsPrices;

默认情况下,@OneToMany 关系是惰性加载的。表 11-3 显示了执行此循环所需的时间。

表 11-3. 默认配置下读取 256 只股票数据所需秒数

测试案例 第一次执行 后续执行
惰性关系 22.7 ± 2 秒(66,817 次 SQL 调用) 1.1 ± 0.7 秒(1 次 SQL 调用)
惰性关系,无遍历 2.0 ± 0.3 秒(1 次 SQL 调用) 1.0 ± 0.02 秒(1 次 SQL 调用)

在此场景中第一次执行包含 256 只股票一年数据的示例循环时,JPA 代码在调用 executeQuery() 方法时执行一条 SQL 语句。该语句在代码清单中的 SQL 调用站点 1 执行。

当代码循环遍历股票并访问每个期权价格集合时,JPA 将发出 SQL 语句来检索与特定实体相关联的所有期权(即一次检索一只股票/日期组合的整个集合)。这发生在 SQL 调用站点 2,并导致执行期间发出 66,816 个独立的 SELECT 语句(261 天 × 256 只股票),总共 66,817 次调用。

该示例第一次执行循环几乎需要 23 秒。下次执行相同代码时,只需略多于 1 秒。这是因为第二次执行循环时,唯一执行的 SQL 是命名查询。通过关系检索的实体仍在 L2 缓存中,因此在这种情况下不需要数据库调用。(请记住,L2 缓存仅适用于从关系或查找操作加载的实体。因此,股票期权实体可以在 L2 缓存中找到,但股票价格——因为它们是从查询加载的——不会出现在 L2 缓存中,必须重新加载。)

表 11-3 的第二行表示不访问关系中的每个期权的代码(即 processOptions 变量为 false 的情况)。在这种情况下,代码速度显著加快:第一次循环迭代花费 2 秒,后续迭代只需 1 秒。(这两种情况的性能差异是由编译器的预热期造成的。虽然第一个示例中并不明显,但也发生了预热。)

缓存与急切加载

在接下来的两个实验中,重新定义了股票价格与期权价格之间的关系,以便急切地加载期权价格。

当所有数据都被使用时(即表 11-3 和 11-4 的第一行),急切加载和延迟加载的性能基本相同。但当关系数据实际上没有被使用时(每个表的第二行),延迟加载的情况节省了一些时间——特别是在循环的第一次执行时。由于在后续迭代中急切加载的代码不会重新加载数据,而是从 L2 缓存中加载数据,因此后续执行的循环并不节省时间。

表 11-4. 读取 256 只股票数据所需的时间(急切加载)

测试用例 第一次执行 后续执行
急切关系 23 ± 1.0 秒(66,817 次 SQL 调用) 1.0 ± 0.8 秒(1 次 SQL 调用)
急切关系,无遍历 23 ± 1.3 秒(66,817 次 SQL 调用) 1.0 ± 0.5 秒(1 次 SQL 调用)

加入抓取和缓存

如前一节所讨论的,查询可以编写成显式使用JOIN语句:

@NamedQuery(name="findAll",
    query="SELECT s FROM StockPriceEagerLazyImpl s " +
    "JOIN FETCH s.optionsPrices ORDER BY s.id.symbol")

使用该命名查询(完整遍历)会在 Table 11-5 中给出数据的结果。

表 11-5. 读取 256 只股票数据所需的时间(JOIN查询)

测试用例 第一次执行 后续执行
默认配置 22.7 ± 2 秒(66,817 次 SQL 调用) 1.1 ± 0.7 秒(1 次 SQL 调用)
加入抓取 9.0 ± 0.3 秒(1 次 SQL 调用) 5.6 ± 0.4 秒(1 次 SQL 调用)
带查询缓存的加入抓取 5.8 ± 0.2 秒(1 次 SQL 调用) 0.001 ± 0.0001 秒(0 次 SQL 调用)

第一次执行带有JOIN查询的循环会带来很大的性能优势:仅需 9 秒。这是仅发出一次 SQL 请求而不是 66,817 次的结果。

不幸的是,下次执行代码时仍然需要那个 SQL 语句,因为查询结果不在 L2 缓存中。例如的后续执行需要 5.6 秒——因为执行的 SQL 语句中有 JOIN 语句,并且正在检索超过 400,000 行的数据。

如果 JPA 提供程序实现了查询缓存,则明显是使用它的好时机。如果在代码的第二次执行过程中不需要 SQL 语句,则后续执行仅需 1 毫秒。请注意,查询缓存仅在每次执行查询时使用的参数完全相同时才有效。

避免查询

如果实体从未通过查询检索,所有实体都可以在初始预热期后通过 L2 缓存访问。可以通过加载所有实体来预热 L2 缓存,稍微修改之前的示例代码如下:

EntityManager em = emf.createEntityManager();
ArrayList<String> allSymbols = ... all valid symbols ...;
ArrayList<Date> allDates = ... all valid dates...;
for (String symbol : allSymbols) {
    for (Date date = allDates) {
        StockPrice sp =
            em.find(StockPriceImpl.class, new StockPricePK(symbol, date);
	... process sp ...
        if (processOptions) {
    	    Collection<? extends StockOptionPrice> options = sp.getOptions();
	    ... process options ...
	}
    }
}

执行此代码的结果在 Table 11-6 中给出。

表 11-6. 读取 256 只股票数据所需的时间(使用 L2 缓存)

测试用例 第一次执行 后续执行
默认配置 22.7 ± 2 秒(66,817 次 SQL 调用) 1.1 ± 0.7 秒(1 次 SQL 调用)
无查询 35 ± 3 秒(133,632 条 SQL 调用) 0.28 ± 0.3 秒(0 条 SQL 调用)

这个循环的第一次执行需要 133,632 条 SQL 语句:66,816 条用于调用 find() 方法,另外 66,816 条用于调用 getOptions() 方法。由于所有实体都在 L2 缓存中,且不需要发出 SQL 语句,因此代码的后续执行非常快速。

请记住,示例数据库包括每个日期和符号对应的五个期权价格,或者一年数据中 256 只股票共 334,080 个期权价格。当通过关系访问特定符号和日期的五只股票期权时,它们可以一次性全部检索出来。这就是为什么只需要 66,816 条 SQL 语句来加载所有期权价格数据。即使从这些 SQL 语句返回了多行,JPA 仍然能够缓存实体——这与执行查询不同。如果通过迭代实体来预热 L2 缓存,请不要逐个迭代相关实体——而是通过简单访问关系来做到这一点。

当优化代码时,您必须考虑缓存(特别是 L2 缓存)的影响。即使您认为您可以比 JPA 生成更好的 SQL(因此应使用复杂的命名查询),请确保在缓存起作用之后该代码是值得的。即使看起来使用简单的命名查询加载数据会更快,也要考虑如果这些实体通过调用 find() 方法加载到 L2 缓存中会发生什么。

调整 JPA 缓存大小

与所有对象复用的情况一样,JPA 缓存存在潜在的性能缺陷:如果缓存消耗过多内存,将导致 GC 压力。这可能需要调整缓存大小或者控制实体保持缓存的模式。不幸的是,这些不是标准选项,因此您必须根据您使用的 JPA 提供程序执行这些调整。

JPA 实现通常提供了设置缓存大小的选项,可以是全局的,也可以是每个实体的。后者显然更灵活,但也需要更多工作来确定每个实体的最佳大小。另一种方法是 JPA 实现使用软引用和/或弱引用来管理 L2 缓存。例如,EclipseLink 提供了五种缓存类型(还有一些已弃用的类型),基于软引用和弱引用的各种组合。虽然这种方法可能比确定每个实体的最佳大小更容易,但仍然需要一些规划:特别是请回忆第 第七章 弱引用在任何 GC 操作中都不会真正存活,因此作为缓存的选择有所疑问。

如果使用基于软引用或弱引用的缓存,则应用程序的性能还取决于堆中发生的其他事情。 本节的示例都使用了一个大堆,以便在应用程序中缓存 400,896 个实体对象不会导致垃圾收集器出现问题。 当存在大型 JPA L2 缓存时,调整堆非常重要以获得良好的性能。

快速总结

  • JPA L2 缓存将自动为应用程序缓存实体。

  • L2 缓存不缓存通过查询检索的实体。 这意味着从长远来看,完全避免查询可能是有利的。

  • 除非所使用的 JPA 实现支持查询缓存,否则使用 JOIN 查询通常会对性能产生负面影响,因为它绕过了 L2 缓存。

Spring 数据

尽管 JDBC 和 JPA 是 Java 平台的标准部分,但其他第三方 Java API 和框架管理数据库访问。 NoSQL 供应商都有自己的 API 来访问其数据库,各种框架通过与 JPA 不同的抽象提供数据库访问。

其中最广泛使用的是 Spring Data,这是一个包含关系型和 NoSQL 数据库访问模块的集合。 这个框架包含几个模块,包括以下内容:

Spring Data JDBC

这被设计为 JPA 的简单替代方案。 它提供与 JPA 类似的实体映射,但没有缓存、延迟加载或脏实体跟踪。 它建立在标准 JDBC 驱动程序之上,因此得到了广泛支持。 这意味着您可以在 Spring 代码中跟踪本章的性能方面:确保对重复调用使用预编译语句,实现必要的接口以支持 Spring 批处理语句模型,并/或直接使用连接对象以更改自动提交语义。

Spring Data JPA

这被设计为标准 JPA 的封装。 其一个重要优势是减少开发人员需要编写的样板代码量(这对开发者的性能有好处,但实际上并不影响我们讨论的性能)。 因为它包装了标准 JPA,本章提到的 JPA 性能方面仍然适用:设置急切加载与延迟加载,批量更新和插入,以及所有 L2 缓存仍然适用。

Spring Data 用于 NoSQL

Spring 具有各种 NoSQL(和类似 NoSQL 的)技术的连接器,包括 MongoDB、Cassandra、Couchbase 和 Redis。 这在某种程度上简化了 NoSQL 访问,因为访问存储的技术是相同的,尽管设置和初始化方面仍有差异。

Spring 数据 R2DBC

Spring Data R2DBC,在第十章提到,允许异步访问 Postgres、H2 和 Microsoft SQL Server 数据库。它遵循典型的 Spring Data 编程模型,而不是直接使用 JDBC,因此类似于 Spring Data JDBC:通过仓库中的简单实体进行访问,尽管没有 JPA 的缓存、延迟加载和其他特性。

摘要

正确调优 JDBC 和 JPA 对数据库的访问是影响中间层应用性能的最重要方法之一。请记住以下最佳实践:

  • 通过适当配置 JDBC 或 JPA 配置,尽可能进行批量读写。

  • 优化应用程序发出的 SQL。对于 JDBC 应用程序,这是一个关于基本标准 SQL 命令的问题。对于 JPA 应用程序,请务必考虑 L2 缓存的参与。

  • 尽可能减少锁定。当数据不太可能争用时,请使用乐观锁定;当数据争用时,请使用悲观锁定。

  • 确保使用准备好的语句池。

  • 确保使用适当大小的连接池。

  • 设置适当的事务范围:它应尽可能大,而不会因事务持有的锁而对应用的可扩展性产生负面影响。

¹ 在实际部署中,您可能更喜欢扩展数据库,但这通常很困难。

² 数据库供应商通常将语句池称为语句缓存

³ 在本书的第一版中,当测试运行在 Oracle 11g 上时,情况也不一样;行 2 和 3 之间以及行 5 和 6 之间有明显差异。这些测试是在具有其自身改进的 Oracle 18c 上运行的。

第十二章:Java SE API 提示

本章涵盖了 Java SE API 的一些实现怪癖,影响其性能。JDK 中存在许多这样的实现细节;这些是我在不同地方发现性能问题的地方(甚至是在我自己的代码中)。本章包括如何处理字符串(特别是重复字符串)的最佳方式;如何正确缓冲 I/O;类加载和如何改进使用大量类的应用程序的启动方式;正确使用集合;以及 JDK 8 的特性,如 lambda 和流。

字符串

字符串(不出所料地)是最常见的 Java 对象。在本节中,我们将探讨处理所有由字符串对象消耗的内存的各种方法;这些技术通常可以显著减少程序所需的堆内存量。我们还将介绍 JDK 11 中涉及字符串连接的新特性。

紧凑字符串

在 Java 8 中,所有字符串都编码为 16 位字符数组,而不考虑字符串的编码。这是不经济的:大多数西方地区可以将字符串编码为 8 位字节数组,即使在需要所有字符为 16 位的地区,像程序常量这样的字符串通常也可以编码为 8 位字节。

在 Java 11 中,除非明确需要 16 位字符,否则字符串编码为 8 位字节数组;这些字符串称为紧凑字符串。Java 6 中的类似(实验性)特性称为压缩字符串;紧凑字符串在概念上是相同的,但在实现上有很大不同。

因此,Java 11 中的平均 Java 字符串大小大约是 Java 8 中同一字符串大小的一半。这通常是巨大的节省:通常,典型 Java 堆的 50% 可能由字符串对象占用。当然,程序会有所不同,但平均而言,使用 Java 11 运行的此类程序的堆需求仅为 Java 8 运行相同程序的 75%。

很容易构建出这种有着超额好处的示例。可以在 Java 8 中运行一个需要大量时间执行垃圾收集的程序。在 Java 11 中以相同大小的堆运行相同程序可能几乎不需要时间进行收集,从而导致性能提升为三到十倍。对于这样的声明要持保留态度:通常情况下,您不太可能在这样的受限堆中运行任何 Java 程序。一切条件相同,您将看到垃圾收集所花时间减少。

对于调优良好的应用程序,真正的好处在于内存使用:您可以立即将典型程序的最大堆大小减少 25%,并且仍然获得相同的性能。反之,如果保持堆大小不变,您应该能够将更多负载引入应用程序,而不会遇到任何 GC 瓶颈(尽管应用程序的其他部分必须能够处理增加的负载)。

这一功能由 -XX:+CompactStrings 标志控制,默认为 true。但与 Java 6 中的压缩字符串不同,紧凑字符串既健壮又高效;你几乎总是希望保持默认设置。唯一的可能例外是在所有字符串都需要 16 位编码的程序中:在紧凑字符串中,对这些字符串的操作可能比未压缩的字符串稍长。

重复字符串与字符串池化

创建许多包含相同字符序列的字符串对象是常见的。这些对象在堆中占用了不必要的空间;由于字符串是不可变的,通常最好重用现有的字符串。我们在第七章讨论了一般情况,其中涉及具有规范表示的任意对象;本节扩展了这个想法,特别是与字符串相关的部分。

知道是否有大量重复的字符串需要堆分析。以下是使用 Eclipse Memory Analyzer 的方法之一:

  1. 加载堆转储。

  2. 从查询浏览器中选择 Java Basics → 按值分组。

  3. 对于 objects 参数,输入 java.lang.String

  4. 单击完成按钮。

结果显示在图 12-1 中。我们有超过 30 万个 NameMemnorParent Name 字符串的副本。还有几个其他字符串也有多个副本;总体而言,此堆中有超过 230 万个重复的字符串。

重复字符串及其内存大小。

图 12-1. 重复字符串占用的内存

可以通过三种方式去除重复的字符串:

  • 通过 G1 GC 执行自动去重

  • 使用 String 类的 intern() 方法创建字符串的规范版本

  • 使用一种自定义方法创建字符串的规范版本

字符串去重

最简单的机制是让 JVM 找到重复的字符串并去重它们:安排所有引用指向单个副本,然后释放剩余的副本。这只有在使用 G1 GC 并且指定 -XX:+UseStringDeduplication 标志(默认为 false)时才可能。此功能仅在 Java 8 的第 20 版之后和所有 Java 11 发行版中存在。

这一功能默认未启用,原因有三。首先,在 G1 GC 的年轻和混合阶段需要额外处理,使它们稍长。其次,它需要一个额外的线程与应用程序并发运行,可能会从应用程序线程中获取 CPU 周期。第三,如果有很少的去重字符串,应用程序的内存使用将更高(而不是更低);这额外的内存来自于跟踪所有字符串以查找重复所涉及的簿记。

这种选项在投入生产之前需要进行彻底测试:它可能会帮助你的应用,尽管在某些情况下会使情况变得更糟。不过,运气会偏向你一些:Java 工程师估计启用字符串去重的预期收益为 10%。

如果你想查看字符串去重在你的应用中的行为,可以在 Java 8 中使用-XX:+PrintStringDeduplicationStatistics标志,或在 Java 11 中使用-Xlog:gc+stringdedup*=debug标志来运行它。生成的日志会类似于以下内容:

[0.896s][debug][gc,stringdedup]   Last Exec: 110.434ms, Idle: 729.700ms,
                                  Blocked: 0/0.000ms
[0.896s][debug][gc,stringdedup]     Inspected:           62420
[0.896s][debug][gc,stringdedup]       Skipped:               0(  0.0%)
[0.896s][debug][gc,stringdedup]       Hashed:            62420(100.0%)
[0.896s][debug][gc,stringdedup]       Known:                 0(  0.0%)
[0.896s][debug][gc,stringdedup]       New:               62420(100.0%)
                                                         3291.7K
[0.896s][debug][gc,stringdedup]     Deduplicated:        15604( 25.0%)
                                                         731.4K( 22.2%)
[0.896s][debug][gc,stringdedup]       Young:                 0(  0.0%)
                                                         0.0B(  0.0%)
[0.896s][debug][gc,stringdedup]       Old:               15604(100.0%)
                                                         731.4K(100.0%)

这次字符串去重线程的运行时间为 110 毫秒,在此期间找到了 15,604 个重复的字符串(在被识别为去重候选项的 62,420 个字符串中)。由此节省的总内存为 731.4K,大约是我们希望从这个优化中获得的 10%。

生成此日志的代码设置了字符串的 25%是重复的,这是 JVM 工程师表示 Java 应用程序的典型情况。 (根据我的经验,正如我之前提到的,堆中字符串的比例更接近 50%; chacun à son goût。)¹ 之所以我们没有节省 25%的字符串内存是因为此优化只安排字符串的后备字符或字节数组进行共享; 字符串对象的其余部分不共享。 字符串对象具有 24 到 32 个字节的开销用于其其他字段(差异是由于平台实现)。 因此,两个相同的 16 个字符的字符串在去重之前每个占用 44(或 52)字节; 在去重之后,它们将占用 64 字节。 如果字符串被内化(如下一节所讨论的),它们将仅占用 40 字节。

正如我之前提到的,这些字符串的处理是与应用线程同时进行的。但实际上,这是处理过程的最后阶段。在年轻代收集期间,所有年轻代中的字符串都会被检查。那些晋升到老年代的字符串成为后台线程检查的候选项(一旦年轻代收集完成)。此外,请回忆一下第六章中关于对象在年轻代幸存者空间中老化的讨论:对象在成为老年代之前可能会在幸存者空间中来回移动几次。默认情况下,寿命为 3 的字符串(即它们被复制到幸存者空间三次)也会成为去重的候选项,并将由后台线程处理。

这导致短暂存在的字符串不会被去重,这很可能是件好事:您可能不希望花费 CPU 周期和内存来去重即将被丢弃的东西。与一般调整 tenuring 周期类似,更改此时发生的点需要大量测试,并且仅在不寻常的情况下才会进行。但为了记录,控制老年化字符串何时可收集的点是通过-XX:StringDeduplicationAgeThreshold=*N*标志,其默认值为 3。

字符串国际化

在编程级别处理重复字符串的典型方式是使用String类的intern()方法。

像大多数优化一样,字符串池的国际化不应该随意进行,但如果大量重复字符串占据了堆的重要部分,则可能是有效的。但通常需要进行特殊调整(在下一节中,我们将探讨一种在某些情况下有益的自定义方式)。

国际化字符串存储在一个特殊的哈希表中,该哈希表位于本地内存中(尽管字符串本身位于堆中)。这个哈希表与您在 Java 中熟悉的哈希表和哈希映射不同,因为这个本地哈希表具有固定的大小:在 Java 8 中为 60,013,在 Java 11 中为 65,536。(如果您使用的是 32 位 Windows JVM,则大小为 1,009。)这意味着在哈希表开始发生碰撞之前,您只能存储大约 32,000 个国际化字符串。

可以通过使用标志-XX:StringTableSize=N(默认为 1,009、60,013 或 65,536,如前所述)在 JVM 启动时设置此表的大小。如果应用程序将会国际化大量字符串,则应增加此数字。如果该值为质数,则字符串国际化表的操作效率最高。

intern() 方法的性能受到字符串表大小调整的主导。例如,表 12-1 显示了使用和不使用该调整创建和国际化 100 万个随机创建的字符串的总时间。

表 12-1. 国际化 100 万个字符串的时间

调整 100% 命中率 0% 命中率
字符串表大小 60013 4.992 ± 2.9 秒 2.759 ± 0.13 秒
字符串表大小 100 万 2.446 ± 0.6 秒 2.737 ± 0.36 秒

注意,当完全命中率为 100%时,未正确调整大小的字符串国际化表的严重惩罚。一旦根据预期数据调整了表的大小,性能将得到显著改善。

0% 命中率表可能有点令人惊讶,因为调优前后的性能基本相同。在这个测试案例中,字符串在进入表后会立即被丢弃。内部字符串表的功能就像键是弱引用一样,所以当字符串被丢弃时,字符串表可以清除它。因此,在这个测试案例中,字符串表实际上从未填满过;最终只有几个条目(因为任何时候只有少量字符串被强引用)。

为了查看字符串表的性能,可以使用 -XX:+PrintStringTableStatistics 参数(默认为 false)运行应用程序。当 JVM 退出时,它将打印出如下表格:

StringTable statistics:
Number of buckets       :     60013 =    480104 bytes, avg   8.000
Number of entries       :   2002784 =  48066816 bytes, avg  24.000
Number of literals      :   2002784 = 606291264 bytes, avg 302.724
Total footprint         :           = 654838184 bytes
Average bucket size     :    33.373
Variance of bucket size :    33.459
Std. dev. of bucket size:     5.784
Maximum bucket size     :        60

这个输出来自于 100% 命中率的示例。在这之后的迭代中,我们有 2,002,784 个内部化字符串(200 万来自我们进行了一个预热和一个测量周期的测试;其余来自 jmh 和 JDK 类)。我们最关心的条目是平均和最大桶大小:我们平均需要遍历 33 个条目,最多需要遍历 60 个条目以搜索哈希表中的一个条目。理想情况下,平均长度应小于一,最大长度接近一。这正是我们在 0% 命中率情况下看到的情况:

Number of buckets       :     60013 =    480104 bytes, avg   8.000
Number of entries       :      2753 =     66072 bytes, avg  24.000
Number of literals      :      2753 =    197408 bytes, avg  71.707
Total footprint         :           =    743584 bytes
Average bucket size     :     0.046
Variance of bucket size :     0.046
Std. dev. of bucket size:     0.214
Maximum bucket size     :         3

因为字符串很快就会从表中释放,所以我们最终只有 2,753 个表条目,对于默认大小为 60,013 来说是可以接受的。

应用程序分配的内部化字符串数(及其总大小)也可以通过 jmap 命令获取:

% jmap -heap process_id
... other output ...
36361 interned Strings occupying 3247040 bytes.

设置字符串表大小过高的惩罚很小:每个桶只占用 8 字节,因此比最优状态多几千个条目只是一次性的几千字节本机(非堆)内存成本。

自定义字符串内部化

字符串表的调优有些尴尬;我们是否可以通过仅使用保留重要字符串的自定义内部化方案来实现更好的效果?在第二章中也概述了该代码。

表 12-2 指引我们找到了问题的答案。除了使用常规的 ConcurrentHashMap 来保存内部化字符串之外,该表还展示了使用 JSR166 开发的额外类中的 CustomConcurrentHashMap 的使用。该自定义映射允许我们为键使用弱引用,因此其行为更接近字符串内部化表。

表 12-2. 通过自定义代码内部化 100 万个字符串所需时间

实现 100% 命中率 0% 命中率
ConcurrentHashMap 7.665 ± 6.9 秒 5.490 ± 2.462 秒
CustomConcurrentHashMap 2.743 ± 0.4 秒 3.684 ± 0.5 秒

在 100% 命中率测试中,ConcurrentHashMap 遭受与内部字符串表相同的问题:每次迭代都会有大量来自条目的 GC 压力。这是在一个 30 GB 堆上的测试结果;更小的堆将会得到更糟糕的结果。

与所有微基准测试一样,在这里深思熟虑使用情况。Concurren⁠t​HashMap 可以被显式管理,而不是我们目前的设置,不断地将新创建的字符串放入其中。根据应用程序的情况,这可能很容易或很难做到;如果足够容易,ConcurrentHashMap 测试将显示与常规内部化或 CustomConcurrentHashMap 测试相同的好处。在实际应用中,GC 压力才是关键:我们只会使用这种方法来删除重复的字符串,以尝试节省 GC 循环。

然而,没有一种情况真的比正确调整过的字符串表的测试更好。自定义映射的优点在于不需要事先设置大小:它可以根据需要调整大小。因此,它比使用 intern() 方法并根据应用程序的情况调整字符串表大小要适应更多应用程序。

字符串连接

字符串连接是另一个潜在的性能陷阱。考虑一个简单的字符串连接,如下所示:

String answer = integerPart + "." + mantissa;

Java 中的特殊优化可以处理这种结构(尽管各版本之间的细节有所不同)。

在 Java 8 中,javac 编译器将该语句转换为以下代码:

String answer = new StringBuilder(integerPart).append(".")
                         .append(mantissa).toString();

JVM 有特殊的代码来处理这种类型的结构(通过设置 -XX:+OptimizeStringConcat 标志来控制,其默认值为 true)。

在 Java 11 中,javac 编译器生成的字节码非常不同;该代码调用 JVM 本身内部的特殊方法来优化字符串连接。

这是少数情况之一,其中字节码在不同版本之间很重要。通常,当您迁移到较新版本时,无需重新编译旧代码:字节码将保持不变。(当然,您会希望使用新编译器编译新代码以使用新的语言特性。)但是,此特定优化取决于实际的字节码。如果您使用 Java 8 编译并运行执行字符串连接的代码,Java 11 JDK 将应用与 Java 8 中相同的优化。代码仍将被优化并运行得相当快。

如果您在 Java 11 下重新编译代码,字节码将使用新的优化,并且可能会更快。

让我们考虑以下三种连接两个字符串的情况:

@Benchmark
public void testSingleStringBuilder(Blackhole bh) {
    String s = new StringBuilder(prefix).append(strings[0]).toString();
    bh.consume(s);
}

@Benchmark
public void testSingleJDK11Style(Blackhole bh) {
    String s = prefix + strings[0];
    bh.consume(s);
}

@Benchmark
public void testSingleJDK8Style(Blackhole bh) {
    String s = new StringBuilder().append(prefix).append(strings[0]).toString();
    bh.consume(s);
}

第一种方法是我们手工编写此操作的方式。第二种方法(在使用 Java 11 编译时)将产生最新的优化,而最终方法(无论使用哪个编译器)在 Java 8 和 Java 11 中将以相同方式进行优化。

表 12-3 显示了这些操作的结果。

表 12-3. 单个连接的性能

模式 每次操作的时间
JDK 11 优化 47.7 ± 0.3 ns
JDK 8 优化 42.9 ± 0.3 ns
字符串构建器 87.8 ± 0.7 ns

在这种情况下,旧(Java 8)和新(Java 11)连接优化之间几乎没有实质性的区别;尽管 jmh 告诉我们这种差异在统计上是显著的,但它们并不特别重要。关键点是这两种优化都优于手动编码这个简单的情况。这有点令人惊讶,因为手动编码的情况似乎更简单:与 JDK 8 情况相比,它调用了一个更少的 append() 方法,因此执行的工作名义上较少。但是 JVM 内的字符串连接优化没有捕捉到这种特定模式,所以它最终会更慢。

JDK 8 优化并不适用于所有连接,但我们可以略微改变我们的测试,如下所示:

@Benchmark
public void testDoubleJDK11Style(Blackhole bh) {
    double d = 1.0;
    String s = prefix + strings[0] + d;
    bh.consume(s);
}

@Benchmark
public void testDoubleJDK8Style(Blackhole bh) {
    double d = 1.0;
    String s = new StringBuilder().append(prefix).
                   append(strings[0]).append(d).toString();
    bh.consume(s);
}

现在性能不同了,正如 表 12-4 所示。

表 12-4. 使用双精度值连接的性能

模式 每次操作所需时间
JDK 11 优化 49.4 ± 0.6 ns
JDK 8 优化 77.0 ± 1.9 ns

JDK 11 的时间与最后一个示例类似,即使我们附加了一个新值并且做了稍微更多的工作。但是 JDK 8 的时间要糟糕得多——它慢了大约 50%。这并不完全是因为额外的连接操作;而是因为连接操作的类型。JDK 8 优化对字符串和整数效果很好,但无法处理双精度(以及大多数其他类型的数据)。在这些情况下,JDK 8 代码会跳过特殊优化,并像之前手动编码的测试一样运行。

当我们进行多个连接操作时,这两种优化都不会延续,特别是在循环内部进行的操作。考虑以下测试:

    @Benchmark
    public void testJDK11Style(Blackhole bh) {
        String s = "";
        for (int i = 0; i < nStrings; i++) {
            s = s + strings[i];
        }
        bh.consume(s);
    }

    @Benchmark
    public void testJDK8Style(Blackhole bh) {
        String s = "";
        for (int i = 0; i < nStrings; i++) {
            s = new StringBuilder().append(s).append(strings[i]).toString();
        }
        bh.consume(s);
    }

    @Benchmark
    public void testStringBuilder(Blackhole bh) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < nStrings; i++) {
            sb.append(strings[i]);
        }
        bh.consume(sb.toString());
    }

现在结果更有利于手动编码,这是有道理的。特别是 Java 8 实现,必须在每次循环迭代中创建一个新的 StringBuilder 操作,即使在 Java 11 中,每次循环创建字符串的开销(而不是在字符串构建器中累加)也会产生影响。这些结果显示在 表 12-5 中。

表 12-5. 多个字符串连接的性能

模式 10 个字符串 1,000 个字符串
JDK 11 代码 613 ± 8 ns 2,463 ± 55 μs
JDK 8 代码 584 ± 8 ns 2,602 ± 209 μs
字符串构建器 412 ± 2 ns 38 ± 211 μs

要点:不要害怕在可以在单个(逻辑)行上完成连接时使用连接,但是除非连接的字符串不在下一次循环迭代中使用,否则永远不要在循环内部使用字符串连接。否则,始终明确使用 StringBuilder 对象以获得更好的性能。在 第一章 中,我提到了有时候需要“过早”进行优化,当该短语用于简单表示“编写良好的代码”时。这是一个典型的例子。

快速摘要

  • 字符串的一行连接表现良好。

  • 对于多个连接操作,请务必使用 StringBuilder

  • 在 JDK 11 中重新编译涉及某些类型的字符串一行连接将显著提高速度。

缓冲 I/O

当我在 2000 年加入 Java 性能组时,我的老板刚刚出版了关于 Java 性能的第一本书,那时最热门的话题之一是缓冲 I/O。十四年后,我准备认为这个话题已经老掉牙,决定在第一版书中不再提及它。然而,就在我开始第一版大纲的那周,我在两个无关的项目中提交了缓冲 I/O 严重影响性能的 bug 报告。几个月后,当我在为第一版书编写示例时,我摸着头想知道为什么我的“优化”如此缓慢。后来我意识到:傻瓜,你忘记了正确地进行 I/O 缓冲。

至于第二版:在我重新审视这一部分之前的两周内,有三位同事来找我,他们在缓冲 I/O 方面犯了我在第一版示例中犯的同样错误。

那么让我们来谈谈缓冲 I/O 的性能。InputStream.read()OutputStream.write()方法操作一个字符。根据它们访问的资源,这些方法可能非常慢。使用read()方法的FileInputStream将非常慢:每次方法调用都需要进入内核以获取 1 字节数据。在大多数操作系统上,内核将对 I/O 进行缓冲,因此(幸运的是)这种情况不会触发每次read()方法调用的磁盘读取。但是该缓冲区位于内核中,而不是应用程序中,每次逐字节读取都意味着为每个方法调用进行昂贵的系统调用。

写入数据也是一样的:使用write()方法将单个字节发送到FileOutputStream需要一个系统调用来将字节存储到内核缓冲区。最终(当文件关闭或刷新时),内核将把该缓冲区写入磁盘。

对于使用二进制数据的基于文件的 I/O,请始终使用BufferedInputStreamBufferedOutputStream来包装底层文件流。对于使用字符(字符串)数据的基于文件的 I/O,请始终使用BufferedReaderBufferedWriter来包装底层流。

尽管讨论文件 I/O 时最容易理解这个性能问题,但这是一个通用问题,几乎适用于每一种类型的 I/O。从套接字返回的流(通过getInputStream()getOutputStream()方法)以相同的方式操作,通过套接字逐字节进行 I/O 操作会非常慢。在这里,同样确保流适当地包装在一个缓冲过滤流中。

当使用ByteArrayInputStreamByteArrayOutputStream类时,会出现更微妙的问题。这些类本质上只是大的内存缓冲区。在许多情况下,将它们与缓冲过滤流包装在一起意味着数据被复制两次:一次到过滤流的缓冲区,一次到ByteArrayInputStream的缓冲区(或者对于输出流来说反过来)。在没有其他流参与的情况下,应避免缓冲 I/O。

当涉及其他过滤流时,是否进行缓冲的问题变得更加复杂。本章后面,您将看到一个涉及多个过滤流(使用ByteArrayOutputStreamObjectOutputStreamGZIPOutputStream类)的对象序列化示例。

没有压缩输出流的情况下,该示例的过滤器如下所示:

protected void makePrices() throws IOException {
    ByteArrayOutputStream baos = new ByteArrayOutputStream();
    ObjectOutputStream oos = new ObjectOutputStream(baos);
    oos.writeObject(prices);
    oos.close();
}

在这种情况下,将baos流包装在BufferedOutputStream中将导致额外复制数据,从而降低性能。

一旦我们添加了压缩,编写代码的最佳方式如下:

protected void makeZippedPrices() throws IOException {
    ByteArrayOutputStream baos = new ByteArrayOutputStream();
    GZIPOutputStream zip = new GZIPOutputStream(baos);
    BufferedOutputStream bos = new BufferedOutputStream(zip);
    ObjectOutputStream oos = new ObjectOutputStream(bos);
    oos.writeObject(prices);
    oos.close();
    zip.close();
}

现在有必要对输出流进行缓冲,因为GZIPOutputStream在处理数据块时比单个字节的效率更高。无论哪种情况,ObjectOutputStream都会向下一个流发送单个字节的数据。如果下一个流是最终目的地——ByteArrayOutputStream,则不需要缓冲。如果在中间有另一个过滤流(比如本例中的GZIPOutputStream),则通常需要缓冲。

关于何时在两个其他流之间使用缓冲流没有一般规则。最终将取决于涉及的流类型,但如果从缓冲流中提供数据块(而不是从ObjectOutputStream提供单个字节序列),则可能情况都会更好。

对于输入流也是同样的情况。在这种特定情况下,GZIPInputStream在数据块上的操作效率更高;在一般情况下,介于ObjectInputStream和原始字节源之间的流也会更喜欢数据块。

请注意,这种情况特别适用于流编码器和解码器。当在字节和字符之间转换时,尽可能处理尽可能大的数据块将提供最佳性能。如果将单个字节或字符提供给编码器和解码器,它们的性能将会受到影响。

事实上,没有对 gzip 流进行缓冲正是我在编写该压缩示例时犯的错误。正如表 12-6 中的数据所示,这是一个代价高昂的错误。

表 12-6. 使用压缩序列化和反序列化Stock对象所需的时间

模式 时间
未缓冲的压缩/解压缩 21.3 ± 8 ms
缓冲压缩/解压缩 5.7 ± 0.08 ms

没有正确进行 I/O 缓冲导致了高达四倍的性能惩罚。

快速总结

  • 因为简单输入和输出流类的默认实现,围绕缓冲 I/O 的问题是很常见的。

  • 对于文件和套接字以及压缩和字符串编码等内部操作,I/O 必须进行适当的缓冲。

类加载

类加载性能是任何试图优化程序启动或在动态系统中部署新代码的人的困扰。

有很多原因导致这种情况。首先,类数据(即 Java 字节码)通常不容易访问。这些数据必须从磁盘或网络加载,它们必须在类路径上的几个 JAR 文件中找到,并且它们必须由几个类加载器中的一个找到。有一些方法可以帮助加快这个过程:一些框架将它们从网络读取的类缓存到一个隐藏目录中,以便在下次启动同一应用程序时可以更快地读取这些类。将应用程序打包成更少的 JAR 文件也将加快其类加载性能。

在这一节中,我们将看一下 Java 11 的一个新特性,以加快类加载速度。

类数据共享

类数据共享CDS)是一种机制,可以在 JVM 之间共享类的元数据。当运行多个 JVM 时,这对于节省内存很有用:通常每个 JVM 都会有自己的类元数据,而这些单独的副本会占用一些物理内存。如果共享这些元数据,只需要在内存中保留一份副本。

结果表明,对单个 JVM 来说,CDS 非常有用,因为它还可以改善启动时间。

在 Java 8(及之前的版本)中提供了类数据共享,但有一个限制,即仅适用于 rt.jar 中的类,并且仅在使用客户端 JVM 的串行收集器时。换句话说,它在 32 位单 CPU Windows 桌面机上有所帮助。

在 Java 11 中,CDS 在所有平台上通常是可用的,尽管它不是开箱即用的,因为没有默认的共享类元数据存档。 Java 12 具有常见 JDK 类的默认共享存档,因此所有应用程序默认会获得一些启动(和内存)的好处。无论哪种情况,通过为我们的应用程序生成更完整的共享存档,我们可以做得更好,因为在 Java 11 中,CDS 可以与任何一组类一起工作,无论哪个类加载器加载它们,它们从哪个 JAR 或模块加载。有一个限制:CDS 仅适用于从模块或 JAR 文件加载的类。您不能共享(或快速加载)来自文件系统或网络 URL 的类。

从某种意义上说,这意味着有两种 CDS:常规 CDS(共享默认的 JDK 类)和应用程序类数据共享,它可以共享任何一组类。应用程序类数据共享实际上是在 Java 10 中引入的,并且它的工作方式与常规 CDS 不同:程序需要使用不同的命令行参数来使用它。这种区分现在已经过时,在 Java 11 及以后的版本中,不管被共享的类是什么,CDS 的工作方式都是相同的。

使用 CDS 所需的第一件事是共享类的共享存档。正如我提到的,Java 12 自带了一个默认的 JDK 类共享存档,位于 $JAVA_HOME/lib/server/classes.jsa(或在 Windows 上是 %JAVA_HOME%\bin\server\classes.jsa)。该存档包含 12,000 个 JDK 类的数据,因此其核心类的覆盖范围非常广泛。要生成你自己的存档,首先需要一个你想启用共享的所有类的列表(从而实现快速加载)。该列表可以包括 JDK 类和应用程序级别的类。

获取这样一个列表有很多方法,但最简单的是使用带有 -XX:+DumpLoadedClassList=filename 标志运行你的应用程序,这将在 filename 中生成你的应用程序已加载的所有类的列表。

第二步是使用该类列表生成共享存档,像这样:

$ java -Xshare:dump -XX:SharedClassListFile=filename \
    -XX:SharedArchiveFile=myclasses.jsa \
    ... classpath arguments ...

这将根据文件列表创建一个新的共享存档文件,并使用给定的名称(这里是 myclasses.jsa)。你必须设置类路径与正常运行应用程序时相同(即使用 -cp-jar 参数)。

这个命令将会生成大量关于找不到的类的警告。这是预期的,因为这个命令无法找到动态生成的类:代理类,基于反射的类等等。如果你看到一个你期望加载的类的警告,试着调整该命令的类路径。不能找到所有类并不是问题;这意味着它们将会从类路径正常加载,而不是从共享存档加载。因此加载特定的类会稍慢一些,但是这样的几个类几乎不会被注意到,所以在这一步不要太过于担心。

最后,使用共享存档来运行应用程序:

$ java -Xshare:auto -XX:SharedArchiveFile=myclasses.jsa ... other args ...

对于这个命令有几点备注。首先,-Xshare 命令有三个可能的取值:

off

不要使用类数据共享。

on

一定要使用类数据共享。

auto

尝试使用类数据共享。

CDS 依赖于将共享存档映射到内存区域,而在某些(大多数情况下是罕见的)情况下,这可能会失败。如果指定了 -Xshare:on,应用程序将在此情况发生时无法运行。因此,默认值是 -Xshare:auto,这意味着通常会使用 CDS,但如果由于某种原因无法映射存档,则应用程序将在没有它的情况下继续运行。由于此标志的默认值为 auto,因此实际上不必在前述命令中指定它。

其次,此命令提供了共享存档的位置。SharedArchiveFile 标志的默认值是前面提到的 classes.jsa 路径(位于 JDK server 目录内)。因此,在 Java 12 中(其中存在该文件),如果我们只想使用(仅限 JDK 的)默认共享存档,就不需要提供任何命令行参数。

在一种常见情况下,加载共享存档可能会失败:用于生成共享存档的类路径必须是用于运行应用程序的类路径的子集,并且自共享存档创建以来 JAR 文件不能发生更改。因此,您不希望生成除 JDK 外的类的共享存档,并将其放在默认位置,因为任意命令的类路径将不匹配。

此外,还要注意更改 JAR 文件。如果使用 -Xshare:auto 的默认设置并更改了 JAR 文件,则应用程序仍将运行,尽管未使用共享存档。更糟糕的是,不会收到任何警告;您唯一能看到的影响是应用程序启动更慢。这是考虑指定 -Xshare:on 而不是默认设置的原因之一,尽管共享存档可能失败的其他原因还有很多。

要验证是否从共享存档加载类,请在命令行中包含类加载日志记录(-Xlog:class+load=info);您将看到通常的类加载输出,并且从共享存档加载的类将显示如下:

[0.080s][info][class,load] java.lang.Comparable source: shared objects file

类数据共享的好处

类数据共享对启动时间的好处显然取决于要加载的类的数量。表 12-7 显示了在书籍示例中启动样本股票服务器应用程序所需的时间;需要加载 6,314 个类。

表 12-7. 使用 CDS 启动应用程序所需的时间

CDS 模式 启动时间
-Xshare:off 8.9 秒
-Xshare:on(默认) 9.1 秒
-Xshare:on(自定义) 7.0 秒

在默认情况下,我们仅使用 JDK 的共享存档;最后一行是所有应用程序类的自定义共享存档。在这种情况下,CDS 可以节省我们 30% 的启动时间。

CDS 还会节省一些内存,因为类数据将在进程之间共享。总体而言,正如您在第八章 中看到的例子一样,在本地内存中的类数据相对较小,特别是与应用程序堆相比。在具有大量类的大型程序中,CDS 将节省更多内存,尽管大型程序可能需要更大的堆,使比例节省仍然很小。然而,在一个特别缺乏本机内存并且运行多个使用大量相同类的 JVM 副本的环境中,CDS 也将为内存节省提供一些好处。

快速总结

  • 加快类加载速度的最佳方法是为应用程序创建一个类数据共享存档。幸运的是,这不需要进行任何编程更改。

随机数

接下来我们将看一些涉及随机数生成的 API。Java 自带三个标准的随机数生成器类:java.util.Randomjava.util.concurrent.ThreadLocalRandomjava.security.SecureRandom。这三个类具有重要的性能差异。

Random类和ThreadLocalRandom类之间的区别在于Random类的主要操作(nextGaussian()方法)是同步的。该方法被检索随机值的任何方法使用,因此无论如何使用随机数生成器,该锁定都可能成为争用点:如果两个线程同时使用相同的随机数生成器,则其中一个将不得不等待另一个完成其操作。这就是为什么有线程本地版本可用的原因:当每个线程有自己的随机数生成器时,Random类的同步不再是问题。(正如在第七章 中讨论的那样,线程本地版本还提供显著的性能优势,因为它重用了昂贵的创建对象。)

这些类与SecureRandom类之间的区别在于所使用的算法。Random类(以及通过继承的ThreadLocalRandom类)实现了典型的伪随机算法。虽然这些算法非常复杂,但最终是确定性的。如果初始种子是已知的,就可以确定引擎将生成的确切数字系列。这意味着黑客能够查看特定生成器的数字系列,并最终推断出下一个数字是什么。尽管良好的伪随机数生成器可以生成看起来非常随机的数字系列(甚至符合随机性的概率预期),但它们并非真正随机。

另一方面,SecureRandom类使用系统接口获取其随机数据的种子。生成数据的方式是特定于操作系统的,但通常情况下,这个来源提供基于真正随机事件(例如鼠标移动时)的数据。这被称为基于熵的随机性,对依赖随机数的操作更加安全。

Java 区分两个随机数源:一个用于生成种子,一个用于生成随机数本身。种子用于创建公钥和私钥,例如通过 SSH 或 PuTTY 访问系统时使用的密钥。这些密钥长期存在,因此需要最强大的加密算法。安全随机数还用于种子常规的随机数流,包括 Java 的 SSL 库的默认实现中使用的流。

在 Linux 系统上,这两个来源分别是/dev/random(用于种子)和/dev/urandom(用于随机数)。这两个系统都基于机器内的熵源:真正随机的事物,如鼠标移动或键盘击键。熵的量是有限的,会随机再生,因此作为真正随机性的来源是不可靠的。这两个系统处理方式不同:/dev/random会阻塞,直到有足够的系统事件生成随机数据,而/dev/urandom则会退而使用伪随机数生成器(PRNG)。PRNG 会从一个真正随机的来源初始化,因此通常与/dev/random产生的数据流一样强大。然而,生成种子所需的熵可能不可用,此时从/dev/urandom得到的数据流理论上可能会受到影响。关于这一问题的争论有两面观点,但普遍的共识是——用/dev/random生成种子,用/dev/urandom处理其他一切——这是 Java 采纳的方案。

结论是获取大量随机数种子可能需要很长时间。调用SecureRandom类的generateSeed()方法将花费不确定的时间,取决于系统中未使用的熵量。如果没有可用的熵,调用可能会出现挂起的情况,可能会持续几秒钟,直到所需的熵可用。这使得性能的定时变得非常困难:性能本身变得随机起来。

另一方面,generateSeed()方法仅用于两个操作。首先,某些算法使用它获取未来调用nextRandom()方法的种子。这通常只需要在应用程序生命周期中执行一次,或者定期执行。其次,创建长期存在的密钥时也会使用此方法,这也是相当少见的操作。

由于这些操作受限,大多数应用程序不会耗尽熵。但对于在启动时创建密码的应用程序来说,熵的限制可能是一个问题,特别是在主机操作系统随机数设备在多个虚拟机和/或 Docker 容器之间共享的云环境中。在这种情况下,程序活动的时间将具有非常大的差异,由于安全种子的使用通常发生在程序初始化时,因此在这个领域的应用程序启动可能会非常慢。

我们有几种方式来处理这种情况。在紧急情况下,以及可以更改代码的情况下,解决这个问题的替代方案是使用 Random 类来运行性能测试,尽管生产中将使用 SecureRandom 类。如果性能测试是模块级测试,这是有道理的:这些测试将需要比生产系统在同一时间段内需要的更多的随机种子。但最终,必须使用 SecureRandom 类来测试预期负载,以确定生产系统的负载是否可以获得足够数量的随机种子。

第二个选择是配置 Java 的安全随机数生成器使用 /dev/urandom 作为种子以及随机数。有两种方法可以实现这一点:首先,可以设置系统属性 -Djava.security​.egd=file:/dev/urandom。²

第三个选项是在 $JAVA_HOME/jre/lib/security/java.security 中更改此设置:

securerandom.source=file:/dev/random

该行定义了用于种子操作的接口,并且如果您希望确保安全的随机数生成器永远不会阻塞,可以将其设置为 /dev/urandom

然而,更好的解决方案是设置操作系统以提供更多熵,通过运行 rngd 守护程序来完成。只需确保 rngd 守护程序配置为使用可靠的硬件熵源(例如,如果可用,则使用 /dev/hwrng),而不是像 /dev/urandom 这样的东西。这种解决方案的优势在于解决了机器上所有程序的熵问题,而不仅仅是 Java 程序。

快速总结

  • Java 的默认 Random 类初始化昂贵,但一旦初始化完成,可重复使用。

  • 在多线程代码中,首选 ThreadLocalRandom 类。

  • 有时,SecureRandom 类会表现出任意的、完全随机的性能。对使用该类的代码进行性能测试必须谨慎计划。

  • 使用 SecureRandom 类可能会遇到阻塞问题,可以通过配置更改来避免,但最好通过增加系统熵在操作系统级别解决这些问题。

Java 本地接口

关于 Java SE 的性能提示(特别是在 Java 刚开始时),通常会说如果想要真正快速的代码,应该使用本地代码。但事实上,如果你希望编写尽可能快的代码,应避免使用 Java 本地接口(JNI)。

在当前 JVM 版本上,良好编写的 Java 代码至少与相应的 C 或 C++ 代码一样快(现在已不是 1996 年了)。语言纯粹主义者将继续辩论 Java 和其他语言的相对性能优点,无疑可以找到用另一种语言编写的应用程序比用 Java 编写的同一应用程序更快的例子(尽管这些例子通常包含编写不良的 Java 代码)。然而,这种辩论忽略了本节的重点:当应用程序已经用 Java 编写时,出于性能原因调用本地代码几乎总是一个坏主意。

但是,有时 JNI 是非常有用的。Java 平台提供了许多操作系统的常见功能,但如果需要访问特定于操作系统的特殊功能,那么就需要 JNI。而且,如果商业(本地)版本的代码已经准备就绪,为什么要构建自己的库来执行操作呢?在这些以及其他情况下,问题就变成了如何编写最有效的 JNI 代码。

答案是尽可能避免从 Java 到 C 的调用。跨 JNI 边界(称为进行跨语言调用)是昂贵的。因为调用现有的 C 库本身就需要编写粘合代码,所以要花时间通过该粘合代码创建新的粗粒度接口:一次性在 C 库中执行多个、多次调用。

有趣的是,反过来未必成立:调用 Java 返回 C 的 C 代码并不会受到很大的性能惩罚(取决于涉及的参数)。例如,请考虑以下代码摘录:

@Benchmark
public void testJavaJavaJava(Blackhole bh) {
    long l = 0;
    for (int i = 0; i < nTrials; i++) {
        long a = calcJavaJava(nValues);
        l += a / nTrials;
    }
    bh.consume(l);
}

private long calcJavaJava(int nValues) {
    long l = 0;
    for (int i = 0; i < nValues; i++) {
        l += calcJava(i);
    }
    long a = l / nValues;
    return a;
}

private long calcJava(int i) {
    return i * 3 + 15;
}

这段(完全无意义的)代码有两个主要循环:一个在基准方法内部,然后一个在 calcJavaJava() 方法内部。那是全部 Java 代码,但我们可以选择使用本地接口,将外部计算方法写在 C 中代替:

@Benchmark
public void testJavaCC(Blackhole bh) {
    long l = 0;
    for (int i = 0; i < nTrials; i++) {
        long a = calcCC(nValues);
        l += 50 - a;
    }
    bh.consume(l);
}

private native long calcCC(int nValues);

或者我们可以在 C 中实现内部调用(其代码应该是显而易见的)。

Table 12-8 展示了在给定 10,000 次试验和 10,000 个值的各种排列情况下的性能。

表 12-8. 跨 JNI 边界计算时间

calculateError Calc Random JNI 转换 总时间
Java Java Java 0 0.104 ± 0.01 秒
Java Java C 10,000,000 1.96 ± 0.1 秒
Java C C 10,000 0.132 ± 0.01 秒
C C C 0 0.139 ± 0.01 秒

仅在 C 中实现最内层方法会产生 JNI 边界最多的交叉(numberOfTrials × numberOfLoops,即 1000 万次)。将交叉数量减少到 numberOfTrials(10,000)可以大大减少这种开销,将其进一步减少到 0 可以提供最佳性能。

如果涉及的参数不是简单的原始类型,则 JNI 代码的性能会变差。这种开销涉及两个方面。首先,对于简单的引用,需要地址转换。其次,对于基于数组的数据,在本地代码中需要进行特殊处理。这包括 String 对象,因为字符串数据本质上是字符数组。要访问这些数组的各个元素,必须进行特殊调用以将对象固定在内存中(对于 JDK 8 中的 String 对象,还要将其从 Java 的 UTF-16 编码转换为 UTF-8)。当不再需要数组时,必须在 JNI 代码中显式释放它。

在数组被固定时,垃圾收集器无法运行——因此 JNI 代码中最昂贵的错误之一就是在长时间运行的代码中固定字符串或数组。这会阻止垃圾收集器运行,从而有效阻塞所有应用程序线程,直到 JNI 代码完成。非常重要的是使数组被固定的关键部分尽可能短暂。

通常,您会在 GC 日志中看到术语 GC Locker Initiated GC。这表明垃圾收集器需要运行,但由于线程在 JNI 调用中固定了数据,所以无法运行。一旦该数据解除固定,垃圾收集器就会运行。如果经常看到这个 GC 原因,请考虑使 JNI 代码更快;其他应用程序线程正在等待 GC 运行时会出现延迟。

有时,为了短暂固定对象的目标与减少跨 JNI 边界调用的目标冲突。在这种情况下,后者的目标更为重要,即使这意味着在 JNI 边界上进行多次交叉调用,因此请尽可能使固定数组和字符串的部分尽可能短。

快速总结

  • JNI 不是性能问题的解决方案。几乎总是比调用本地代码更快。

  • 当使用 JNI 时,限制从 Java 到 C 的调用次数;跨 JNI 边界的成本很高。

  • 使用数组或字符串的 JNI 代码必须固定这些对象;限制它们固定的时间长度,以免影响垃圾收集器。

异常

Java 异常处理以昂贵著称。尽管在大多数情况下,其额外成本并不值得尝试绕过它,但它比处理常规控制流昂贵一些。另一方面,由于它并非免费,异常处理也不应作为通用机制。指导方针是根据良好程序设计的一般原则使用异常:主要是,代码只应在发生意外情况时抛出异常。遵循良好的代码设计意味着你的 Java 代码不会因异常处理而变慢。

两件事可能会影响异常处理的一般性能。首先是代码块本身:设置 try-catch 块是否昂贵?虽然很久以前可能是这样,但多年来情况并非如此。不过,因为互联网记忆力强,有时您会看到建议仅因为 try-catch 块而避免异常。这些建议已过时;现代 JVM 可以生成处理异常的代码。

第二个方面是异常涉及在异常点获取堆栈跟踪(尽管在本节后面您会看到一个例外)。这个操作可能很昂贵,特别是如果堆栈跟踪很深。

让我们来看一个例子。这里有三种特定方法的实现要考虑:

private static class CheckedExceptionTester implements ExceptionTester {
    public void test(int nLoops, int pctError, Blackhole bh) {
        ArrayList<String> al = new ArrayList<>();
        for (int i = 0; i < nLoops; i++) {
            try {
                if ((i % pctError) == 0) {
                    throw new CheckedException("Failed");
                }
                Object o = new Object();
                al.add(o.toString());
            } catch (CheckedException ce) {
                // continue
            }
        }
        bh.consume(al);
    }
}

private static class UncheckedExceptionTester implements ExceptionTester {
    public void test(int nLoops, int pctError, Blackhole bh) {
        ArrayList<String> al = new ArrayList<>();
        for (int i = 0; i < nLoops; i++) {
            Object o = null;
            if ((i % pctError) != 0) {
                o = new Object();
            }
            try {
                al.add(o.toString());
            } catch (NullPointerException npe) {
                // continue
            }
        }
        bh.consume(al);
    }
}

private static class DefensiveExceptionTester implements ExceptionTester {
    public void test(int nLoops, int pctError, Blackhole bh) {
        ArrayList<String> al = new ArrayList<>();
        for (int i = 0; i < nLoops; i++) {
            Object o = null;
            if ((i % pctError) != 0) {
                o = new Object();
            }
            if (o != null) {
                al.add(o.toString());
            }
        }
        bh.consume(al);
    }
}

每个方法都会创建一个从新创建的对象中生成的任意字符串数组。该数组的大小将根据需要抛出的异常数目而变化。

表 12-9 显示了在最坏情况下(pctError 为 1,每次调用生成一个异常,结果是一个空列表)完成每种方法的时间,例子代码可能是浅层(意味着只有 3 个类在堆栈上)或者深层(意味着在堆栈上有 100 个类)。

表 12-9. 处理异常所需的时间(100%)

方法 浅层时间 深层时间
已检查的异常 24031 ± 127 μs 30613 ± 329 μs
未经检查的异常 21181 ± 278 μs 21550 ± 323 μs
防御性编程 21088 ± 255 μs 21262 ± 622 μs

该表显示了三个有趣的点。首先,在检查异常的情况下,浅层情况和深层情况之间的时间差异显著。构建堆栈跟踪需要时间,这取决于堆栈深度。

但第二种情况涉及未经检查的异常,在 JVM 创建空指针解引用异常时。发生的情况是编译器在某个时刻优化了系统生成的异常情况;JVM 开始重用同一个异常对象,而不是每次需要时都创建新的异常对象。无论调用堆栈如何,该对象每次执行相关代码时都被重用,并且异常实际上不包含调用堆栈(即 printStackTrace() 方法没有输出)。这种优化在完全抛出完整堆栈异常相当长时间后才会发生,因此,如果您的测试用例不包括足够的预热周期,您将看不到其效果。

最后,考虑没有抛出异常的情况:注意到它与未检查的异常情况几乎具有相同的性能。这种情况在这个实验中起到了控制作用:测试会进行大量的工作来创建对象。防御性编程和其他情况之间的区别在于实际花费在创建、抛出和捕获异常上的时间。因此,总体时间非常短。在 100,000 次调用中平均下来,个体执行时间的差异几乎不会被注意到(请注意,这是最坏的情况示例)。

所以,对于不慎使用异常而言,性能惩罚要比预期的小得多,而对于大量相同系统异常的惩罚几乎不存在。然而,在某些情况下,你可能会遇到只是简单地创建了太多异常的代码。由于性能惩罚来自于填充堆栈跟踪信息,可以设置-XX:-StackTraceInThrowable标志(默认为true)以禁用堆栈跟踪信息的生成。

这通常不是一个好主意:堆栈跟踪存在是为了使我们能够分析发生意外错误的原因。启用此标志后,这种能力就会丢失。实际上有代码检查堆栈跟踪并根据其中的信息决定如何从异常中恢复。这本身就是一个问题,但问题的关键是禁用堆栈跟踪可能会神秘地破坏代码。

JDK 本身存在一些 API,异常处理可能会导致性能问题。当从集合类中检索不存在的项时,许多集合类会抛出异常。例如,当调用pop()方法时,如果堆栈为空,则Stack类会抛出EmptyStackException。在这种情况下,通常最好通过首先检查堆栈长度来使用防御性编程。(另一方面,与许多集合类不同,Stack类支持null对象,因此pop()方法不能返回null来指示空堆栈。)

在 JDK 中最臭名昭著的一个例子是类加载中对异常使用的质疑:当ClassLoader类的loadClass()方法试图加载它无法找到的类时会抛出ClassNotFoundException。这实际上并不是一个异常情况。一个单独的类加载器不应该知道如何加载应用程序中的每个类,这就是为什么有类加载器层次结构的原因。

在一个存在数十个类加载器的环境中,这意味着在搜索类加载器层次结构以找到知道如何加载给定类的类加载器时会创建大量的异常。在我曾经使用过的非常大的应用服务器中,禁用堆栈跟踪生成可以加快启动时间多达 3%。这些服务器从数百个 JAR 文件中加载超过 30,000 个类;这当然是一种因人而异的情况。³

快速总结

  • 异常处理并不一定是处理昂贵的操作,但应该在适当的时候使用。

  • 堆栈越深,处理异常的代价就越高。

  • JVM 将优化频繁创建的系统异常的堆栈惩罚。

  • 禁用异常中的堆栈跟踪有时可以提高性能,尽管在这个过程中通常会丢失关键信息。

日志记录

日志记录是性能工程师既爱又恨的事情之一,或者(通常)两者都是。每当我被问及为什么程序运行不佳时,我首先要求提供任何可用的日志,希望应用程序产生的日志可以提供关于应用程序正在执行的操作的线索。每当我被要求审查工作代码的性能时,我立即建议关闭所有日志记录语句。

这里涉及多个日志。JVM 生成其自己的日志语句,其中最重要的是 GC 日志(参见第六章)。该日志可以定向输出到一个独立的文件,文件的大小可以由 JVM 管理。即使在生产代码中,GC 日志(即使启用详细日志记录)的开销非常低,并且如果出现问题,预期的好处非常大,因此应始终打开。

HTTP 服务器生成的访问日志在每个请求时都会更新。该日志通常会产生显著影响:关闭该日志记录肯定会改善针对应用服务器运行的任何测试的性能。从诊断性的角度来看,当出现问题时,这些日志(根据我的经验)通常没有太大帮助。然而,从业务需求的角度来看,该日志通常至关重要,因此必须保持启用状态。

虽然它不是 Java 的标准,但许多 HTTP 服务器支持 Apache mod_log_config约定,允许您为每个请求指定要记录的信息(不遵循mod_log_config语法的服务器通常支持另一种日志自定义)。关键是尽量记录尽可能少的信息,同时满足业务需求。日志的性能取决于写入的数据量。

特别是在 HTTP 访问日志中(以及一般来说,在任何类型的日志中),建议以数字形式记录所有信息:使用 IP 地址而不是主机名,时间戳(例如,自纪元以来的秒数)而不是字符串数据(例如,“2019 年 6 月 3 日星期一 17:23:00 -0600”),等等。尽量减少需要时间和内存计算的数据转换,以便系统的影响也最小化。日志始终可以进行后处理以提供转换后的数据。

对于应用程序日志,我们应该牢记三个基本原则。首先是在记录数据和记录级别之间保持平衡。JDK 中有七个标准的日志记录级别,并且默认情况下记录器配置为输出其中的三个级别(INFO及更高)。这经常在项目中造成混淆:INFO级别的消息听起来应该是相当常见的,并且应该提供应用程序流程的描述("现在我正在处理任务 A","现在我在执行任务 B"等等)。特别是对于大量线程和可伸缩的应用程序,这样多的日志记录会对性能产生不利影响(更不用说过于啰嗦而无用了)。不要害怕使用更低级别的日志记录语句。

类似地,当代码提交到组仓库时,考虑项目使用者的需求,而不是作为开发者的个人需求。我们都希望在代码集成到更大系统并通过一系列测试后能够得到很多有用的反馈,但如果一条消息对最终用户或系统管理员来说没有意义,默认启用它并不会有所帮助。这只会减慢系统速度(并使最终用户感到困惑)。

第二个原则是使用细粒度的记录器。每个类一个记录器可能配置起来有些繁琐,但能够更精确地控制日志输出通常是值得的。在小模块中为一组类共享一个记录器是一个不错的折衷方案。请记住,生产环境中的问题——特别是在负载较大或与性能相关的问题——如果环境发生显著变化,可能会很难复现。打开过多的日志记录通常会改变环境,使得原始问题不再显现。

因此,您必须能够仅为一小部分代码(至少最初只是一小部分FINE级别的日志语句,然后是更多的FINERFINEST级别的语句)打开日志记录,以确保不影响代码的性能。

在这两个原则之间,应该可以在生产环境中启用小型消息子集而不影响系统性能。这通常是一个要求:生产系统管理员可能不会在降低系统性能的情况下启用日志记录,如果系统变慢,那么再现问题的可能性就会降低。

在向代码引入日志记录时的第三个原则是记住,编写具有意外副作用的日志记录代码是很容易的,即使未启用日志记录也是如此。这是另一种情况下,“过早”优化代码是一个好事情的例子:正如第一章的例子所示,记得在需要记录的信息包含方法调用、字符串连接或任何其他类型的分配(例如,为 MessageFormat 参数分配 Object 数组)时,始终使用 isLoggable() 方法。

快速总结

  • 代码应包含大量日志记录,以便用户了解其功能,但默认情况下不应启用任何日志记录。

  • 在调用记录器之前不要忘记测试日志记录级别,如果记录器的参数需要方法调用或对象分配。

Java 集合 API

Java 的集合 API 非常广泛;它至少拥有 58 个集合类。在编写应用程序时,选择适当的集合类以及适当使用集合类,是重要的性能考量。

使用集合类的第一条规则是选择适合应用程序算法需求的集合类。这些建议并不局限于 Java;它实际上是数据结构的基础知识。LinkedList 不适合搜索;如果需要随机访问数据,请将集合存储在 HashMap 中。如果数据需要保持排序状态,请使用 TreeMap 而不是尝试在应用程序中对数据进行排序。如果数据将通过索引进行访问,请使用 ArrayList,但如果需要经常在数组中间插入数据,则不要使用 ArrayList。等等……选择哪种集合类的算法选择非常关键,但在 Java 中的选择与其他编程语言中的选择并无不同。

在使用 Java 集合时,需要考虑一些特殊情况。

同步与非同步

默认情况下,几乎所有 Java 集合都是非同步的(主要的例外是 HashtableVector 及其相关类)。

第九章提出了一个微基准测试,比较基于 CAS 的保护与传统同步。这在多线程情况下是不切实际的,但如果所讨论的数据始终由单个线程访问,那么完全不使用任何同步会有什么影响呢?表 12-10 显示了该比较结果。由于没有尝试模拟争用,因此在这种情况下的微基准测试在这一个特定情况下是有效的:当不存在争用时,并且所讨论的问题是“过度同步”访问资源的成本。

表 12-10. 同步访问与非同步访问的性能

模式 单次访问 10,000 次访问
CAS 操作 22.1 ± 11 ns 209 ± 90 μs
同步方法 20.8 ± 9 ns 179 ± 95 μs
非同步方法 15.8 ± 5 ns 104 ± 55 μs

使用任何数据保护技术相对于简单的非同步访问都会有一些小的惩罚。就像使用微基准测试一样,差异微小:大约在 5-8 纳秒的数量级上。如果所讨论的操作在目标应用程序中执行频率足够高,则性能惩罚会有些明显。在大多数情况下,这种差异将被应用程序其他领域的远大于此的效率低下所抵消。还要记住,这里的绝对数字完全取决于运行测试的目标机器(我的家用机器带有 AMD 处理器);为了获得更真实的测量结果,需要在与目标环境相同的硬件上运行测试。

因此,在同步列表(例如从Collections类的synchronizedList()方法返回的列表)和非同步ArrayList之间进行选择,应该使用哪一个?对ArrayList的访问速度稍快,而且根据列表的访问频率不同,可能会产生可测量的性能差异。(正如在第九章中指出的,对同步方法的过度调用也可能对某些硬件平台的性能产生负面影响。)

另一方面,这假设代码永远不会被多个线程访问。今天可能是这样,但明天呢?如果可能会改变,最好现在使用同步集合,并减轻由此产生的任何性能影响。这是一个设计选择,未来是否使代码具有线程安全性值得花费时间和精力将取决于正在开发的应用程序的情况。

集合大小调整

集合类设计为容纳任意数量的数据元素,并根据需要进行扩展,随着新项添加到集合中。适当调整集合的大小对其整体性能可能很重要。

尽管 Java 中集合类提供的数据类型非常丰富,但在基本水平上,这些类必须仅使用 Java 基本数据类型来保存其数据:数字(integerdouble等),对象引用以及这些类型的数组。因此,ArrayList包含一个实际数组:

private transient Object[] elementData;

当在ArrayList中添加和删除项时,它们将存储在elementData数组中的所需位置(可能会导致数组中的其他项移动)。同样,HashMap包含一个称为HashMap$Entry的内部数据类型的数组,该数组将每个键值对映射到由键的哈希码指定的数组中的位置。

并非所有集合都使用数组来保存它们的元素;例如,LinkedList 将每个数据元素保存在内部定义的 Node 类中。但是,那些确实使用数组来保存它们的元素的集合类需要特别考虑大小。可以通过查看它的构造函数来判断某个特定类是否属于这一类别:如果它有一个允许指定集合初始大小的构造函数,它就在内部使用数组来存储项目。

对于那些集合类,准确指定初始大小是很重要的。以 ArrayList 的简单示例为例:elementData 数组(默认情况下)将以初始大小为 10 开始。当第 11 个项被插入到 ArrayList 中时,列表必须扩展 elementData 数组。这意味着分配一个新数组,将原始内容复制到该数组中,然后添加新项。例如,HashMap 类使用的数据结构和算法要复杂得多,但原理是相同的:在某个时候,这些内部结构必须调整大小。

ArrayList 类选择通过增加大约现有大小的一半来调整数组大小,因此 elementData 数组的大小将首先为 10,然后为 15,然后为 22,然后为 33,依此类推。无论使用何种算法来调整数组大小(参见侧边栏),这都会导致内存浪费(进而影响应用程序执行 GC 所花费的时间)。此外,每次必须调整数组大小时,都必须执行昂贵的数组复制操作,将内容从旧数组转移到新数组中。

为了最大限度地减少这些性能惩罚,请确保尽可能准确地估计集合的最终大小来构造它们。

集合和内存效率

刚刚看到了集合内存效率不够优化的一个例子:在用于保存集合中元素的后备存储时通常会有一些内存浪费。

对于稀疏使用的集合,这可能特别成问题:那些只有一两个元素的集合。如果广泛使用这些稀疏使用的集合,它们可能会浪费大量内存。解决这个问题的一种方法是在创建集合时调整其大小。另一种方法是考虑在这种情况下是否真的需要集合。

当大多数开发人员被问及如何快速对任何数组进行排序时,他们会提出快速排序作为答案。性能良好的工程师会想知道数组的大小:如果数组足够小,最快的排序方式将是使用插入排序。⁴ 大小是重要的。

类似地,HashMap 是根据键值查找项目的最快方式,但如果只有一个键,与使用简单对象引用相比,HashMap 就过度了。即使有几个键,维护几个对象引用的内存消耗也远远小于完整的 HashMap 对象,这对 GC 的影响是积极的。

快速总结

  • 仔细考虑如何访问集合并选择适当的同步类型。然而,对于内存受保护的集合(特别是使用 CAS-based 保护的集合),无竞争访问的惩罚是最小的;有时最好保险起见。

  • 集合的大小对性能有很大影响:如果集合太大,可能会减慢垃圾收集器;如果集合太小,则可能会导致大量复制和调整大小。

Lambdas 和匿名类

对许多开发人员来说,Java 8 最令人兴奋的特性是添加了 lambda。毫无疑问,lambda 对 Java 开发人员的生产力有巨大的积极影响,尽管这种好处很难量化。但是我们可以检查使用 lambda 构造的代码的性能。

关于 lambda 性能的最基本问题是它们与它们的替代品——匿名类的比较。结果显示几乎没有区别。

使用 lambda 类的典型示例通常以创建匿名内部类的代码开始(通常示例使用Stream而不是此处显示的迭代器;有关Stream类的信息稍后在本节中介绍):

public void calc() {
    IntegerInterface a1 = new IntegerInterface() {
        public int getInt() {
            return 1;
        }
    };
    IntegerInterface a2 = new IntegerInterface() {
        public int getInt() {
            return 2;
        }
    };
    IntegerInterface a3 = new IntegerInterface() {
        public int getInt() {
            return 3;
        }
    };
    sum = a1.get() + a2.get() + a3.get();
}

这与使用 lambda 的以下代码进行比较:

public int calc() {
   IntegerInterface a3 = () -> { return 3; };
   IntegerInterface a2 = () -> { return 2; };
   IntegerInterface a1 = () -> { return 1; };
    return a3.getInt() + a2.getInt() + a1.getInt();
}

Lambda 或匿名类的主体至关重要:如果主体执行任何重要操作,那么操作花费的时间将会压倒 lambda 或匿名类实现中的任何小差异。然而,即使在这种最小的情况下,执行此操作所需的时间基本相同,如表 12-11 所示,尽管随着表达式(即类/lambda 的数量)的增加,确实会出现一些小差异。

表 12-11. 使用 lambda 和匿名类执行calc()方法的时间

实现 1,024 个表达式 3 个表达式
匿名类 781 ± 50 μs 10 ± 1 ns
Lambda 587 ± 27 μs 10 ± 2 ns
静态类 734 ± 21 μs 10 ± 1 ns

在这个例子中典型用法的一个有趣之处是,使用匿名类的代码每次调用方法时都会创建一个新对象。如果方法被频繁调用(如在性能测试中必须这样做),那么许多匿名类的实例会很快被创建和丢弃。正如你在第五章中看到的,这种使用通常对性能影响不大。分配(更重要的是初始化)对象存在非常小的成本,并且由于它们很快被丢弃,它们实际上不会拖慢垃圾收集器。然而,在纳秒级的测量范围内,这些小时间确实会累积起来。

表中的最后一行使用的是预构建对象,而不是匿名类:

private IntegerInterface a1 = new IntegerInterface() {
    public int getInt() {
        return 1;
    }
};
... Similarly for the other interfaces....
public void calc() {
       return a1.get() + a2.get() + a3.get();
   }
}

典型的 lambda 用法在循环的每次迭代中不会创建新对象,这是一些边界情况下 lambda 使用性能优势的地方。即使在这个例子中,差异也是微小的。

快速总结

  • 使用 lambda 还是匿名类的选择应该由编程的便利性决定,因为它们在性能上没有区别。

  • Lambdas 并非作为匿名类实现,因此在类加载行为重要的环境中有一个例外;在这种情况下,lambda 会稍微更快。

流和过滤器性能

Java 8 的另一个关键特性,也是经常与 lambda 一起使用的特性,是新的Stream工具。流的一个重要性能特性是它们可以自动并行化代码。关于并行流的信息可以在第九章找到;本节讨论流和过滤器的一般性能特性。

懒遍历

流的第一个性能优势是它们被实现为惰性数据结构。假设我们有一个股票符号列表,目标是找到列表中第一个不包含字母A的符号。通过流执行此操作的代码如下:

@Benchmark
public void calcMulti(Blackhole bh) {
    Stream<String> stream = al.stream();
    Optional<String> t = stream.filter(symbol -> symbol.charAt(0) != 'A').
        filter(symbol -> symbol.charAt(1) != 'A').
        filter(symbol -> symbol.charAt(2) != 'A').
        filter(symbol -> symbol.charAt(3) != 'A').findFirst();
    String answer = t.get();
    bh.consume(answer);
}

显然,有一个更好的方法可以使用单一的过滤器来实现,但我们将在本节稍后讨论这个问题。现在,考虑在这个例子中实现惰性流的含义。每个filter()方法返回一个新的流,因此在这里实际上有四个逻辑流。

filter()方法事实上并不执行任何操作,只是设置一系列指针。其效果是当在流上调用findFirst()方法时,尚未执行任何数据处理——还没有对数据与字符A进行比较。

相反,findFirst()要求前一个流(从 filter 4 返回)提供一个元素。该流目前没有元素,因此它回调到由 filter 3 产生的流,依此类推。Filter 1 将从数组列表(从技术上讲是从流中)获取第一个元素,并测试其第一个字符是否为A。如果是,则完成回调并将该元素传递到下游;否则,它继续迭代数组直到找到匹配的元素(或耗尽数组)。Filter 2 的行为类似——当回调到 Filter 1 返回时,它测试第二个字符是否不是A。如果是,则完成其回调并将符号传递到下游;如果不是,则再次回调到 Filter 1 获取下一个符号。

所有这些回调听起来可能效率低下,但考虑一下替代方案。急切处理流的算法可能如下所示:

private <T> ArrayList<T> calcArray(List<T> src, Predicate<T> p) {
    ArrayList<T> dst = new ArrayList<>();
    for (T s : src) {
        if (p.test(s))
            dst.add(s);
    }
    return dst;
}

@Benchmark
public void calcEager(Blackhole bh) {
    ArrayList<String> al1 = calcArray(al, 0, 'A');
    ArrayList<String> al2 = calcArray(al1, 1, 'A');
    ArrayList<String> al3 = calcArray(al2, 2, 'A');
    ArrayList<String> al4 = calcArray(al3, 3, 'A');
    String answer = al4.get(0);
    bh.consume(answer);
}

这种替代方案比 Java 实际采用的懒惰实现效率低的原因有两点。首先,它需要创建大量的ArrayList类的临时实例。其次,在懒惰的实现中,一旦findFirst()方法获得一个元素,处理就可以停止了。这意味着只有部分项目实际上需要通过过滤器。相反,急切的实现必须多次处理整个列表,直到创建最后的列表。

因此,在这个例子中,懒惰的实现比替代方案要更高效并不奇怪。在这种情况下,测试正在处理一个按字母顺序排序的、包含 456,976 个四个字母符号的列表。急切的实现在遇到符号BBBB之前只处理了 18,278 个符号就停止了。而迭代器则需要更长时间才能找到答案,如表 12-12 所示,需要两个数量级的时间。

表 12-12. 懒惰与急切过滤器处理时间

实现
过滤器/findFirst 0.76 ± 0.047 毫秒
迭代器/findFirst 108.4 ± 4 毫秒

因此,过滤器比迭代器快得多的一个原因是,它们可以利用算法上的优化机会:懒惰的过滤器实现只需在完成需要的工作后停止处理,处理的数据量较少。

如果整个数据集必须被处理,过滤器和迭代器在这种情况下的基本性能如何?例如,我们稍微改变了测试。之前的例子很好地说明了多个过滤器如何工作,但理想情况下,显而易见的是使用单个过滤器代码性能会更好:

@Benchmark
public void countFilter(Blackhole bh) {
    count = 0;
    Stream<String> stream = al.stream();
    stream.filter(
        symbol -> symbol.charAt(0) != 'A' &&
        symbol.charAt(1) != 'A' &&
        symbol.charAt(2) != 'A' &&
        symbol.charAt(3) != 'A').
          forEach(symbol -> { count++; });
    bh.consume(count);
}

这个例子还改变了最终代码以计算符号的数量,以便整个列表都能被处理。与此同时,急切的实现现在可以直接使用迭代器:

@Benchmark
public void countIterator(Blackhole bh) {
    int count = 0;
    for (String symbol : al) {
      if (symbol.charAt(0) != 'A' &&
          symbol.charAt(1) != 'A' &&
          symbol.charAt(2) != 'A' &&
          symbol.charAt(3) != 'A')
          count++;
      }
    bh.consume(count);
}

即使在这种情况下,懒惰的过滤器实现也比迭代器更快(见表 12-13)。

表 12-13. 单个过滤器与迭代器处理时间对比

实现 所需时间
过滤器 7 ± 0.6 毫秒
迭代器 7.4 ± 3 毫秒

快速总结

  • 过滤器通过允许在迭代数据时中途结束来提供显著的性能优势。

  • 即使整个数据集被处理,单个过滤器的性能也略优于迭代器。

  • 多个过滤器会带来额外开销;务必编写良好的过滤器。

对象序列化

对象序列化 是一种将对象的二进制状态写出的方法,以便稍后可以重新创建它。JDK 提供了一个默认机制来序列化实现了 SerializableExternalizable 接口的对象。实际上几乎每种对象的序列化性能都可以从默认的序列化代码中得到改善,但这绝对是一种在没有充分理由时不应该优化的情况。编写专门的序列化和反序列化代码将花费相当多的时间,并且这样的代码比使用默认序列化更难维护。序列化代码有时候也比较棘手,因此尝试优化它会增加生成错误代码的风险。

临时字段

一般来说,改进对象序列化成本的方法是序列化更少的数据。这可以通过将字段标记为 transient 来实现,默认情况下这些字段不会被序列化。然后类可以提供特殊的 writeObject()readObject() 方法来处理这些数据。如果数据不需要被序列化,将其标记为 transient 就足够了。

覆盖默认的序列化

writeObject()readObject() 方法允许完全控制数据的序列化方式。伴随着极大的控制权而来的是极大的责任:很容易搞砸这个。

要了解为什么序列化优化很棘手,可以看看一个简单的代表位置的 Point 对象的情况:

public class Point implements Serializable {
    private int x;
    private int y;
    ...
}

可以编写特殊序列化的代码如下:

public class Point implements Serializable {
    private transient int x;
    private transient int y;
    ....
    private void writeObject(ObjectOutputStream oos) throws IOException {
        oos.defaultWriteObject();
	oos.writeInt(x);
	oos.writeInt(y);
    }
    private void readObject(ObjectInputStream ois)
	                        throws IOException, ClassNotFoundException {
        ois.defaultReadObject();
	x = ois.readInt();
	y = ois.readInt();
    }
}

在像这样的简单例子中,更复杂的代码不会更快,但它仍然是功能正确的。但要注意在一般情况下使用这种技术时:

public class TripHistory implements Serializable {
    private transient Point[] airportsVisited;
    ....
    // THIS CODE IS NOT FUNCTIONALLY CORRECT
    private void writeObject(ObjectOutputStream oos) throws IOException {
        oos.defaultWriteObject();
        oos.writeInt(airportsVisited.length);
        for (int i = 0; i < airportsVisited.length; i++) {
            oos.writeInt(airportsVisited[i].getX());
            oos.writeInt(airportsVisited[i].getY());
        }
    }

    private void readObject(ObjectInputStream ois)
	                        throws IOException, ClassNotFoundException {
        ois.defaultReadObject();
        int length = ois.readInt();
        airportsVisited = new Point[length];
        for (int i = 0; i < length; i++) {
            airportsVisited[i] = new Point(ois.readInt(), ois.readInt();
        }
    }
}

在这里,airportsVisited 字段是一个数组,记录了我曾经飞过或从中飞出的所有机场,按照我访问它们的顺序排列。因此,像 JFK 这样的机场在数组中频繁出现;SYD 目前只出现了一次。

因为写入对象引用的成本很高,所以这段代码肯定比默认的序列化机制更快:在我的机器上,一个包含 100,000 个 Point 对象的数组在序列化时需要 15.5 ± 0.3 毫秒,反序列化时需要 10.9 ± 0.5 毫秒。使用这种“优化”方法,序列化只需 1 ± 0.600 毫秒,反序列化只需 0.85 ± 0.2 微秒。

然而,这段代码是不正确的。在序列化之前,一个单一的对象表示 JFK,并且该对象的引用多次出现在数组中。在数组被序列化然后再次反序列化之后,多个对象表示 JFK。这改变了应用程序的行为。

在这个例子中,当数组被反序列化时,那些指向 JFK 的引用最终变成了单独的、不同的对象。现在,当这些对象中的一个被更改时,只有那个对象被更改了,并且它最终拥有与其他引用 JFK 的对象不同的数据。

这是一个重要的原则要牢记,因为优化序列化通常涉及对象引用的特殊处理。如果处理得当,这可以极大地提高序列化代码的性能。如果处理不当,可能会引入难以察觉的错误。

考虑到这一点,让我们探讨StockPriceHistory类的序列化,看看如何进行序列化优化。该类中的字段包括以下内容:

public class StockPriceHistoryImpl implements StockPriceHistory {
    private String symbol;
    protected SortedMap<Date, StockPrice> prices = new TreeMap<>();
    protected Date firstDate;
    protected Date lastDate;
    protected boolean needsCalc = true;
    protected BigDecimal highPrice;
    protected BigDecimal lowPrice;
    protected BigDecimal averagePrice;
    protected BigDecimal stdDev;
    private Map<BigDecimal, ArrayList<Date>> histogram;
    ....
    public StockPriceHistoryImpl(String s, Date firstDate, Date lastDate) {
        prices = ....
    }
}

当为给定符号s构建股票历史记录时,对象创建并存储了一个按日期排序的prices映射,其中包含startend之间所有价格的日期。代码还保存了firstDatelastDate。构造函数不填充任何其他字段;它们是惰性初始化的。当调用这些字段中的任何一个 getter 时,getter 会检查needsCalc是否为true。如果是,它将必要时一次性计算剩余字段的适当值。

此计算包括创建histogram,记录股票以特定价格收盘的天数。直方图包含与prices映射中相同的数据(以BigDecimalDate对象表示),只是以不同的方式查看数据。

因为所有惰性初始化的字段都可以从prices数组计算得出,它们都可以标记为transient,因此在序列化或反序列化它们时不需要特殊处理。在这种情况下,示例很容易,因为代码已经在字段的惰性初始化上进行了处理;它可以在接收数据时重复执行这种惰性初始化。即使代码急切地初始化了这些字段,仍然可以将任何计算出的字段标记为transient,并在类的readObject()方法中重新计算它们的值。

还要注意,这保留了priceshistogram对象之间的对象关系:当重新计算直方图时,它将只向新映射中插入现有对象。

这种优化几乎总是一件好事,但在某些情况下可能会影响性能。表 12-14 显示了序列化和反序列化这种情况时,histogram对象是 transient 和 nontransient 的时间,以及每种情况下序列化数据的大小。

表 12-14。对象带有 transient 字段的序列化和反序列化时间

对象 序列化时间 反序列化时间 数据大小
没有 transient 字段 19.1 ± 0.1 毫秒 16.8 ± 0.4 毫秒 785,395 字节
transient 直方图 16.7 ± 0.2 毫秒 14.4 ± 0.2 毫秒 754,227 字节

到目前为止,这个示例节省了大约 15%的总序列化和反序列化时间。但是此测试实际上还没有在接收端重新创建histogram对象。当接收端代码首次访问它时,该对象将被创建。

有时histogram对象可能不会被需要;接收方可能只对特定日期的价格感兴趣,而不关心直方图。这就是更不寻常的情况:如果histogram总是需要,并且计算直方图花费超过 2.4 毫秒,那么使用延迟初始化字段的情况实际上会导致性能下降。

在这种情况下,计算直方图并不属于那种情况——它是一个非常快速的操作。一般来说,可能很难找到重新计算数据比序列化和反序列化数据更昂贵的情况。但在优化代码时需要考虑这一点。

这个测试实际上并不传输数据;数据写入和读取都是从预分配的字节数组进行的,因此只测量了序列化和反序列化的时间。但是,请注意,将histogram字段设为瞬态也节省了大约 13%的数据大小。如果要通过网络传输数据,这一点将非常重要。

压缩序列化数据

代码的序列化性能可以通过第三种方式进行改进:压缩序列化数据以便更快地传输。在股票历史类中,通过在序列化过程中压缩prices映射来实现:

public class StockPriceHistoryCompress
	implements StockPriceHistory, Serializable {

    private byte[] zippedPrices;
    private transient SortedMap<Date, StockPrice> prices;

    private void writeObject(ObjectOutputStream out)
    		throws IOException {
        if (zippedPrices == null) {
	    makeZippedPrices();
	}
	out.defaultWriteObject();
    }

    private void readObject(ObjectInputStream in)
    		throws IOException, ClassNotFoundException {
        in.defaultReadObject();
        unzipPrices();
    }

    protected void makeZippedPrices() throws IOException {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        GZIPOutputStream zip = new GZIPOutputStream(baos);
        ObjectOutputStream oos = new ObjectOutputStream(
		new BufferedOutputStream(zip));
        oos.writeObject(prices);
        oos.close();
        zip.close();
        zippedPrices = baos.toByteArray();
    }

    protected void unzipPrices()
    		throws IOException, ClassNotFoundException {
        ByteArrayInputStream bais = new ByteArrayInputStream(zippedPrices);
        GZIPInputStream zip = new GZIPInputStream(bais);
        ObjectInputStream ois = new ObjectInputStream(
		new BufferedInputStream(zip));
        prices = (SortedMap<Date, StockPrice>) ois.readObject();
        ois.close();
        zip.close();
    }
}

zipPrices()方法将价格映射序列化为字节数组并保存生成的字节,在writeObject()方法中调用defaultWriteObject()方法时会将其正常序列化。(实际上,只要自定义了序列化,将zippedPrices数组设为瞬态并直接写出其长度和字节会略微更好。但这个示例代码更清晰,简单更好。)在反序列化时,执行反向操作。

如果目标是将数据序列化为字节流(如原始示例代码中),这是一个失败的建议。这并不令人惊讶;压缩字节所需的时间远远长于将它们写入本地字节数组的时间。这些时间显示在表格 12-15 中。

表格 12-15. 使用压缩序列化和反序列化 10,000 个对象所需的时间

使用案例 序列化时间 反序列化时间 数据大小
无压缩 16.7 ± 0.2 毫秒 14.4 ± 0.2 毫秒 754,227 字节
压缩/解压缩 43.6 ± 0.2 毫秒 18.7 ± 0.5 毫秒 231,844 字节
仅压缩 43.6 ± 0.2 毫秒 .720 ± 0.3 毫秒 231,844 字节

这张表格最有趣的一点在于最后一行。在这个测试中,数据在发送之前被压缩,但readObject()方法中并未调用unzipPrices()方法。相反,它在需要的时候才会被调用,这将是接收方首次调用getPrice()方法的时候。如果没有这个调用,反序列化时只需要处理少量的BigDecimal对象,速度相当快。

在这个例子中,接收者可能根本不需要实际的价格数据:接收者可能只需要调用 getHighPrice() 等方法来检索关于数据的聚合信息。只要这些方法是所需的全部内容,延迟解压缩 prices 信息可以节省大量时间。如果正在持久化的对象是需要的(例如,如果它是 HTTP 会话状态,作为备份副本存储以防应用服务器失败),那么延迟解压缩数据既可以节省 CPU 时间(跳过解压缩)又可以节省内存(因为压缩数据占用的空间更少)。

因此——特别是如果目标是节省内存而不是时间——对序列化的数据进行压缩,然后延迟解压缩可能是有用的。

如果序列化的目的是在网络上传输数据,我们会根据网络速度做出通常的权衡。在快速网络上,压缩所花费的时间很可能比传输更少数据所节省的时间长;而在较慢的网络上,情况可能恰恰相反。在这种情况下,我们将传输大约少了 500,000 字节的数据,因此可以根据传输这么多数据的平均时间来计算成本或节省。在这个例子中,我们将花费大约 40 毫秒来压缩数据,这意味着我们需要传输少约 500,000 字节的数据。在 100 Mbit/秒的网络上,这种情况下是打平的,意味着慢速的公共 WiFi 将会从启用压缩中受益,但更快的网络则不会。

跟踪重复对象

"对象序列化" 以一个例子开始,说明了如何不序列化包含对象引用的数据,以免在反序列化时破坏对象引用。然而,在 writeObject() 方法中可能实现的更强大的优化之一是不写出重复的对象引用。对于 StockPriceHistoryImpl 类而言,这意味着不会写出 prices 映射的重复引用。因为示例中使用了该映射的标准 JDK 类,我们不必担心这个问题:JDK 类已经被优化为最佳序列化它们的数据。尽管如此,深入了解这些类如何执行它们的优化仍然是有益的,以便理解可能的优化方式。

StockPriceHistoryImpl 类中,关键结构是一个 TreeMap。该映射的简化版本出现在图 12-2 中。使用默认序列化,JVM 将为节点 A 写出其原始数据字段;然后对节点 B(然后是节点 C)递归调用 writeObject() 方法。节点 B 的代码将写出其原始数据字段,然后递归写出其 parent 字段的数据。

树图结构

图 12-2. 简单的 TreeMap 结构

但是请等一下——那个 parent 字段是节点 A,它已经被写入。对象序列化代码足够智能以意识到这一点:它不会重新写入节点 A 的数据。相反,它只是向先前写入的数据添加一个对象引用。

跟踪先前写入的对象集合以及所有递归操作会对对象序列化造成轻微的性能损失。然而,正如在一个 Point 对象数组示例中所演示的那样,这是无法避免的:代码必须跟踪先前写入的对象并恢复正确的对象引用。不过,可以通过抑制可以在反序列化时轻松重新创建的对象引用来执行智能优化。

不同的集合类处理方式各不相同。例如,TreeMap 类只需遍历树并仅写入键和值;序列化过程会丢弃关于键之间关系(即它们的排序顺序)的所有信息。当数据被反序列化后,readObject() 方法会重新对数据进行排序以生成树。虽然再次对对象进行排序听起来可能会很昂贵,但实际上并非如此:在一个包含 10,000 个股票对象的集合上,这一过程比使用默认对象序列化快约 20%。

TreeMap 类也从这种优化中受益,因为它可以写出更少的对象。在地图中,一个节点(或者在 JDK 语言中称为 Entry)包含两个对象:键和值。因为地图不能包含两个相同的节点,序列化代码不需要担心保留对节点的对象引用。在这种情况下,它可以跳过写入节点对象本身,直接写入键和值对象。因此,writeObject() 方法最终看起来像这样(语法适应阅读的便利性):

private void writeObject(ObjectOutputStream oos) throws IOException {
    ....
    for (Map.Entry<K,V> e : entrySet()) {
        oos.writeObject(e.getKey());
        oos.writeObject(e.getValue());
    }
    ....
}

这看起来非常像对 Point 示例不起作用的代码。在这种情况下的不同之处在于,当这些对象可能相同时,代码仍然会写出对象。TreeMap 不能有两个相同的节点,因此不需要写出节点引用。然而,TreeMap 可以 有两个相同的值,因此必须将值作为对象引用写出。

这将我们带回了起点:正如我在本节开头所述,正确进行对象序列化优化可能会有些棘手。但是,当对象序列化在应用程序中成为显著的瓶颈时,正确优化它确实可以带来重要的好处。

快速总结

  • 数据的序列化可能是一个性能瓶颈。

  • 将实例变量标记为 transient 将使序列化更快,并减少要传输的数据量。这两者通常都会带来显著的性能提升,除非在接收端重新创建数据需要很长时间。

  • 通过 writeObject()readObject() 方法的其他优化可以显著加快序列化的速度。在使用时要小心,因为很容易出错并引入微妙的 bug。

  • 即使数据不会通过缓慢的网络传输,压缩序列化数据通常也是有益的。

总结

本节对 Java SE JDK 的关键领域进行了介绍,这也结束了我们对 Java 性能的考察。本章的大多数主题之一是展示了 JDK 本身性能的演进。随着 Java 作为一个平台的发展和成熟,其开发人员发现,重复生成的异常不需要花费时间提供线程堆栈;使用线程本地变量来避免随机数生成器同步是一个好的选择;ConcurrentHashMap 的默认大小太大等等。

连续不断的改进过程正是 Java 性能调优的全部内容。从调优编译器和垃圾收集器,到有效使用内存,理解 API 中关键性能差异,等等,本书中的工具和流程将允许您在自己的代码中提供类似的持续改进。

¹ Chacun à son goût 是(抱歉,约翰·施特劳斯二世)歌剧爱好者说“YMMV”(你的效果可能会有所不同)的方式。

² 由于早期 Java 版本中的一个 bug,有时建议将此标志设置为 /dev/./urandom 或其他变体。在 Java 8 及更高版本的 JVM 中,您可以简单地使用 /dev/urandom

³ 或者我应该说:Chacun à son goût

⁴ 快速排序的实现通常会在小数组中使用插入排序;在 Java 中,Arrays.sort() 方法假定任何少于 47 个元素的数组都可以通过插入排序比快速排序更快地排序。

附录:调优标志摘要

本附录涵盖了常用标志并指导何时使用它们。常用 在这里包括了在早期 Java 版本中常用且不再推荐的标志;旧版本 Java 的文档和提示可能会推荐这些标志,因此在这里进行了提及。

表 A-1. 调整即时编译器的标志

标志 功能 使用时机 参见
-server 此标志不再起作用,会被默默忽略。 不适用 “分层编译”
-client 此标志不再起作用,会被默默忽略。 不适用 “分层编译”
-XX:+TieredCompilation 使用分层编译。 除非内存严重受限,否则始终使用。 “分层编译” 和 “分层编译的权衡”
-XX:ReservedCodeCacheSize=<MB> 为 JIT 编译器编译的代码保留空间。 运行大型程序时,如果看到代码缓存不足的警告。 “调整代码缓存”
-XX:InitialCodeCacheSize=<MB> 为 JIT 编译器编译的代码分配初始空间。 如果需要预先分配代码缓存的内存(这种情况很少见)。 “调整代码缓存”
-XX:CompileThreshold=<N> 设置方法或循环执行多少次后进行编译。 此标志已不再推荐使用。 “编译阈值”
-XX:+PrintCompilation 提供 JIT 编译器操作的日志。 当怀疑某个重要方法未被编译,或者对编译器的操作感到好奇时。 “检查编译过程”
-XX:CICompilerCount=<N> 设置 JIT 编译器使用的线程数。 当启动了过多的编译器线程时。主要影响运行多个 JVM 的大型机器。 “编译线程”
-XX:+DoEscapeAnalysis 启用编译器的激进优化。 在罕见情况下可能引发崩溃,因此有时建议禁用。除非确定它引起了问题,否则不要禁用。 “逃逸分析”
-XX:UseAVX=<N> 设置在 Intel 处理器上使用的指令集。 在 Java 11 早期版本中应将此设置为 2;在后续版本中,默认为 2。 “特定于 CPU 的代码”
-XX:AOTLibrary=<path> 使用指定库进行预编译。 在某些有限情况下,可能加速初始程序执行。仅在 Java 11 中为实验特性。 “预编译”

表 A-2. 选择 GC 算法的标志

Flag 它的作用 何时使用它 另请参阅
--- --- --- ---
-XX:+UseSerialGC 使用简单的单线程 GC 算法。 适用于单核虚拟机和容器,或者小(100 MB)堆。 “串行垃圾收集器”
-XX:+UseParallelGC 使用多线程收集年轻代和老年代,同时应用程序线程停止。 用于通过吞吐量调优而不是响应性;Java 8 的默认选项。 “吞吐量收集器”
-XX:+UseG1GC 使用多线程收集年轻代,同时应用程序线程停止,以及后台线程从老年代中删除垃圾,最小化暂停时间。 当您有可用的 CPU 用于后台线程,并且不希望出现长时间的 GC 暂停时使用。Java 11 的默认选项。 “G1 GC 收集器”
-XX:+UseConcMarkSweepGC 使用后台线程从老年代中删除垃圾,最小化暂停时间。 不再推荐使用;请改用 G1 GC。 “CMS 收集器”
-XX:+UseParNewGC 与 CMS 一起,使用多线程收集年轻代,同时应用程序线程停止。 不再推荐使用;请改用 G1 GC。 “CMS 收集器”
-XX:+UseZGC 使用实验性的 Z 垃圾收集器(仅适用于 Java 12)。 为了减少年轻代 GC 的暂停时间,可以同时收集。 “并发压缩:ZGC 和 Shenandoah”
-XX:+UseShenandoahGC 使用实验性的 Shenandoah 垃圾收集器(仅适用于 Java 12 OpenJDK)。 为了减少年轻代 GC 的暂停时间,可以同时收集。 “并发压缩:ZGC 和 Shenandoah”
-XX:+UseEpsilonGC 使用实验性的 Epsilon 垃圾收集器(仅适用于 Java 12)。 如果您的应用程序从不需要执行 GC。 “无收集:Epsilon GC”

表 A-3. 所有 GC 算法共同的标志

Flag 它的作用 何时使用它 另请参阅
--- --- --- ---
-Xms 设置堆的初始大小。 当默认的初始大小对您的应用程序来说太小时。 “调整堆大小”
-Xmx 设置堆的最大大小。 当默认的最大大小对您的应用程序来说太小(或可能太大)时。 “调整堆大小”
-XX:NewRatio 设置年轻代与老年代的比例。 增加此比例以减少分配给年轻代的堆空间比例;降低此比例以增加分配给年轻代的堆空间比例。这只是一个初始设置;除非关闭自适应大小调整,否则比例将会变化。随着年轻代大小的减少,您将看到更频繁的年轻代 GC 和较少的完全 GC(反之亦然)。 “调整代大小”
-XX:NewSize 设置年轻代的初始大小。 当您已经精确调整了应用程序的需求时。 “代际大小调整”
-XX:MaxNewSize 设置年轻代的最大大小。 当您已经精确调整了应用程序的需求时。 “代际大小调整”
-Xmn 设置年轻代的初始和最大大小。 当您已经精确调整了应用程序的需求时。 “代际大小调整”
-XX:MetaspaceSize=*N* 设置元空间的初始大小。 对于使用大量类的应用程序,可以增加此值以超过默认值。 “大小调整元空间”
-XX:MaxMetaspaceSize=*N* 设置元空间的最大大小。 将此数字降低以限制类元数据使用的本机空间量。 “大小调整元空间”
-XX:ParallelGCThreads=*N* 设置垃圾收集器用于前台活动(例如收集年轻代和对吞吐量 GC 来说,收集老年代)的线程数。 在运行多个 JVM 或者在 Java 8 更新 192 之前的 Docker 容器中,可以将此值降低。考虑在非常大的系统上增加这个值以支持非常大的堆的 JVM。 “控制并行度”
-XX:+UseAdaptiveSizePolicy 设置后,JVM 将调整各种堆大小以尝试满足 GC 目标。 如果堆大小已经精确调整,请关闭此选项。 “自适应大小调整”
-XX:+PrintAdaptiveSizePolicy 向 GC 日志添加有关代的调整大小信息。 使用此标志可以了解 JVM 的操作方式。使用 G1 时,检查此输出以查看是否通过巨大对象分配触发了完整 GC。 “自适应大小调整”
-XX:+PrintTenuringDistribution 将续期信息添加到 GC 日志中。 使用续期信息确定是否以及如何调整续期选项。 “续期和幸存者空间”
-XX:InitialSurvivorRatio=*N* 设置年轻代中专门用于幸存者空间的空间量。 如果短生命周期对象频繁晋升到老年代,可以增加此值。 “续期和幸存者空间”
-XX:MinSurvivorRatio=*N* 设置年轻代中专用于幸存者空间的自适应空间量。 减少此值会减少幸存者空间的最大大小(反之亦然)。 “续期和幸存者空间”
-XX:TargetSurvivorRatio=*N* JVM 尝试保持在幸存者空间中的空闲空间量。 增加此值会减少幸存者空间的大小(反之亦然)。 “续期和幸存者空间”
-XX:InitialTenuringThreshold=*N* JVM 尝试保持对象在 survivor 空间中的初始 GC 周期数。 增加此数字以使对象在 survivor 空间中保持更长时间,尽管要注意 JVM 会对其进行调整。 “Tenuring 和 Survivor Spaces”
-XX:MaxTenuringThreshold=*N* JVM 尝试保持对象在 survivor 空间中的最大 GC 周期数。 增加此数字以使对象在 survivor 空间中保持更长时间;JVM 将在此值和初始阈值之间调整实际阈值。 “Tenuring 和 Survivor Spaces”
-XX:+DisableExplicitGC> 阻止对System.gc()的调用产生任何效果。 用于防止糟糕的应用程序显式执行 GC。 “Causing 和 Disabling Explicit Garbage Collection”
-XX:-AggressiveHeap 启用了一组对具有大量内存的机器以及运行单个具有大堆的 JVM 进行了“优化”的调整标志。 最好不要使用此标志,而是根据需要使用特定的标志。 “AggressiveHeap”

表 A-4。控制 GC 日志记录的标志

标志 作用 何时使用 另请参阅
-Xlog:gc* 控制 Java 11 中的 GC 日志记录。 应始终启用 GC 日志记录,即使在生产中也是如此。 与 Java 8 的以下一组标志不同,此标志控制 Java 11 GC 日志记录的所有选项; 有关将此选项映射到 Java 8 标志的文本,请参阅文本。 “GC 工具”
-verbose:gc 在 Java 8 中启用基本的 GC 日志记录。 应始终启用 GC 日志记录,但通常最好使用其他更详细的日志记录。 “GC 工具”
-Xloggc:<path> 在 Java 8 中,将 GC 日志定向到特殊文件而不是标准输出。 始终如此,以更好地保存日志中的信息。 “GC 工具”
-XX:+PrintGC 在 Java 8 中启用基本的 GC 日志记录。 应始终启用 GC 日志记录,但通常更详细的日志记录更好。 “GC 工具”
-XX:+PrintGCDetails 在 Java 8 中启用详细的 GC 日志记录。 始终如此,即使在生产中(日志记录开销很小)。 “GC 工具”
-XX:+PrintGCTimeStamps 在 Java 8 中,为 GC 日志中的每个条目打印相对时间戳。 始终如此,除非启用了日期时间戳。 “GC 工具”
-XX:+PrintGCDateStamps 在 Java 8 中为 GC 日志中的每个条目打印时间戳。 比时间戳的开销略大,但可能更容易处理。 “GC 工具”
-XX:+PrintReferenceGC 在 Java 8 中,在 GC 期间打印关于软引用和弱引用处理的信息。 如果程序大量使用这些引用,请添加此标志以确定它们对 GC 开销的影响。 “软引用、弱引用及其他引用”
-XX:+UseGCLogFileRotation 启用 GC 日志的轮转以节省文件空间在 Java 8 中。 在生产系统中,运行时间长达数周时,GC 日志可能会占用大量空间。 “GC 工具”
-XX:NumberOfGCLogFiles=*N* 当在 Java 8 中启用日志文件轮转时,指示要保留的日志文件数。 在生产系统中,运行时间长达数周时,GC 日志可能会占用大量空间。 “GC 工具”
-XX:GCLogFileSize=*N* 当在 Java 8 中启用日志文件轮转时,指示每个日志文件在轮转之前的大小。 在生产系统中,运行时间长达数周时,GC 日志可能会占用大量空间。 “GC 工具”

表 A-5. 吞吐量收集器的标志

标志 功能 使用时机 参见
--- --- --- ---
-XX:MaxGCPauseMillis=*N* 给吞吐量收集器一个提示,暂停时间应该是多长;堆的大小动态调整以尝试达到该目标。 如果默认计算出的堆大小不符合应用程序目标,作为调优吞吐量收集器的第一步。 “自适应和静态堆大小调优”
-XX:GCTimeRatio=*N* 给吞吐量收集器一个提示,你愿意在 GC 中花费多少时间;堆的大小动态调整以尝试达到该目标。 如果默认计算出的堆大小不符合应用程序目标,作为调优吞吐量收集器的第一步。 “自适应和静态堆大小调优”

表 A-6. G1 收集器的标志

标志 功能 使用时机 参见
--- --- --- ---
-XX:MaxGCPauseMillis=*N* 给 G1 收集器一个提示,暂停时间应该是多长;G1 算法会调整以尝试达到该目标。 作为调优 G1 收集器的第一步;增加此值以尝试防止 Full GC。 “调优 G1 GC”
-XX:ConcGCThreads=*N* 设置用于 G1 后台扫描的线程数。 当有大量 CPU 可用并且 G1 正在经历并发模式失败时。 “调优 G1 GC”
-XX:InitiatingHeapOccupancyPercent=*N* 设置 G1 后台扫描开始的阈值。 如果 G1 正在经历并发模式失败,请降低此值。 “调优 G1 GC”
-XX:G1MixedGCCountTarget=*N* 设置混合 GC 的次数,G1 尝试释放已标记为主要包含垃圾的区域。 如果 G1 经历并发模式失败,请降低此值;如果混合 GC 周期过长,请增加此值。 “调优 G1 GC”
-XX:G1MixedGCCountTarget=*N* 设置混合 GC 的次数,G1 尝试释放已标记为主要包含垃圾的区域。 如果 G1 经历并发模式失败,请降低此值;如果混合 GC 周期过长,请增加此值。 “调优 G1 GC”
-XX:G1HeapRegionSize=*N* 设置 G1 区域的大小。 对于非常大的堆或应用程序分配非常大的对象,请增加此值。 “G1 GC 区域大小”
-XX:+UseStringDeduplication 允许 G1 消除重复字符串。 适用于有大量重复字符串且国际化不可行的程序。 “重复字符串和字符串国际化”

表 A-7. CMS 收集器标志

标志 功能 使用时机 参见
-XX:CMSInitiating​OccupancyFraction``=*N* 确定 CMS 应在老年代后台扫描开始时刻。 当 CMS 经历并发模式失败时,降低此值。 “理解 CMS 收集器”
-XX:+UseCMSInitiating​OccupancyOnly 导致 CMS 仅使用 CMSInitiatingOccupancyFraction 来确定何时启动 CMS 后台扫描。 每当指定 CMSInitiatingOccupancyFraction 时。 “理解 CMS 收集器”
-XX:ConcGCThreads=*N* 设置用于 CMS 后台扫描的线程数。 当大量 CPU 可用且 CMS 经历并发模式失败时。 “理解 CMS 收集器”
-XX:+CMSIncrementalMode 以增量模式运行 CMS。 不再支持。 N/A

表 A-8. 内存管理标志

标志 功能 使用时机 参见
-XX:+HeapDumpOnOutOfMemoryError 在 JVM 抛出内存溢出错误时生成堆转储。 如果应用程序因堆空间或永久代导致内存溢出错误,请启用此标志,以便分析堆中的内存泄漏。 “内存溢出错误”
-XX:HeapDumpPath=<path> 指定自动生成堆转储时应写入的文件名。 若要指定除了 java_pid.hprof 之外的路径用于在内存溢出错误或 GC 事件时生成的堆转储,请使用此选项。 “内存溢出错误”
-XX:GCTimeLimit=<N> 指定 JVM 在执行太多 GC 周期时不抛出OutOfMemoryException的时间。 降低此值,以便在程序执行太多 GC 周期时,JVM 更早地抛出 OOM 异常。 “内存不足错误”
-XX:HeapFreeLimit=<N> 指定 JVM 必须释放的内存量,以防止抛出OutOfMemoryException 降低此值,以便在程序执行太多 GC 周期时,JVM 更早地抛出 OOM 异常。 “内存不足错误”
-XX:SoftRefLRUPolicyMSPerMB=*N* 控制软引用在被使用后存活的时间。 在低内存条件下,缩短此值以更快地清理软引用。 “软引用、弱引用和其他引用”
-XX:MaxDirectMemorySize=*N* 控制通过ByteBuffer类的allocateDirect()方法分配多少本机内存。 如果要限制程序可以分配的直接内存量,考虑设置此标志。不再需要设置此标志来分配超过 64 MB 的直接内存。 “本机 NIO 缓冲区”
-XX:+UseLargePages 指示 JVM 从操作系统的大页系统中分配页面(如果适用)。 如果操作系统支持,此选项通常会提高性能。 “大页”
-XX:+StringTableSize=*N* 设置 JVM 用于保存国际化字符串的哈希表的大小。 如果应用程序执行大量的字符串国际化,则增加此值。 “重复字符串和字符串国际化”
-XX:+UseCompressedOops 模拟对象引用的 35 位指针。 对于小于 32 GB 的堆,默认是这个值;禁用它永远没有好处。 “压缩 Oops”
-XX:+PrintTLAB 在 GC 日志中打印关于 TLAB 的摘要信息。 在使用不支持 JFR 的 JVM 时,请确保 TLAB 分配工作效率。 “线程本地分配缓冲区”
-XX:TLABSize=*N* 设置 TLAB 的大小。 当应用程序在 TLAB 外执行大量分配时,使用此值来增加 TLAB 的大小。 “线程本地分配缓冲区”
-XX:-ResizeTLAB 禁用 TLAB 的调整大小功能。 每当指定TLABSize时,请确保禁用此标志。 “线程本地分配缓冲区”

表 A-9。本机内存跟踪的标志

标志 作用 使用时机 参见
-XX:NativeMemoryTracking=*X* 启用本机内存跟踪。 当需要查看 JVM 在堆外使用的内存时。 “本机内存跟踪”
-XX:+PrintNMTStatistics 在程序终止时打印本地内存跟踪统计信息。 当需要查看 JVM 在堆外使用的内存时使用。 “本地内存跟踪”

Table A-10. 线程处理标志

Flag What it does When to use it See also
-Xss<N> 设置线程的本机堆栈大小。 减小此大小以为 JVM 的其他部分提供更多内存。 “调整线程堆栈大小”
-XX:-BiasedLocking 禁用 JVM 的偏向锁定算法。 可以改善基于线程池的应用程序的性能。 “偏向锁定”

Table A-11. JVM 杂项标志

Flag What it does When to use it See also
-XX:+CompactStrings 在可能的情况下使用 8 位字符串表示(仅适用于 Java 11)。 默认;始终使用。 “紧凑字符串”
-XX:-StackTraceInThrowable 阻止每次抛出异常时收集堆栈跟踪。 在系统具有非常深的堆栈并且频繁抛出异常的情况下使用(且修复代码以减少异常抛出不可行时)。 “异常”
-Xshare 控制类数据共享。 使用此标志为应用程序代码创建新的 CDS 存档。 “类数据共享”

Table A-12. Java Flight Recorder 标志

Flag What it does When to use it See also
-XX:+FlightRecorder 启用 Java Flight Recorder。 始终建议启用 Flight Recorder,因为除非实际进行记录(在这种情况下,根据使用的功能,开销将有所不同,但仍然相对较小)。 “Java Flight Recorder”
-XX:+FlightRecorderOptions 设置通过命令行进行默认录制的选项(仅适用于 Java 8)。 控制如何为 JVM 进行默认录制。 “Java Flight Recorder”
-XX:+StartFlightRecorder 使用给定的 Flight Recorder 选项启动 JVM。 控制如何为 JVM 进行默认录制。 “Java Flight Recorder”
-XX:+UnlockCommercialFeatures 允许 JVM 使用商业(非开源)功能。 如果具有适当的许可证,则必须设置此标志才能在 Java 8 中启用 Java Flight Recorder。 “Java Flight Recorder”
posted @ 2024-06-15 12:22  绝不原创的飞龙  阅读(33)  评论(0编辑  收藏  举报