Ohhnews

分类导航

$ cd ..
foojay原文

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

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

目录


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

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

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

第 3 部分将详细介绍 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 的“静态初始化”代码来填充类的静态字段。

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

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

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

“内务处理”的弊端

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

随着越来越多的 JDK 代码和应用程序代码逐渐链接到运行时,JVM 内务处理工作产生的阻碍逐渐减少。同时,交付编译后的代码也逐步提高了应用程序的执行速度。

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

Leyden 项目“预启动”(premain)实验

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

对于在进入应用程序主方法之前运行的 JDK 运行时代码,以及应用程序调用的 JDK 库代码,情况尤其如此。JVM 总是会加载 java.lang.Objectjava.lang.Classjava.util.String 等基础类。在每次运行中,相同的字符串实例(硬编码为 JDK 方法中的字面量)都会被添加到堆中。ListHashTable 等容器类通常被重复用于相同的目的。

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

利用这种跨运行的 JDK 元数据同一性获益的想法并不新鲜。自 JDK 13 起,类数据共享(CDS) 能够通过将 JVM 的 JDK 类元数据模型存储在 CDS 归档文件中,从而优化掉 JDK 类的类加载和字节码解析,使其能够在后续运行中“即开即用”地重新加载。

该版本的 CDS 提供了一种有效但有限的 JDK 热启动能力,将 JDK 启动(即完成 JDK 初始化并进入应用程序主程序)所需的时间减半。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 及更高版本中,现有功能还将继续优化。