Gunnar Morling

Gunnar Morling

软件工程领域的随想

初探 JEP 483:Ahead-of-Time Class Loading & Linking 技术

发布于 2025 年 3 月 27 日

在“Let’s Take a Look at…​!”博客系列中,我将探索数据和流处理领域中有趣的项目、发展和技术。这可以是 KIP 和 FLIP、开源项目、服务、Java 和 JVM 的相关改进等等。目的是获得一些实践经验,了解潜在的用例和应用,并理解其中的权衡。如果您认为我应该关注某个特定主题,请在下面的评论中告诉我。

Java 24 上周发布了,这是一个内容丰富的版本:发布了超过 20 个 Java 增强提案 (JEP),其中包括紧凑对象头 (JEP 450,我希望很快能花一些时间深入研究一下),一个新的类文件 API (JEP 484),以及更灵活的构造函数体 (JEP 492,第三次预览)。 另一个可能不太引人注目的 JEP 是 JEP 483("Ahead-of-Time Class Loading & Linking")。它承诺在无需对应用程序本身进行任何修改的情况下减少 Java 应用程序的启动时间,这有什么不好呢? 让我们仔细看看!

JEP 483 是一个更广泛的 OpenJDK 倡议 Project Leyden 的一部分,其目标是减少 Java 程序的整体占用空间,包括启动时间和达到峰值性能的时间。 最终,它的目标是启用 Java 应用程序的 ahead-of-time compilation,从而为 GraalVM 及其对 AOT native image compilation 的支持提供替代方案,后者最近取得了巨大的成功和普及。 AOT class loading 和 linking 是 Project Leyden 中实现此目标的第一步。 它建立在早期 Java 版本中提供的 Application Class Data Sharing (AppCDS) 功能之上。 虽然 AppCDS 仅读取和解析应用程序引用的类文件并将它们转储到存档文件中,但 JEP 483 还会加载和链接类并缓存该数据。 也就是说,甚至更多的工作从应用程序运行时转移到构建时,从而进一步减少启动时间。

与 AppCDS 的情况一样,需要进行训练运行以创建 AOT 缓存文件。 在该运行期间,您应确保加载了正确的类集:如果未加载应用程序所需的所有类,则 AOT 缓存将无法得到充分利用,并且 JVM 将退回到在运行时按需加载它们。 另一方面,当加载应用程序在运行时实际未使用的类(例如,测试框架的类)时,缓存文件的大小会膨胀而没有任何好处。 类路径在训练运行和实际应用程序运行之间必须保持一致:必须存在相同的 JAR 文件,并且顺序相同。 但是,运行时类路径可以添加额外的 JAR,这些 JAR 自然不会进入 AOT 缓存。

让我们以 Apache Kafka 为例,将 AOT class loading 和 linking 应用到实践中。 虽然像 Kafka broker 这样的长时间运行的组件的启动开销通常可能不那么重要,但当例如在开发和测试期间经常启动和停止 broker 时,它绝对会产生影响。

为 Apache Kafka 构建 AOT 缓存

巧合的是,Apache Kafka 4.0 也在 上周发布了。 因此,让我们 下载 它并将其用于我们的实验。 解压发行版并格式化 Kafka 文件的目录:

1 2 3

| ```
tar xvf kafka_2.13-4.0.0.tgz
KAFKA_CLUSTER_ID="$(bin/kafka-storage.sh random-uuid)"
bin/kafka-storage.sh format --standalone -t $KAFKA_CLUSTER_ID -c config/server.properties

---|---
`


构建 AOT 缓存是一个两步过程。 首先,需要生成应进入存档的所有类的列表。 然后,此列表用于创建存档本身。 这感觉有点比应有的更复杂,而且 JEP 确实提到简化此过程已在路线图上。

像这样创建类列表:
1
2

| ``` export EXTRA_ARGS="-XX:AOTMode=record -XX:AOTConfiguration=kafka.aotconf" (1) bin/kafka-server-start.sh config/server.properties

  
---|---  
`

1 | EXTRA_ARGS 变量可用于在启动 Kafka 时将任何其他参数传递给 JVM,在本例中,用于指定 AOT 缓存的类列表应记录在 kafka.aotconf 文件中。 ---|---
顺便说一句,从 4.0 版本开始,Kafka 已完全与 ZooKeeper 分道扬镳,并且专门支持 KRaft 进行集群协调。 通过使用 server.properties 文件,我们的单个 broker 在所谓的“组合”模式下运行,因此它同时具有“broker”和“controller”角色。 很高兴看到多年来事情变得多么简单!

Kafka 启动后,打开一个单独的 shell 窗口。 在 Kafka 中创建一个主题,然后生成并使用几个消息,如下所示:

1 2 3 4 5 6 7 8 9 10 11 12 13 14

| ```
bin/kafka-topics.sh --create --topic my-topic --bootstrap-server localhost:9092
Created topic my-topic.
bin/kafka-console-producer.sh --topic my-topic --bootstrap-server localhost:9092
>hello
>world
<Ctrl + C>
bin/kafka-console-consumer.sh --topic my-topic --from-beginning --bootstrap-server localhost:9092
hello
world
<Ctrl + C>
Processed a total of 2 messages

---|---
`


这显示了创建 AOT 缓存文件时所涉及的权衡:我们 _不必_ 在此处生成和使用消息,但在所有可能性中,这将触发仅在运行时加载和链接的类的加载。 通过 [JDK Flight Recorder](https://www.morling.dev/blog/jep-483-aot-class-loading-linking/</blog/rest-api-monitoring-with-custom-jdk-flight-recorder-events/>) 监视哪些类被加载可能是一个好主意,从而确保您在创建 AOT 缓存文件时确实捕获了相关的集合。

通过在启动 broker 的会话中按 `<Ctrl + C>` 来停止 broker。 如果您查看 _kafka.aotconf_ 文件,您会看到它本质上是一个要缓存的类的长列表,以及其他与类相关的元数据。 顶部的注释仍然暗示了 Leyden 的 AOT 支持构建在 CDS 之上的历史:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17

| ```

NOTE: Do not modify this file.

This file is generated via the -XX:DumpLoadedClassList=<class_list_file> option

and is used at CDS archive dump time (see -Xshare:dump).

java/lang/Object id: 0 java/io/Serializable id: 1 java/lang/Comparable id: 2 java/lang/CharSequence id: 3 java/lang/constant/Constable id: 4 java/lang/constant/ConstantDesc id: 5 java/lang/String id: 6 java/lang/reflect/AnnotatedElement id: 7 java/lang/reflect/GenericDeclaration id: 8 java/lang/reflect/Type id: 9 java/lang/invoke/TypeDescriptor id: 10 ...

  
---|---  
`

接下来,让我们尝试创建实际的 AOT 缓存文件。 为此,请指定 -XX:AOTMode=create 选项。 请注意,在此过程中实际上并未执行应用程序,而是 JVM 仅创建 AOT 缓存文件并再次退出:

1 2

| ```
export EXTRA_ARGS="-XX:AOTMode=create -XX:AOTConfiguration=kafka.aotconf -XX:AOTCache=kafka.aot" **(1)**
bin/kafka-server-start.sh config/server.properties

---|---
`


**1** | 使用先前创建的配置文件创建 AOT 缓存
---|---  
哦,哦,有些东西不太如预期那样工作:
1
2
3
4
5

| ``` java.lang.IllegalArgumentException: javax.management.NotCompliantMBeanException: com.sun.management.UnixOperatingSystemMXBean: During -Xshare:dump, module system cannot be modified after it's initialized at java.management/javax.management.StandardMBean.(StandardMBean.java:270) at java.management/java.lang.management.ManagementFactory.addMXBean(ManagementFactory.java:882) at java.management/java.lang.management.ManagementFactory.lambda$getPlatformMBeanServer$1(ManagementFactory.java:474) ...

  
---|---  
`

这个消息让我有点困惑——我不认为我以任何方式与 Java 模块系统进行交互? 所以我向 leyden-dev 邮件列表发送了 一条消息,我在那里了解到这可能是由启动 JVM 的 JMX 代理触发的。 虽然我没有主动这样做,但实际上 默认情况下是这种情况,根据 Kafka 发行版附带的 run-class.sh 启动器脚本。 因此,让我们禁用 JMX 诊断并再次尝试:

1 2

| ```
export KAFKA_JMX_OPTS=" "
bin/kafka-server-start.sh config/server.properties

---|---
`


有些类因各种原因被跳过,但总的来说,这次看起来好多了:
1
2
3
4
5
6
7
8

| ``` [0.908s][warning][cds] Preload Warning: Verification failed for org.apache.logging.log4j.core.async.AsyncLoggerContext [2.307s][warning][cds] Skipping org/slf4j/Logger: Old class has been linked [2.307s][warning][cds,resolve] Cannot aot-resolve Lambda proxy because org.slf4j.Logger is excluded [2.613s][warning][cds ] Skipping jdk/internal/event/Event: JFR event class [2.615s][warning][cds ] Skipping org/apache/logging/slf4j/Log4jLogger: Unlinked class not supported by AOTClassLinking [2.615s][warning][cds ] Skipping org/apache/logging/slf4j/Log4jLoggerFactory: Unlinked class not supported by AOTClassLinking ... AOTCache creation is complete: kafka.aot

  
---|---  
`

Log4j 的 AsyncLoggerContext 类未能通过验证有点令人担忧,但我们将把分析留到以后再进行。 在这种情况下,AOT 缓存文件的大小为 66 MB。 它被认为是实现细节,因此可能会在 Java 版本之间发生变化。 现在让我们看看使用 AOT 缓存对 Kafka 启动时间的影响。 为此,只需在运行应用程序时指定缓存文件的名称:

1 2

| ```
export EXTRA_ARGS="-XX:AOTCache=kafka.aot"
bin/kafka-server-start.sh config/server.properties

---|---
`


我通过比较 Kafka 发出的第一个日志消息的时间戳与显示“Kafka Server started”的消息的时间戳来测量启动时间,始终从新格式化的 Kafka 日志目录开始,并在运行之间刷新页面缓存。 在我的机器上(配备 M3 Max 处理器和 48 GB 共享内存的 2023 MacBook Pro)平均运行五次,这花费了 285 毫秒。 相比之下,Kafka 在没有存档的情况下启动需要 690 毫秒,即 AOT 缓存使这种情况下启动时间减少了 59%。

构建 AOT 缓存时,您还可以通过指定 `-XX:-AOTClassLinking` 选项来禁用 AOT class loading 和 linking,从而有效地产生与在早期 Java 版本中使用 AppCDS 时相同的行为。 这将导致我的笔记本电脑上的 Kafka 启动时间为 327 毫秒,即在这种情况下,改进的主要部分确实源于提前读取和解析类文件,而 AOT loading 和 linking 仅产生相对较小的额外改进。 最后,我还测量了在 Docker 容器中启动 [Kafka native binary](https://www.morling.dev/blog/jep-483-aot-class-loading-linking/<https:/hub.docker.com/r/apache/kafka-native>)(参见 [KIP 974](https://www.morling.dev/blog/jep-483-aot-class-loading-linking/<https:/cwiki.apache.org/confluence/display/KAFKA/KIP-974%3A+Docker+Image+for+GraalVM+based+Native+Kafka+Broker>))所需的时间,该时间为 118 毫秒,即不到 AOT 缓存所需时间的一半。 但请记住,此映像被认为是实验性的,尚未准备好投入生产,而在 JVM 上使用 AOT 缓存运行 Kafka 时不应有任何此类担忧。

## 使用 Apache Flink 进行 AOT 缓存

如前所述,除了测试场景之外,Kafka 通常是长时间运行的工作负载,因此启动时间在总体方案中并不那么重要。 为了添加另一个数据点,我还测试了 AOT class-loading 和 linking 对一个简单的 Apache Flink 作业有多大的好处。

现在,Flink 作业通常通过将它们作为 JAR 上传到 Flink 集群来部署,之后它们的代码会使用 [自定义类加载器](https://www.morling.dev/blog/jep-483-aot-class-loading-linking/<https:/nightlies.apache.org/flink/flink-docs-master/docs/ops/debugging/debugging_classloading/>) 加载。 截至今天,JEP 483 不支持 AOT class loading 和 linking 与用户定义的类加载器一起使用(JEP 建议此限制可能会在未来的 Java 版本中取消)。 这意味着只有 Flink 的内置类将受益于 AOT,而 Flink 作业的任何类及其依赖项都将被排除在外。 因此,对于我的实验,我决定使用 Flink 的 [mini-cluster deployment](https://www.morling.dev/blog/jep-483-aot-class-loading-linking/<https:/nightlies.apache.org/flink/flink-docs-stable/api/java/org/apache/flink/runtime/minicluster/MiniCluster.html>),这是一种以非分布式方式使用 Flink 的简化模式,只需运行作业的主类即可。

测试作业使用用于 Apache Kafka 的 Flink connector 从 Kafka 主题读取消息。 我测量了启动作业后的首次消息时间:在没有 AOT 缓存的情况下(再次平均运行五次),这在我的机器上花费了 1.875 秒,而使用 AOT 缓存则花费了 0.913 秒。 在这种情况下,首次消息时间减少了 51%,非常棒! 使用没有加载和链接类的 AOT 缓存比默认行为提高了 40%(1.118 秒)。 我无法测试 Flink 作为 GraalVM native binary; 如果您知道任何使这成为现实的工作,我很乐意听取您的意见!

## 总结

AOT class loading 和 linking 是 Java 中非常受欢迎的补充。 它建立在先前存在的 CDS 和 AppCDS 概念之上,通过将加载和链接类的过程提前到构建时,有助于进一步缩短基于 JVM 的应用程序的启动时间。 实际影响将因特定应用程序而异,对于 Kafka 和基本的 Flink 作业,我观察到启动时间分别减少了 59% 和 51%。
![jep 483 results](https://www.morling.dev/images/jep_483_results.png)

虽然启动时间对于长时间运行的工作负载来说并不那么重要,但它们可以在云原生场景中产生巨大的差异,在云原生场景中,应用程序会动态扩展,根据传入请求的负载按需启动新实例。 还可以考虑 scale-to-zero 部署、基于云的流处理解决方案中实时查询的预览作业、CLI 实用程序、启动 Kafka 等资源进行集成测试等等——只要有人在等待进程启动并提供响应,您可以节省的每一位时间都将立即带来更好的用户体验。

Project Leyden 和 JEP 483 提供的 AOT 机制的优点在于,它不需要对您的应用程序代码进行任何修改。 它可以与任何 Java 应用程序一起使用,从而可以显着减少启动时间,而且基本上是免费的。 当前形式的所需训练运行感觉有点麻烦,但 JEP 建议将在未来的版本中对该领域进行改进。 实际上,已经有一个 [draft JEP](https://www.morling.dev/blog/jep-483-aot-class-loading-linking/<https:/openjdk.org/jeps/8350022>),它提供了关于这可能是什么样子的更多细节。 总的来说,从软件开发生命周期的角度来看,对训练运行的要求可能具有挑战性,尤其是在考虑(不可变的)容器映像时,例如在部署到 Kubernetes 时。 应用程序将必须在映像构建时执行,还要执行一些工作来触发加载和链接所有相关类,可能还需要远程资源(如数据库)。 这可能并不总是那么容易做到。

最大的问题是 Project Leyden 与 GraalVM(Oracle 开发的另一项 Java AOT 技术)相比如何。 据我所知,这两个项目之间有很多重叠的目标。 目前,GraalVM 比 Leyden 先进得多,完全支持 AOT 编译,不仅可以对启动时间进行更令人印象深刻的改进(使用 GraalVM 编译为 native binary 时,Java 应用程序可以在几毫秒内启动),还可以显着减少内存使用量。 不利的一面是,应用程序及其依赖项通常需要调整和或多或少复杂的配置才能利用 GraalVM 的 AOT 编译(像 [Quarkus](https://www.morling.dev/blog/jep-483-aot-class-loading-linking/<https:/quarkus.io/>) 这样的框架可以帮助完成这项任务)。 此外,GraalVM 基础上的封闭世界假设阻止了 JVM 以动态性而闻名,例如在应用程序运行时加载类以用于插件用例、动态修改甚至生成类等。

在这方面,看看 Project Leyden 将在这一领域提出什么将非常有趣。 它也寻求最终支持 AOT 编译,但正在 [探索高度约束的封闭世界假设和完全动态性之间的中间地带](https://www.morling.dev/blog/jep-483-aot-class-loading-linking/<https:/www.jfokus.se/jfokus24-preso/Project-Leyden—​Capturing-Lightning-in-a-Bottle.pdf>),例如通过为开发人员提供指定其应用程序的哪些模块可以作为类重定义的手段,以及哪些模块不能。 除了更快的启动时间外,这里的另一个目标是更快的预热,即更快达到峰值性能的时间。

自 [2020 年启动](https://www.morling.dev/blog/jep-483-aot-class-loading-linking/<https:/mail.openjdk.org/pipermail/discuss/2020-April/005429.html>) 以来,Leyden 沉寂了很长一段时间,但最近又开始加速,JEP 483 是第一个实际交付成果之一。 绝对值得密切关注其他 Leyden JEP、[AOT code compilation](https://www.morling.dev/blog/jep-483-aot-class-loading-linking/<https:/openjdk.org/jeps/8335368>) 和 [AOT method profiling](https://www.morling.dev/blog/jep-483-aot-class-loading-linking/<https:/openjdk.org/jeps/8325147>)。 目前处于草案状态,这些尚未确定目标 Java 版本,但可以从 OpenJDK 网站获得 [early access builds](https://www.morling.dev/blog/jep-483-aot-class-loading-linking/<https:/jdk.java.net/leyden/>)。

© 2019 - 2025 Gunnar Morling | 在 [Creative Commons BY-SA 4.0](https://www.morling.dev/blog/jep-483-aot-class-loading-linking/<https:/creativecommons.org/licenses/by-sa/4.0/>) 下获得许可