Skip to content
欢迎扫码关注公众号

JEP 483: Ahead-of-Time Class Loading & Linking | 提前类加载与链接

概述

通过在 HotSpot Java 虚拟机启动时,使应用程序的类立即以已加载和链接的状态可用,从而提高启动时间。通过在一个运行过程中监控应用程序,并将所有类的已加载和链接形式存储在一个缓存中,以便在后续运行中使用,来实现这一目标。为未来进一步改善启动时间和预热时间奠定基础。

目标

  • 利用大多数应用程序每次运行时几乎以相同方式启动的事实,提高启动速度。

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

  • 除了与此功能直接相关的命令行选项外,不要求改变从命令行使用 java 启动器启动应用程序的方式。

  • 不要求使用 jlinkjpackage 工具。

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

非目标

  • 不打算缓存由用户定义的类加载器加载的类。只能缓存通过 JDK 的 内置类加载器 从类路径、模块路径以及 JDK 本身加载的类。我们可能会在未来的工作中解决这个限制。

动机

Java 平台非常动态化。这是其巨大的优势来源。

诸如动态类加载、动态链接、动态分派和动态反射等功能赋予了开发者极大的表达能力。他们可以创建使用反射检查应用代码中的注解来确定应用配置的框架;编写在运行时发现并随后加载然后链接到插件组件的库;最后,通过组合动态链接到其他库的库来组装应用程序,从而利用丰富的 Java 生态系统。

诸如动态编译、动态反优化和动态存储回收等功能给 JVM 带来了广泛的灵活性。它可以在检测到将字节码编译成本地代码会有所值时这样做。它可以假设一个特定的频繁执行路径进行推测性优化,并在观察到该假设不再成立时回退到解释字节码。当观察到这样做会有益时,它可以回收存储空间。通过这些和相关技术,JVM 可以实现比传统静态方法更高的峰值性能。

然而,所有这些动态性都有代价,每次应用程序启动时都必须支付这一代价。

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

如果应用程序还使用了一个框架,例如 Spring 框架,则框架在启动时发现 @Bean@Configuration 及相关注解将进一步触发更多工作。

所有这些工作都是按需、懒惰地、及时完成的。尽管如此高度优化,许多 Java 程序还是能够在毫秒内启动。即使这样,使用 Web 应用框架加上 XML 处理、数据库持久化等库的大规模服务器应用程序可能需要几秒钟甚至几分钟才能启动。

然而,应用程序往往重复自己,通常在每次启动时都做基本相同的事情:扫描相同的 JAR 文件,读取、解析、加载和链接相同的类,执行相同的静态初始化器,并使用反射来配置相同的应用对象。提高启动时间的关键在于尝试至少提前急切地做一些这样的工作,而不是仅仅及时完成。换句话说,根据 Project Leyden 的说法,我们的目标是 将一些工作提前

描述

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

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

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

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

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

(此第二步不运行应用程序,它仅创建缓存。我们计划在未来的工作中简化缓存创建的过程。)

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

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

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

请注意,配置和缓存文件的格式未指定且可能在没有通知的情况下更改。

通过 AOT 缓存,JVM 通常会在程序运行时即时完成的读取、解析、加载和链接工作被提前到了第二步,即创建缓存的过程中。因此,程序在第三步启动得更快,因为它的类可以直接从缓存中获取。

例如,以下是一个程序,尽管简短,但它使用了 Stream API,从而导致将近 600 个 JDK 类被读取、解析、加载和链接:

java
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 兆字节。

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

如何训练你的 JVM

一次训练运行捕获应用程序配置和执行历史,用于后续的测试和生产运行。因此,生产运行是训练运行的良好候选者。然而,使用生产运行进行训练并不总是实际可行的,特别是对于如创建日志文件、打开网络连接和访问数据库等操作的服务器应用程序。对于这些情况,我们建议创建一个尽可能模仿实际生产运行的人工训练运行。除其他事项外,它应该完全配置自己,并练习典型的生产代码路径。

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

一些额外的小贴士:

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

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

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

  • 关注运行广泛的短期验证场景,有时称为“冒烟测试”或“健全性测试”。这通常足以加载你在生产中需要的大部分类。避免覆盖罕见极端案例和很少使用的功能的大规模测试套件。也避免压力测试和回归测试,这些通常不代表典型的启动活动。

  • 记住,只有当训练运行所做的与生产运行相似时,AOT 缓存才有帮助。如果训练运行未能达到这一点,缓存的效果就会较差。

训练与后续运行的一致性

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

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

  • 所有运行必须具有统一的类路径。随后的运行可以指定额外的类路径条目,附加到训练类路径之后;否则,类路径必须完全相同。类路径只能包含 JAR 文件;不支持目录出现在类路径中,因为 JVM 无法高效地检查它们是否一致。

  • 所有运行必须在命令行上有统一的模块选项和一致的模块图。如果存在,则传递给 -m--module-p--module-path--add-modules--enable-native-access 选项的参数必须相同。不应使用 --add-exports--add-opens--add-reads--illegal-native-access--limit-modules--patch-module--upgrade-module-path 选项。

  • 所有运行不得使用能够通过 ClassFileLoadHook 任意重写 classfiles 的 JVMTI 代理。

  • 所有运行不得使用调用 AddToBootstrapClassLoaderSearchAddToSystemClassLoaderSearch API 的 JVMTI 代理。

如果违反了上述任何约束,默认情况下,JVM 会发出警告并忽略缓存。

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

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

如果提供了此选项,而上述任一约束被违反,或者缓存不存在,JVM 将报告错误并退出。

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

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

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

历史

这里提出的预先缓存是 HotSpot JVM 中的一个古老特性——类数据共享 (CDS) 的自然进化。

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

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

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

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

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

HelloStreamPetClinic
JDK 230.0314.486
AOT 缓存,无加载或链接0.027 (+13%)3.008 (+33%)
AOT 缓存,带有加载和链接0.018 (+42%)2.604 (+42%)

因此,Spring Boot 用户以及更广泛的 Spring 框架 用户,只需利用之前 JDK 版本中已有的 CDS 功能,即可享受显著的启动时间改进。

新引入的 -XX:AOT* 命令行选项目前在大多数情况下是现有 CDS 选项(如 -Xshare-XX:DumpLoadedClassList-XX:SharedArchiveFile)的宏。我们引入 -XX:AOT* 选项是为了为此及未来的预先功能提供统一的用户体验,并去除潜在混淆的“共享”相关词汇。

兼容性

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

之所以如此,是因为类读取、解析、加载和链接的时机和顺序对于 Java 代码来说是无关紧要的。Java 语言和虚拟机规范赋予了 JVM 在调度这些操作时广泛的自由度。当我们从即时(just-in-time)将这些操作转移到提前(ahead-of-time)时,应用程序会观察到类如同 JVM 在请求的确切时刻执行该工作一样被加载和链接——只是快得不可思议。

未来的工作

  • 这里提出的两步工作流程比较繁琐。在不久的将来,我们预计将其简化为一个步骤,既执行训练运行也创建 AOT 缓存。

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

  • ZGC 目前还不被支持。我们打算在 未来的工作 中解决这个限制。

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

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

测试

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

  • 提前加载和链接独立于现有的 CDS 功能。大多数 CDS 测试应该在使用 -XX:+AOTClassLinking 选项运行时通过。少数测试对类加载顺序敏感;我们将视情况对它们进行修订。

风险和假设

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

  • 我们假设对用户自定义类加载器的有限支持是可以接受的。与一些潜在用户的对话表明,他们愿意接受固定的类路径和模块配置,因此可以接受一组固定的内置类加载器,并仅在需要这种灵活性时使用专门的类加载器。

  • 我们假设提前加载和链接带来的低级副作用在实践中是无关紧要的。这些包括文件系统访问的时间、日志消息、JDK 内部的簿记活动以及 CPU 和内存使用的变化。那些观察并依赖于这样微妙效果的应用程序如果提前加载和链接类的话可能会变得不稳定。我们假设这类应用程序很少见,并且可以通过调整来补偿。