Ohhnews

分类导航

$ cd ..
foojay原文

OpenJDK Leyden 项目如何提升 Java 性能(第一部分)

#java#openjdk#leyden项目#性能优化#jvm

目录

在本系列的三篇博文中,我们将解释 OpenJDK Leyden 项目如何帮助改善 Java 在性能方面明显落后于其他语言的一个特定领域,即应用程序的“启动”、“预热”和“初始内存占用”。

第一部分解释了这些术语的含义,以及为什么 Java 在匹配其他语言表现时面临挑战。随后概述了 Leyden 为改善现有 JDK 版本中的启动和预热所做的努力,以及对未来版本的规划。

第二部分介绍了如何使用 Leyden 提供的新功能,并展示了测试结果,这些结果表明已经取得了非常显著的进展,且这种趋势将持续下去。

第三部分详细说明了 Leyden 提出的解决方案是如何运作的,并首次展示了相关工具,这些工具可以帮助您评估由此带来的收益,并对应用程序进行调优,从而充分利用 Leyden 提供的优势。

Java 性能简史

几十年来,Java 一直是最受欢迎的面向对象编程语言之一。它的成功在很大程度上归功于它提供了一个可移植的托管运行时,使得解决许多常见的编程挑战变得简单且安全。特别是,Java 是第一个让程序员能够轻松交付多线程应用程序的可移植语言,这些程序可以在运行时分配和管理存储,而无需担心无效内存访问的风险。

尽管 Java 属于动态语言家族(最著名的成员包括 Lisp、Smalltalk 和 Self),但它依然保持着高人气,这令一些程序员感到惊讶。动态语言允许代码库在程序执行时以增量方式定义。这种代码库通常使用特定于语言的虚拟机来实现。传统上,动态语言通过解释源代码或从源代码派生的中间字节码来执行。这通常会导致性能低于原生编译的非动态语言。

然而,现代 Java 运行时依赖强大的“即时”(JIT)编译器在运行时将字节码转换为原生机器码。JIT 编译是一项近 40 年前在 Smalltalk 中首次尝试的技术,它使 Java 的性能比仅有解释器运行时的早期阶段提高了几个数量级。运行时执行分析的使用支持了反馈驱动优化推测性优化。这使得 Java JIT 编译器能够实现远超提前(AOT)编译程序的峰值性能。

为什么 Java 需要时间才能达到峰值性能

动态类加载和 JIT 编译的缺点是,Java 运行时需要一些时间才能达到这种令人印象深刻的峰值性能。

当一个新的 Java 应用程序启动时,它通常是“冷启动”。应用程序需要使用的所有类和方法的详细信息仅以紧凑的字节码表示形式存在,存储在磁盘上(要么在应用程序提供的类文件中,要么嵌入在 Java 平台的 jmod 文件中)。Java 虚拟机(JVM)必须解析并解包这些字节码,构建其自己的类和方法库的“元数据”模型,供解释器和已编译代码高效运行。在执行任何类的方法之前,它还必须设置每个加载类的基础状态,运行 Java 的“静态初始化”(static init)代码来填充类的静态字段。

此外,JVM 还必须执行动态链接。当 Java 方法的编译或执行首次遇到调用(invoke 字节码)或数据访问(get/putfield 字节码)时,JVM 必须链接该调用或数据访问点。这涉及将字节码中以符号名称形式出现的对目标类和方法/字段的引用,替换为直接的内存引用。这首先识别目标元数据类,然后识别目标元数据方法或字段。如果目标类在执行过程中尚未被遇到,此链接步骤可能会触发进一步的字节码加载、解析和类初始化。

JVM 通常从解释器执行 Java 方法开始。当然,它可以随时执行原生代码,在加载时或首次调用时立即编译 Java 方法字节码。然而,编译完成需要时间,因此通常在解释的同时在后台进行会更好。事实上,选择性地进行 JIT 编译往往收益更大。那些只被调用一两次的方法,编译它们所消耗的周期可能比直接解释字节码更多。

此外,如果没有运行时执行配置文件数据作为输入,编译器就无法做出明智的、反馈驱动的优化,从而无法显著提高编译代码的性能。最重要的是,它无法通过推测之前的执行模式将持续存在来简化编译后的代码,即用陷阱(trap)替换位于未执行的“冷”分支上的代码。推测性编译(一种 30 多年前在 Self 编译器中首次使用的优化)减少了馈入特定编译的字节码的大小和复杂性。这反过来又实现了方法调用的深度内联,并提供了识别更多衍生优化的可能性。冷分支上触发陷阱的罕见情况通过去优化(deoptimizing)处理,即跳回解释器并使用更新的分支配置文件重新编译该方法。

“内务管理”的负面影响

在应用程序执行的早期阶段,上述 JVM 内务管理(housekeeping)开销处于最高水平。类加载和初始化、类链接以及方法执行配置文件数据的记录,作为执行的副作用频繁发生(无论是针对应用程序代码还是 JDK 运行时代码),阻碍了应用程序的直接推进。方法编译虽然在专用的后台编译器线程中进行,但这仍然会占用 CPU 周期,再次阻碍了应用程序的进展。

随着越来越多的必需 JDK 代码和应用程序代码逐渐链接到运行时中,JVM 内务管理工作造成的阻碍会逐渐减少。同时,已编译代码的交付也逐步提高了应用程序的执行速度。

一段时间后,应用程序达到稳定状态,即大多数或所有类都已加载并链接,大多数或所有方法都已完成分析,所有“热”方法都已编译成高效的代码。偶尔,输入数据的变化或程序行为的阶段性转变会驱动应用程序进入冷路径,从而触发去优化并产生额外的 JVM 开销。然而,总的来说,应用程序在预热后会继续以稳定的峰值性能运行。

Leyden 项目的“premain”实验

Leyden 项目一直在 项目的 'premain' 分支 中尝试减少 JVM 内务管理任务的阻碍。驱动 Leyden premain 实验的观察结果是:在大多数情况下,应用程序运行期间发生的内务管理操作,在相同的工作中(尤其是在阻碍最大的早期阶段)产生的几乎是完全相同的结果。在每次运行中,大量相同的字节码被加载和链接,相同的类被初始化,相同的方法被证明是热点,并最终以相同或非常相似的配置文件信息被编译。

对于在进入应用程序 main 方法之前运行的 JDK 运行时代码,以及应用程序调用的 JDK 库代码,情况尤其如此。JVM 总是会加载 java.lang.Object、java.lang.Class 或 java.util.String 等基础类。在每次运行中,作为字面量硬编码在 JDK 方法中的相同 String 实例都会被添加到堆中。像 List 和 HashTable 这样的容器类通常被重复用于相同的目的。

JDK 类对于任何给定的版本都是固定的,因此它们的类、方法和字段元数据将始终相同,并且它们总是会以完全相同的方式相互引用(即链接)。事实上,Leyden premain 分支的名字来源于其最初的重点,即优化在进入应用程序 main 方法之前发生的 JDK 执行。

利用跨运行的 JDK 元数据一致性来获益的想法并不新鲜。自 JDK 13 以来,类数据共享 (CDS) 已经能够通过将 JDK 类的 JVM 元数据模型存储在 CDS 存档中,来优化 JDK 类的加载和字节码解析,从而允许在后续运行中“即开即用”地重新加载。

该版本的 CDS 为 JDK 提供了有效的(尽管有限的)预热启动能力,使 JDK 启动时间(即完成 JDK 初始化并进入应用程序 main 例程的时间)缩短了一半。CDS 还通过降低调用 JDK 库代码时的初始成本,帮助了应用程序的预热。

对于应用程序类,无法保证在两次运行之间相同的类会以相同的格式存在,或者在一次运行中加载和使用的类在后续运行中总是以相同的方式加载和使用。然而,只要相同的 jar 包出现在类路径中,并且类字节码是在没有特定于运行时的代理转换的情况下加载的,那么保存的元数据就有可能(对于许多类而言,可能性很大)被重用。

较新版本的 CDS 通过动态 CDS 存档支持保存和恢复应用程序类的元数据,允许 JVM 在后续运行中跳过这些类的加载和字节码解析成本,从而改善了应用程序的启动和预热。

Leyden 的 premain 分支建立在这一成功的基础上,但它解决的目标远不止归档元数据。更广泛的内部 JVM 状态——不仅是元数据,还包括静态字段数据、链接数据、方法配置文件、编译后的代码——这些在预热期间缓慢构建的状态,可能会根据每次运行的具体情况而有所不同。然而,如果在一次运行中创建的大部分内容能够像 CDS 当前处理元数据那样保存到存档中,那么它在后续运行中应该是可重用的,从而绕过通常用于创建它的内务管理开销。

即使某些保存的状态可能因后续运行中未引用该类或未调用该方法而变得无用,重用部分状态依然能带来收益。重新加载所需状态的成本可以远低于重新创建的成本,这意味着应用程序可以更早达到峰值性能,且受 JVM 的阻碍更小。可保存的重用状态越多,阻碍的减少就越明显。

训练运行与生产运行

因此,其背后的基本思路是运行两次应用程序:

  1. 训练运行(Training Run): 在此过程中,我们缓存元数据、分析统计信息、部分堆数据、编译后的代码等。
  2. 生产运行(Production Run): 加载之前(提前)缓存的信息,使运行从“热”状态开始。这是我们使用应用程序的“真实”运行。

当然,这只有在训练运行准确代表生产运行的情况下才有意义。

为了实现这一点,我们需要遵循以下约束:

  • 相同的硬件: 否则编译后的代码可能无法运行,所做的优化甚至可能对生产运行的性能产生负面影响。
  • 相同的 Java 版本和源代码: 如果更改了源代码,与源代码相关的任何缓存内容都会失效并变得无用。
  • 相同的操作系统系列: JVM 的某些部分在 Linux、Windows 或 MacOS 上的行为不同。如果更改了操作系统,我们不能直接重用缓存的信息。
  • 相同的 JVM 选项(基本如此): 我们或许可以更改一些 JVM 选项(例如使用不同的垃圾回收器)。但这样一来,我们缓存的分析统计信息以及关于应用程序行为的信息,可能就不再适用于新配置。最好不要乱动这些设置。
  • [可选] 无自定义类加载器: 缓存(目前)会忽略使用自定义类加载器加载的类。这意味着应用程序的那部分在第二次运行时将不会处于“热”状态。

Leyden 分支中开发的一些 AOT 改进已经包含在撰写本文时的最新 JDK LTS 版本(25)中。但后续版本的计划是迁移更多功能,缓存更多内容,性能增益将越来越好。性能提升在很大程度上取决于您的应用程序使用情况、所使用的 JDK 版本以及训练的质量。

在下一篇文章中,我们将解释如何使用 JDK 25 中提供的 AOT 新功能。我们还将展示测试结果,这些结果表明已经取得了非常显著的进展,并且在 JDK 26 及更高版本中已有的功能上还将持续改进。