跳到主要内容

JEP 483:提前类加载和链接

概括

当 HotSpot Java 虚拟机启动时,通过使应用程序的类在加载和链接状态下立即可用来缩短启动时间。通过在一次运行期间监视应用程序并将所有类的加载和链接形式存储在缓存中以供后续运行使用来实现这一点。为将来改进启动和预热时间奠定基础。

目标

  • 利用大多数应用程序每次运行时都以大致相同的方式启动这一事实来缩短启动时间。

  • 不需要对应用程序、库或框架的代码进行任何更改。

  • java除了与此功能直接相关的命令行选项之外,不需要对使用启动器从命令行启动应用程序的方式进行任何更改。

  • 不需要使用jlinkjpackage工具。

  • 为持续改进启动时间和预热时间(即 HotSpot JVM 优化应用程序代码以实现最佳性能所需的时间)奠定基础。

非目标

  • 我们的目标不是缓存由用户定义的类加载器加载的类。只有由 JDK 的内置类加载器从类路径、模块路径和 JDK 本身加载的类才能被缓存。我们可能会在未来的工作中解决这一限制。

动机

Java 平台具有高度的动态性。这是其强大力量的源泉。

动态类加载、动态链接、动态调度和动态反射等功能为开发人员提供了巨大的表达能力。他们可以创建使用反射通过检查应用程序代码中的注释来确定应用程序配置的框架。他们可以编写动态加载然后链接到运行时发现的插件组件的库。最后,他们可以通过编写动态链接到其他库的库来组装应用程序,从而利用丰富的 Java 生态系统。

动态编译、动态反优化和动态存储回收等功能为 JVM 提供了广泛的灵活性。当它通过观察应用程序的行为发现这样做是值得的时,它可以将方法从字节码编译为本机代码。它可以推测性地优化本机代码,假设特定的频繁执行路径,并在观察到该假设不再成立时恢复到解释字节码。当它发现这样做是有利可图时,它可以回收存储。通过这些和相关技术,JVM 可以实现比传统静态方法更高的峰值性能。

然而,所有这些活力都是有代价的,每次应用程序启动时都必须付出代价。

JVM 在典型的服务器应用程序启动期间会执行大量工作,并交织以下几种活动:

另外,如果应用程序使用框架,例如 Spring Framework,那么框架启动时发现@Bean@Configuration以及相关注释将触发更多工作。

所有这些工作都是按需、懒惰、及时地完成的。然而,它经过了大量优化,因此许多 Java 程序在几毫秒内即可启动。即便如此,使用 Web 应用程序框架以及用于 XML 处理、数据库持久性等的库的大型服务器应用程序可能需要几秒甚至几分钟才能启动。

然而,应用程序往往会重复执行,每次启动时通常都会执行相同的操作:扫描相同的 JAR 文件、读取和解析、加载和链接相同的类、执行相同的静态初始化程序以及使用反射来配置相同的应用程序对象。缩短启动时间的关键是尝试提前完成至少部分工作,而不仅仅是及时完成。换句话说,就莱顿项目而言,我们的目标是将部分工作提前完成

描述

我们扩展了 HotSpot JVM,使其支持_提前缓存_,该缓存可以在读取、解析、加载和链接类后存储它们。为特定应用程序创建缓存后,该缓存可以在该应用程序的后续运行中重复使用,以缩短启动时间。

创建缓存需要两个步骤。首先,在_训练运行_中运行应用程序一次,以记录其 AOT 配置,在本例中记录到文件中app.aotconf

$ java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf \
-cp app.jar com.example.App ...

其次,使用配置创建缓存,在文件中app.aot

$ java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf \
-XX:AOTCache=app.aot -cp app.jar

(第二步不运行应用程序,它只是创建缓存。我们打算在未来的工作中简化缓存创建的过程。)

随后,在测试或生产中,使用缓存运行应用程序:

$ java -XX:AOTCache=app.aot -cp app.jar com.example.App ...

(如果缓存文件不可用或不存在,则 JVM 会发出警告消息并继续。)

有了 AOT 缓存,JVM 通常在程序运行于第三步时实时执行的读取、解析、加载和链接工作被提前转移到第二步,从而创建缓存。随后,程序在第三步启动得更快,因为它的类可立即从缓存中获取。

例如,这里有一个程序,虽然很短,但它使用了Stream API,因此导致近 600 个 JDK 类被读取、解析、加载和链接:

import java.util.*;
import java.util.stream.*;

public class HelloStream {

public static void main(String ... args) {
var words = List.of("hello", "fuzzy", "world");
var greeting = words.stream()
.filter(w -> !w.contains("z"))
.collect(Collectors.joining(", "));
System.out.println(greeting); // hello, world
}

}

该程序在 JDK 23 上运行时间为 0.031 秒。在完成创建 AOT 缓存所需的少量额外工作后,它在 JDK NN 上运行时间为 0.018 秒 — 提高了 42%。AOT 缓存占用 11.4 MB。

对于代表性的服务器应用程序,请考虑Spring PetClinic版本 3.2.0。它在启动时加载并链接大约 21,000 个类。使用 AOT 缓存时,它在 JDK 23 上启动时间为 4.486 秒,在 JDK NN 上启动时间为 2.604 秒——巧合的是,也提高了 42%。AOT 缓存占用 130 兆字节。

如何训练你的 JVM

训练运行会捕获应用程序配置和执行历史记录,以供后续测试和生产运行使用。因此,生产运行是训练运行的理想选择。但是,使用生产运行进行训练并不总是可行的,尤其是对于创建日志文件、打开网络连接和访问数据库等的服务器应用程序。对于此类情况,我们建议创建尽可能类似于实际生产运行的合成训练运行。除其他事项外,它应该完全配置自身并执行典型的生产代码路径。

实现此目的的一种方法是向您的应用程序添加第二个主类,专门用于训练,例如com.example.AppTrainer。此类可以调用生产主类,使用临时日志文件目录、本地网络配置和模拟数据库(如果需要)来练习应用程序的常见模式。您可能已经以集成测试的形式拥有这样的主类。

其他一些建议:

  • 为了优化启动时间,请构建训练运行,使其加载与生产运行启动时加载的相同的类。您可以通过-verbose:class命令行选项或JDK Flight Recorderjdk.ClassLoad的事件检查加载了哪些类。

  • 为了最小化 AOT 缓存的大小,请避免在训练运行中加载在生产运行中未使用的类。例如,不要使用用丰富的测试框架编写的测试套件。我们可能会在未来的工作中提供一种从缓存中过滤此类类的方法。

  • 如果在生产中,您的应用程序与网络上的其他主机交互或访问数据库,那么在训练中,您可能希望模拟这些交互以确保加载必要的类。如果在 Java 代码中执行此类模拟,则会导致缓存生产中不需要的其他类。同样,我们可能会提供一种方法来在未来的工作中从缓存中过滤此类类。如果出于某种原因,您无法模拟这些类型的交互,因此无法将它们包含在训练运行中,那么生产中处理它们所需的类将像往常一样从类路径或模块中即时加载。

  • 专注于运行一系列简短的验证场景,有时称为“冒烟测试”或“健全性测试”。这通常足以加载生产中需要的大多数类。避免使用涵盖罕见极端情况和很少使用的功能的大型测试套件。还要避免压力测试和回归测试,这些测试通常不是典型启动活动的特征。

  • 请记住,AOT 缓存仅在训练运行与生产运行类似的情况下才有用。如果训练运行未达到这一水平,那么缓存就没那么有用了。

训练和后续运行的一致性

要享受训练运行期间生成的 AOT 缓存的好处,训练运行和所有后续运行必须基本相似。

  • 所有运行必须使用相同的 JDK 版本,并且采用相同的硬件架构(例如x64aarch64)和操作系统。

  • 所有运行都必须具有一致的类路径。后续运行可以指定额外的类路径条目,附加到训练类路径;否则,类路径必须相同。类路径必须仅包含 JAR 文件;不支持类路径中的目录,因为 JVM 无法有效地检查它们的一致性。

  • -m所有运行都必须在命令行上具有一致的模块选项和一致的模块图。 、--module-p--module-path和选项的参数--add-modules(如果存在)必须相同。--limit-modules--patch-module、 和--upgrade-module-path选项不得使用。

  • 所有运行都不能使用可以使用 ClassFileLoadHook 任意重写类文件的 JVMTI 代理。

  • 所有运行都不能使用调用AddToBootstrapClassLoaderSearchAddToSystemClassLoaderSearchAPI 的 JVMTI 代理。

如果违反任何这些约束,则 JVM 默认会发出警告并忽略缓存。

要检查您的 JVM 是否正确配置为使用 AOT 缓存,您可以将选项添加-XX:AOTMode=on到命令行:

$ java -XX:AOTCache=app.aot -XX:AOTMode=on \
-cp app.jar com.example.App ...

如果存在此选项,则当违反上述任何约束或缓存不存在时,JVM 会报告错误并退出。

(该-XX:AOTMode=on选项仅用于诊断目的,应避免在生产环境中使用。否则,如果在您控制范围之外添加了不兼容的 VM 选项,您的应用程序可能无法启动。例如,云提供商可能会决定使用 JVMTI/ClassFileLoadHook 运行某些 JVM 以进行监控)。

(如果需要,您可以通过 完全禁用 AOT 缓存-XX:AOTMode=off。您也可以通过 指定默认模式-XX:AOTMode=auto,在这种情况下,JVM 将尝试使用通过-XX:AOTCache选项指定的 AOT 缓存;如果缓存不可用或不存在,则它会发出警告消息并继续。)

一致性要求的一个有用例外是训练和后续运行可能使用不同的垃圾收集器。另一个有用的例外是训练和后续运行可能使用不同的主类;如上所述,这为构建训练运行提供了灵活性。

历史

这里提出的提前缓存是 HotSpot JVM 中旧功能类数据共享(CDS) 的自然演变。

CDS最初是在 2004 年 JDK 5 的一次更新中引入的。它最初旨在减少在同一台机器上运行的多个 Java 应用程序的内存占用。它通过读取和解析 JDK 类文件来实现这一点,将生成的元数据存储在只读存档文件中,该文件稍后可以由多个 JVM 进程使用相同的虚拟内存页直接映射到内存中。我们后来扩展了 CDS,以便它还可以存储应用程序类的元数据。

如今,CDS 的共享优势已被新的安全实践所削弱,例如地址空间布局随机化(ASLR),这使得文件映射到内存的地址变得不可预测。然而,CDS 仍然提供了显着的启动时间改进 — — 以至于 JDK 12 及更高版本的版本包含一个内置的 CDS 存档,其中包含一千多个常用 JDK 类的元数据。因此,CDS 无处不在,尽管许多 Java 开发人员从未听说过它,也很少有人直接使用过它。

AOT 缓存建立在 CDS 之上,不仅可以提前读取和解析类文件,还可以加载和链接它们。您可以-XX:-AOTClassLinking在创建缓存时通过选项禁用后两项优化,以查看它们的效果:

$ java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf \
-XX:AOTCache=app.aot -XX:-AOTClassLinking

当我们使用此选项时,我们可以看到程序启动时间的大部分改进HelloStream是由于提前加载和链接,而 PetClinic 应用程序启动时间的大部分改进是由于 CDS 今天已经完成的提前读取和解析(所有时间以秒为单位,百分比是累积的):

HelloStream

宠物诊所

JDK 23

0.031

4.486

AOT 缓存,无需加载或链接

0.027(+13%)

3.008(+33%)

AOT 缓存,具有加载和链接功能

0.018(+42%)

2.604(+42%)

因此, Spring Boot以及更普遍的Spring Framework用户今天只需使用以前的 JDK 版本中已有的 CDS 功能,就可以享受显著的启动时间改进。

目前,新的-XX:AOT*命令行选项大部分是现有 CDS 选项(例如、和)的宏-Xshare-XX:DumpLoadedClassList我们-XX:SharedArchiveFile引入这些-XX:AOT*选项是为了为此功能和未来的超前功能提供统一的用户体验,并删除可能令人混淆的单词“share”和“shared”。

兼容性

提前类加载和链接适用于所有现有的 Java 应用程序、库和框架。除了创建 AOT 缓存的额外步骤外,它不需要更改源代码,也不需要更改构建配置。它完全支持 Java 平台的高度动态特性,包括运行时反射。

这是因为类读取、解析、加载和链接的时间和顺序对于 Java 代码来说并不重要。Java 语言和虚拟机规范为 JVM 提供了调度这些操作的广泛自由。当我们将这些操作从即时变为提前时,应用程序会观察到类被加载和链接,就好像 JVM 在请求的确切时刻完成了这项工作一样 — — 尽管速度快得令人难以置信。

未来工作

  • 这里提出的两步工作流程很繁琐。在不久的将来,我们希望将其简化为一个步骤,即执行训练运行并创建 AOT 缓存。

  • 目前,进行训练运行的唯一方法是让应用程序运行代表性工作负载,至少在启动时运行,然后退出。在未来的工作中,我们可能会创建新的工具来帮助开发人员更灵活地定义和评估此类训练运行和工作负载,也许还允许他们手动调整存储在 AOT 缓存中的内容。我们还可以在生产运行期间不引人注意地收集训练数据。

  • 尚不支持 ZGC。我们打算在未来的工作中解决这一限制。

  • 在某些情况下,JVM 无法提前加载类,更不用说链接它们了。这些包括由用户定义的类加载器加载的类、需要旧版本字节码验证器的旧类以及签名类。如果某个类无法 AOT 加载,则其他可 AOT 加载的类也无法 AOT 链接到它。在所有这些情况下,JVM 都会像往常一样恢复到即时加载和链接。如果这些限制被证明很重要,我们可能会在未来的工作中解决它们。

  • 提前加载和链接类有助于将来缩短预热时间。将来,在训练运行期间,我们可以记录哪些代码运行最频繁的统计数据,并缓存生成的任何优化代码。这将使应用程序能够立即以优化状态启动。

测试

  • 我们将创建新的单元测试用例来覆盖新的命令行选项。

  • 提前加载和链接与现有的 CDS 功能无关。大多数 CDS 测试在使用该-XX:+AOTClassLinking选项运行时应该会通过。少数测试对类的加载顺序很敏感;我们将根据需要修改它们。

风险和假设

  • 我们假设对于想要使用此功能的开发人员来说,在训练和后续运行中所需的一致性是可以容忍的。他们必须特别确保类路径和模块配置在所有运行中都是一致的。

  • 我们假设对用户定义类加载器的有限支持是可以容忍的。与一些潜在用户的交流表明,他们愿意接受固定的类路径和模块配置,从而接受一组固定的内置类加载器,并且只在需要灵活性时才使用专门的类加载器。

  • 我们假设提前加载和链接的低级副作用在实践中并不重要。这些副作用包括文件系统访问时间、日志消息、JDK 内部簿记活动以及 CPU 和内存使用率的变化。如果提前加载和链接类,观察并依赖这些细微影响的应用程序可能会变得不稳定。我们假设此类应用程序很少见,并且可以进行调整以进行补偿。