JEP 483: 提前类加载和链接
概述
通过使应用程序的类在 HotSpot Java 虚拟机启动时立即可用(处于已加载和已链接状态)来改善启动时间。通过在一次运行中监视应用程序并存储所有类的已加载和已链接形式到缓存中,以便在后续运行中使用,从而实现这一点。为将来对启动时间和预热时间的改进奠定基础。
目标
-
通过利用大多数应用程序每次运行时启动方式大致相同这一点来改进启动时间。
-
不需要对应用程序、库或框架的代码进行任何更改。
-
除了与此功能直接相关的命令行选项外,不需要更改如何使用
java
启动器从命令行启动应用程序。 -
不需要使用
jlink
或jpackage
工具。 -
为持续改进启动时间和预热时间(即 HotSpot JVM 优化应用程序代码以达到峰值性能所需的时间)打下基础。
非目标
- 缓存由用户定义的类加载器加载的类不是目标。只有通过 JDK 的内置类加载器从类路径、模块路径和 JDK 本身加载的类才能被缓存。我们可能会在将来的工作中解决这一限制。
动机
Java 平台是高度动态的。这是一个巨大的优势。
诸如动态类加载、动态链接、动态分派和动态反射等功能为开发人员提供了巨大的表达能力。他们可以创建框架,通过检查应用程序代码中的注释来确定应用程序的配置。他们可以编写库,这些库能够动态加载然后链接到运行时发现的插件组件。最终,他们可以通过组合动态链接到其他库的库来组装应用程序,从而利用丰富的 Java 生态系统。
诸如动态编译、动态去优化和动态存储回收等功能为 JVM 提供了广泛的灵活性。当通过观察应用程序的行为发现这样做是值得的时候,它可以将一个方法从字节码编译成本地代码。它可以推测性地优化本地代码,假设一个特定的频繁执行路径,并在观察到该假设不再成立时恢复到解释字节码。当它观察到这样做是有利可图的时候,它可以回收存储。通过这些及相关技术,JVM 可以实现比传统静态方法更高的峰值性能。
然而,所有这些动态性都是有代价的,这个代价在每次应用程序启动时都必须支付。
JVM 在典型的服务器应用程序启动期间做了很多工作,穿插了几种类型的活动:
-
它扫描磁盘上的数百个 JAR 文件,并将数千个类文件读取并解析到内存中;
-
它将解析的类数据加载到类对象中,并将它们链接在一起,以便类可以使用彼此的 API,这涉及到验证字节码和解析符号引用,这反过来又可能涉及实例化 lambda 对象;以及
-
它执行类的静态初始化器——它们的
static
字段初始化器和static { ... }
块——这可以创建许多对象,甚至执行 I/O 操作,如打开日志文件。
此外,如果应用程序使用了框架(例如 Spring 框架),那么该框架在启动时对 @Bean
、@Configuration
和相关注解的发现将触发更多的工作。
所有这些工作都是按需进行的,懒加载,即时完成。然而,它经过了大量优化,因此许多 Java 程序可以在几毫秒内启动。即便如此,一个使用了 Web 应用框架加上 XML 处理、数据库持久化等库的大型服务器应用程序,可能需要几秒甚至几分钟才能启动。
然而,应用程序往往重复执行相同的操作,每次启动时基本上都在做同样的事情:扫描相同的 JAR 文件、读取和解析并加载和链接相同的类、执行相同的静态初始化器,并使用反射来配置相同的应用程序对象。改善启动时间的关键是尝试提前完成至少部分工作,而不是仅在需要时才进行。换句话说,用 Project Leyden 的术语来说,我们的目标是将部分工作提前。
描述
我们扩展了 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 兆字节。
对于一个典型的服务器应用程序,可以参考 Spring PetClinic 的 3.2.0 版本。它在启动时加载和链接约 21,000 个类。在 JDK 23 上,它启动需要 4.486 秒,而在使用 AOT 缓存的 JDK NN 上,启动仅需 2.604 秒——巧合的是,这也提高了 42%。AOT 缓存占用 130 兆字节。
如何训练你的JVM
一次训练运行会捕获应用程序的配置和执行历史,以便在后续的测试和生产运行中使用。因此,生产运行是进行训练运行的良好候选。然而,使用生产运行进行训练并不总是实际可行的,特别是对于那些创建日志文件、打开网络连接和访问数据库的服务器应用程序。对于这种情况,我们建议创建一个尽可能接近实际生产运行的合成训练运行。它应该能够全面配置自身并执行典型的生产代码路径。
实现这一目标的一种方法是在应用程序中添加第二个主类,专门用于训练,例如 com.example.AppTrainer
。这个类可以调用生产主类,以临时日志文件目录、本地网络配置和(如果需要)模拟数据库来运行应用程序的常见模式。你可能已经有了这种形式的集成测试主类。
一些额外的提示:
-
为了优化启动时间,请将训练运行的结构设计为在启动时加载与生产运行相同的类。您可以使用
-verbose:class
命令行选项或 JDK Flight Recorder 的jdk.ClassLoad
事件来检查加载了哪些类。 -
为了最小化 AOT 缓存的大小,请避免在训练运行中加载在生产运行中不使用的类。例如,不要使用用丰富的测试框架编写的测试套件。我们可能会在未来的工作中提供一种方法来从缓存中过滤掉这些类。
-
如果您的应用程序在生产环境中与网络上的其他主机交互或访问数据库,那么在训练过程中,您可能希望模拟这些交互以确保加载必要的类。如果这种模拟是用 Java 代码完成的,将会导致额外的不需要在生产中使用的类被缓存。同样,我们可能会在未来的工作中提供一种方法来从缓存中过滤掉这些类。如果由于某种原因,您无法模拟这些类型的交互,因此无法将其包含在训练运行中,那么在生产中处理这些交互所需的类将像往常一样从类路径或模块中即时加载。
-
专注于运行一系列广泛的简短验证场景,有时也称为“冒烟测试”或“健全性测试”。这通常足以加载您在生产中需要的大多数类。避免使用涵盖罕见边缘情况和很少使用功能的大规模测试套件。同时避免压力测试和回归测试,因为它们通常不代表典型的启动活动。
-
请记住,AOT 缓存只有在训练运行与生产运行做类似的事情时才有帮助。如果训练运行没有达到这个目标,那么缓存的作用就会减小。
训练和后续运行的一致性
要享受在训练运行期间生成的 AOT 缓存带来的好处,训练运行和所有后续运行必须基本上相似。
-
所有运行必须使用相同的 JDK 版本,并且必须在相同的硬件架构(例如,
x64
或aarch64
)和操作系统上进行。 -
所有运行必须具有一致的类路径。后续运行可以指定额外的类路径条目,附加到训练类路径之后;否则,类路径必须完全相同。类路径只能包含 JAR 文件;不支持类路径中的目录,因为 JVM 无法高效地检查它们的一致性。
-
所有运行在命令行上必须具有一致的模块选项,并且模块图也必须一致。如果存在
-m
、--module
、-p
、--module-path
和--add-modules
选项,它们的参数必须完全相同。不得使用--limit-modules
、--patch-module
和--upgrade-module-path
选项。 -
所有运行不得使用能够通过 ClassFileLoadHook 任意重写类文件的 JVMTI 代理。
-
所有运行不得使用调用
AddToBootstrapClassLoaderSearch
和AddToSystemClassLoaderSearch
API 的 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)。
(如果需要,可以通过 -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
选项禁用后两项优化,从而观察其效果:
$ java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf \
-XX:AOTCache=app.aot -XX:-AOTClassLinking
当我们使用此选项时,我们可以看到 HelloStream
程序的启动时间改进主要是由于提前加载和链接,而 PetClinic 应用程序的启动时间改进主要是由于 CDS 当前已经完成的提前读取和解析(所有时间均以秒为单位,百分比是累积的):
HelloStream
PetClinic
JDK 23
0.031
4.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*
选项是为了给这个和未来的提前编译功能提供统一的用户体验,并去掉可能令人困惑的“share”和“shared”等词汇。
兼容性
提前类加载和链接适用于每个现有的 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 和内存使用情况的变化。如果类被提前加载和链接,那些观察并依赖于此类微妙效应的应用程序可能会变得不稳定。我们假设这样的应用程序很少见,并且它们可以被调整以进行补偿。