跳到主要内容

JEP 295:提前编译(Ahead-of-Time Compilation)

QWen Max 中英对照

概述

在启动虚拟机之前,将 Java 类编译为本地代码。

目标

  • 提高小型和大型 Java 应用程序的启动时间,对峰值性能的影响要尽可能有限。

  • 尽可能少地改变最终用户的工作流程。

非目标

不需要为保存和加载编译后的代码提供明确的、暴露的类库机制。

动机

JIT 编译器速度很快,但 Java 程序可能会变得非常庞大,以至于 JIT 完全预热需要花费很长时间。很少使用的 Java 方法可能根本不会被编译,由于重复的解释调用,这可能会导致性能损失。

描述

在 JDK 9 中,对任何 JDK 模块、类或用户代码进行 AOT 编译是实验性的,并且不受支持。

要使用 AOT 编译的 java.base 模块,用户必须编译该模块并将生成的 AOT 库复制到 JDK 安装目录中,或者在 java 命令行中指定它。除此之外,AOT 编译代码的使用对最终用户来说是完全透明的。

AOT 编译由一个新工具 jaotc 完成:

jaotc --output libHelloWorld.so HelloWorld.class
jaotc --output libjava.base.so --module java.base

它使用 Graal 作为代码生成后端。

在 JVM 启动期间,AOT 初始化代码会在一个众所周知的位置查找知名的共享库,或者按照命令行中通过 AOTLibrary 标志指定的路径进行查找。如果找到共享库,则会被加载并使用。如果没有找到共享库,那么此 JVM 实例将关闭 AOT 功能。

java -XX:AOTLibrary=./libHelloWorld.so,./libjava.base.so HelloWorld

新的 Java AOT 标志和 jaotc 标志列在以下小节中,这些小节还包含了如何为 java.base 模块构建和安装 AOT 库的说明。

用于 AOT 编译代码的容器格式是共享库。JDK 9 版本仅支持 Linux/x64,其中共享库格式为 ELF。AOT 库中的 AOT 编译代码被 JVM 视为现有 CodeCache 的扩展。当加载一个 Java 类时,JVM 会检查已加载的 AOT 库中是否存在相应的 AOT 编译方法,并从 Java 方法描述符链接到它们。AOT 编译代码遵循与普通 JIT 编译代码相同的调用/去优化/卸载规则。

由于类字节码会随着时间而变化,无论是通过源代码的更改,还是通过类转换和重定义,JVM 都需要检测到这些变化,并在字节码不匹配时拒绝使用 AOT 编译的代码。这是通过 类指纹 实现的。在 AOT 编译期间,会为每个类生成一个指纹,并将其存储在共享库的数据段中。之后,当某个类被加载并且发现该类存在 AOT 编译的代码时,当前字节码的指纹会与共享库中存储的指纹进行比较。如果两者不匹配,则不会使用该类对应的 AOT 编译代码。

在 AOT 编译和执行期间应使用相同的 JDK。Java 版本会记录在 AOT 库中,并在加载时进行检查。更新 Java 时需要重新进行 AOT 编译。

jaotc 不会解析不是系统类或编译类一部分的引用类。这些类必须添加到类路径中。否则,在 AOT 编译期间可能会抛出 ClassNotFoundException 异常。

jaotc --output=libfoo.so --jar foo.jar -J-cp -J./
jaotc --output=libactivation.so --module java.activation -J--add-module=java.se.ee

AOT 使用方法

使用 jaotc 工具执行 AOT 编译。该工具是 Java 安装的一部分,与 javac 类似。

jaotc --output libHelloWorld.so HelloWorld.class

然后在应用程序执行期间指定生成的 AOT 库:

java -XX:AOTLibrary=./libHelloWorld.so HelloWorld

在此版本中,AOT 编译和执行期间应使用相同的 Java 运行时配置。例如:

jaotc -J-XX:+UseParallelGC -J-XX:-UseCompressedOops --output libHelloWorld.so HelloWorld.class 
java -XX:+UseParallelGC -XX:-UseCompressedOops -XX:AOTLibrary=./libHelloWorld.so HelloWorld

它包括使用相同 JDK 构建变体的要求:生产版(product)或调试版(debug)。

运行时配置记录在 AOT 库中,并在执行期间加载库时进行验证。如果验证失败,这个 AOT 库将不会被使用,JVM 会继续运行;如果指定了 -XX:+UseAOTStrictLoading 标志,JVM 将退出。

在 JVM 启动期间,AOT 初始化代码会在一个已知位置查找知名的共享库,或者查找由 -XX:AOTLibrary 选项指定的库。如果找到共享库,则会被加载并使用。如果找不到任何共享库,那么在此 JVM 实例运行中 AOT 将被关闭。

AOT 库可以通过 --compile-for-tiered 标志控制的两种模式进行编译:

  • 非分层 AOT 编译的代码行为类似于静态编译的 C++ 代码,因为它不会收集性能分析信息,也不会发生 JIT 重新编译。
  • 分层 AOT 编译的代码确实会收集性能分析信息。其所进行的性能分析与在第 2 层编译的 C1 方法所执行的 简单性能分析 相同。如果 AOT 方法达到了 AOT 调用阈值,则这些方法会首先由 C1 在第 3 层重新编译,以收集完整的性能分析信息。这是为了 C2 的 JIT 重新编译,从而生成最优代码并达到应用程序的峰值性能。

在 Tier 3 中重新编译代码的额外步骤是必要的,因为完全性能分析的开销过高,无法用于所有方法,特别是像 java.base 模块中的方法。对于用户应用程序来说,允许使用相当于 Tier 3 的性能分析进行 AOT 编译可能是有意义的,但 JDK 9 将不支持此功能。

java.base 的逻辑编译模式是分层的 AOT(提前编译),因为需要通过 JIT(即时编译)重新编译 java.base 方法以达到峰值性能。只有在某些特定场景下,非分层的 AOT 编译才有意义。这些场景包括需要可预测行为的应用程序、占用空间比峰值性能更重要的情况,或不允许动态代码生成的系统。在这些情况下,AOT 编译需要覆盖整个应用程序,因此在 JDK 9 中仍是实验性的功能。

可以为不同的执行环境生成 AOT 库的集合。JVM 了解针对特定运行时配置生成的 java.base AOT 库的以下“知名”名称。它将在 $JAVA_HOME/lib 目录中查找这些库,并加载与当前运行时配置相对应的库:

-XX:-UseCompressedOops -XX:+UseG1GC :       libjava.base.so
-XX:+UseCompressedOops -XX:+UseG1GC : libjava.base-coop.so
-XX:-UseCompressedOops -XX:+UseParallelGC : libjava.base-nong1.so
-XX:+UseCompressedOops -XX:+UseParallelGC : libjava.base-coop-nong1.so

JVM 还知道下一个 Java 模块的 AOT 库名称,但它们的编译、安装和使用是实验性的:

java.base
jdk.compiler
jdk.scripting.nashorn
jdk.internal.vm.ci
jdk.internal.vm.compiler

生成和使用 java.base 模块的 AOT 库的步骤

使用 jaotc 编译 java.base 模块。它需要较大的 Java 堆来保存所有编译方法的数据(大约 50000 个方法):

jaotc -J-XX:+UseCompressedOops -J-XX:+UseG1GC -J-Xmx4g --compile-for-tiered --info --compile-commands java.base-list.txt --output libjava.base-coop.so --module java.base

java.base 中的一些方法会导致编译失败,并通过使用 --compile-comands 选项被排除:

cat java.base-list.txt

# jaotc: java.lang.StackOverflowError
exclude sun.util.resources.LocaleNames.getContents()[[Ljava/lang/Object;
exclude sun.util.resources.TimeZoneNames.getContents()[[Ljava/lang/Object;
exclude sun.util.resources.cldr.LocaleNames.getContents()[[Ljava/lang/Object;
exclude sun.util.resources..*.LocaleNames_.*.getContents\(\)\[\[Ljava/lang/Object;
exclude sun.util.resources..*.LocaleNames_.*_.*.getContents\(\)\[\[Ljava/lang/Object;
exclude sun.util.resources..*.TimeZoneNames_.*.getContents\(\)\[\[Ljava/lang/Object;
exclude sun.util.resources..*.TimeZoneNames_.*_.*.getContents\(\)\[\[Ljava/lang/Object;
# java.lang.Error: Trampoline must not be defined by the bootstrap classloader
exclude sun.reflect.misc.Trampoline.<clinit>()V
exclude sun.reflect.misc.Trampoline.invoke(Ljava/lang/reflect/Method;Ljava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object;
# JVM asserts
exclude com.sun.crypto.provider.AESWrapCipher.engineUnwrap([BLjava/lang/String;I)Ljava/security/Key;
exclude sun.security.ssl.*
exclude sun.net.RegisteredDomain.<clinit>()V
# Huge methods
exclude jdk.internal.module.SystemModules.descriptors()[Ljava/lang/module/ModuleDescriptor;

生成 AOT 库后,在应用程序执行期间使用 -XX:AOTLibrary 选项指定它(默认情况下,Java 在 JDK 9 中使用 G1 和压缩指针,您无需指定这些标志):

java -XX:AOTLibrary=./libjava.base-coop.so,./libHelloWorld.so HelloWorld

或者将生成的 AOT 库复制到 JDK 安装目录(您可能需要调整目录的权限):

cp libjava.base-coop.so $JAVA_HOME/lib/

在这种情况下,它将会自动加载,无需在命令行中指定:

java -XX:AOTLibrary=./libHelloWorld.so HelloWorld

考虑从 AOT 库中 strip 未使用的符号以减小库的大小。

新的运行时 AOT 标志

-XX:+/-UseAOT

使用 AOT 编译的文件。默认情况下它是开启的。

-XX:AOTLibrary=<file>

指定 AOT 库文件的列表。使用冒号(:)或逗号(,)分隔库条目。

-XX:+/-PrintAOT

打印使用 AOT 的类和方法。

提供了额外的诊断标志(需要指定 -XX:+UnlockDiagnosticVMOptions 标志):

-XX:+/-UseAOTStrictLoading

如果任何 AOT 库的运行时配置与当前运行时设置不匹配,则退出 JVM。

JVM 运行时集成了以下与 JEP 158: Unified JVM Logging 相关的统一日志 AOT 标签。

aotclassfingerprint

如果类的指纹与 AOT 库中记录的指纹不匹配,则创建日志。

aotclassload

在 AOT 库中找到相应类数据时创建日志。

aotclassresolve

创建日志,记录来自 AOT 编译代码的请求解析类是否成功。

jaotc:Java 提前编译器

jaotc 是 Java 静态编译器,它为已编译的 Java 方法生成本地代码。它使用 Graal 作为代码生成后端,并使用 libelf 生成 .so AOT 库。

该工具是 Java 安装的一部分,可以像 javac 一样使用。

jaotc <options> <name or list>

其中,name 是类名或 jar 文件。list 是一个由冒号分隔的类名、模块、jar 文件或包含类文件的目录列表。

以下是可用的 jaotc 选项:

--output <file>

输出文件名。默认名称为 "unnamed.so"。

--class-name <class names>

要编译的 Java 类列表

--jar <jar files>

要编译的 jar 文件列表

--module <modules>

要编译的 Java 模块列表

--directory <dirs>

要搜索的文件目录列表以进行编译

--search-path <dirs>

指定文件的搜索目录列表

--compile-commands <file>

包含编译命令的文件名:

exclude sun.util.resources..*.TimeZoneNames_.*.getContents\(\)\[\[Ljava/lang/Object; 
exclude sun.security.ssl.*
compileOnly java.lang.String.*

AOT 目前识别两个编译命令:

exclude       - exclude compilation of specified methods 
compileOnly - compile only specified methods

正则表达式用于指定类和方法。

--compile-for-tiered

为分层编译生成了分析代码。默认情况下,不会生成分析代码(未来可能会更改)。

--compile-with-assertions

生成带有 Java 断言的代码。默认情况下,断言代码不会被生成。

--compile-threads <number>

要使用的编译线程数。默认值为 min(16, available_cpus)

--ignore-errors

忽略类加载期间抛出的所有异常。默认情况下,如果类加载抛出异常,则退出编译。

--exit-on-error

在编译错误时退出。默认情况下,编译失败会被跳过,并继续编译其他方法。

--info

打印有关编译阶段的信息

--verbose

打印有关编译阶段的更多详细信息,启用 --info 标志

--debug

打印更多详细信息,开启 --info 和 --verbose 标志

--help

打印 jaotc 使用信息和标志

--version

打印版本信息

-J<flag>

直接将 flag 传递给 JVM 运行时系统

当前 AOT 的限制

  • JDK 9 中的 AOT(提前编译)初始版本仅提供实验性使用,并且仅限于在运行 64 位 Java 的 Linux x64 系统上使用,支持 Parallel 或 G1 GC。
  • AOT 编译必须在与运行 Java 应用程序时使用的 AOT 代码相同配置的系统上执行。
  • 在 AOT 编译和执行期间,必须使用相同的 Java 运行时配置。例如,如果应用程序将使用 Parallel GC 和 AOT 代码,则 jaotc 工具应通过 -J 标志以 Parallel GC 运行。运行时配置不匹配可能会导致应用程序在执行过程中崩溃。
  • 可能无法编译使用动态生成的类和字节码的 Java 代码(如 lambda 表达式、invokedynamic)。任何未被 AOT 编译的代码将在运行时以常规方式处理:首先在解释器中运行,然后由 JIT 编译器编译。
  • AOT 不支持自定义类加载器,因为在 AOT 编译期间没有关于它们的信息。AOT 编译的方法不会用于由非内置加载器加载的类。

这些限制可能会在未来的版本中得到解决。

替代方案

已经讨论了保存配置文件或编译决策的问题,但这对减少实际花费在编译代码上的时间毫无帮助。也许可以改为保存低级中间表示(IR)的非常后期的副本,但这似乎并不简单。

测试

将开发新的 AOT jtreg 测试,以测试 AOT 功能。

所有现有的测试都可以使用启用了 AOT 的 JDK 运行。这已经作为单独的夜间测试配置完成。

另一种配置是在启用了 AOT 的 JDK 上运行所有测试,并且 java.base 模块是通过 AOT 编译的。

风险与假设

使用预编译代码可能会导致使用不够优化的代码,从而造成性能损失。性能测试表明,某些应用程序能从 AOT 编译代码中受益,而其他一些应用程序则明显表现出性能下降。由于 AOT 功能是一个可选功能,因此可以避免用户应用程序可能出现的性能下降问题。如果用户发现某个应用程序启动更慢、未能达到预期的峰值性能或者崩溃,他们只需使用 -XX:-UseAOT 标志关闭 AOT,或者移除任何 AOT 库即可。

建议 AOT 编译在受信任的环境中进行,在这些环境中,JDK 库和工具受到保护,免遭篡改。

依赖

此项目依赖于 JEP 243: Java-Level JVM Compiler Interface,因为 AOT 编译器使用 Graal 作为代码生成后端,而 Graal 又依赖于 JVMCI。

该项目将会把 Graal 核心 合并到 JDK 中,并且在 Linux/x64 构建版本中交付。