Skip to content

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

摘要

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

目标

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

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

非目标

不需要提供显式的、开放的类库机制来保存和加载编译的代码。

动机

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

描述

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

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

AOT 编译由新的工具 jaotc 完成:

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

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

在 JVM 启动期间,AOT 初始化代码将在已知位置或通过 AOTLibrary 标志在命令行指定的位置查找已知的共享库。如果找到共享库,则会使用它们。如果没有找到共享库,则此 JVM 实例将关闭 AOT。

bash
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。

bash
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 一样。

bash
jaotc --output libHelloWorld.so HelloWorld.class

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

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

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

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

这包括使用相同的 JDK 构建变体:产品版或调试版。

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

在 JVM 启动期间,AOT 初始化代码会在已知位置查找已知的共享库,或者通过 -XX:AOTLibrary 选项指定的库。如果找到共享库,则会使用它们。如果找不到共享库,则此 JVM 实例的运行将关闭 AOT。

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

  • 非分层 AOT 编译代码的行为类似于静态编译的 C++ 代码,即不收集任何配置信息,也不会进行任何 JIT 重新编译。
  • 分层 AOT 编译代码会收集配置信息。所做的配置与在 Tier 2 上编译的 C1 方法进行的 简单配置 相同。如果 AOT 方法达到 AOT 调用阈值,则首先由 C1 在 Tier 3 上重新编译这些方法,以收集完整的配置信息。这对于 C2 JIT 重新编译是必需的,以产生最佳代码并达到最高应用程序性能。

重新在 Tier 3 上编译代码的额外步骤是必要的,因为进行全面配置的开销对于所有方法来说太高,尤其是对于 java.base 模块中的方法来说。对于用户应用程序,允许具有与 Tier 3 等效的配置的 AOT 编译可能是有意义的,但这在 JDK 9 中不受支持。

java.base 的逻辑编译模式是分层 AOT,因为希望对 java.base 方法进行 JIT 重新编译以达到最高性能。只有在某些情况下才会使用非分层 AOT 编译。这包括需要可预测行为的应用程序,当占用空间比峰值性能更重要时,或者对于不允许动态代码生成的系统。在这些情况下,AOT 编译需要对整个应用程序进行,并且在 JDK 9 中是实验性的。

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

bash
-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 库名称,但它们的编译、安装和使用是实验性的:

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

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

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

bash
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

通过使用 --compile-commands 选项,可以排除 java.base 中导致编译失败的某些方法:

shell
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 选项指定它(在 JDK 9 中,默认情况下 java 使用 G1 和压缩指针 - 无需指定这些标志):

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

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

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

在这种情况下,它将在不需要在命令行上指定的情况下自动加载:

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

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

新的运行时 AOT 标志

bash
-XX:+/-UseAOT

使用 AOT 编译的文件。默认情况下,此选项为开启状态。

bash
-XX:AOTLibrary=<file>

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

bash
-XX:+/-PrintAOT

打印使用的 AOT 类和方法。

还有一个附加的诊断标志(需要指定 -XX:+UnlockDiagnosticVMOptions 标志):

bash
-XX:+/-UseAOTStrictLoading

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

JVM 运行时具有以下与 JEP 158: Unified JVM Logging 集成的统一日志记录 AOT 标签。

bash
aotclassfingerprint

在类的指纹与 AOT 库中记录的指纹不匹配时创建日志。

bash
aotclassload

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

bash
aotclassresolve

在来自 AOT 编译代码的解析类的请求成功或失败时创建日志。

jaotc:Java 预编译器

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

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

bash
jaotc <> <名称或列>

其中 ' 名称 ' 是类名或者 JAR 文件,' 列表 ' 是由冒号分隔的类名、模块、JAR 文件或包含类文件的目录列表。

以下是可用的 jaotc 选项:

bash
--output <>

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

bash
--class-name <>

要编译的 Java 类的列表

bash
--jar <JAR>

要编译的 JAR 文件的列表

bash
--module <>

要编译的 Java 模块的列表

bash
--directory <>

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

bash
--search-path <>

要搜索指定文件的目录列表

bash
--compile-commands <>

包含编译命令的文件名:

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

AOT 目前支持两个编译命令:

bash
exclude       - 排除指定方法的编译
compileOnly   - 仅编译指定方法

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

bash
--compile-for-tiered

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

bash
--compile-with-assertions

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

bash
--compile-threads <>

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

bash
--ignore-errors

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

bash
--exit-on-error

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

bash
--info

打印有关编译阶段的信息

bash
--verbose

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

bash
--debug

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

bash
--help

打印 jaotc 的用法和选项信息

bash
--version

打印版本信息

bash
-J<标志>

标志 直接传递给 JVM 运行时系统

当前 AOT 的限制

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

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

替代方案

已经讨论过保存配置文件或编译决策,但这并不能减少实际编译代码所需的时间。可以尝试将低级别的 IR 最后的一个副本保存下来,但这看起来同样复杂。

测试

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

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

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

风险和假设

预编译代码的使用可能导致使用不太优化的代码,从而导致性能损失。性能测试显示,一些应用程序从 AOT 编译的代码中获益,而其他应用程序明显出现回归。由于 AOT 功能是一项选择性功能,用户可以避免与用户应用程序可能出现的性能回归。如果用户发现应用程序启动速度较慢,达不到预期的性能峰值,或者崩溃,他们可以使用 -XX:-UseAOT 标志关闭 AOT,或者删除任何 AOT 库。

建议在可信环境中进行 AOT 编译,以保护 JDK 库和工具免受篡改。

依赖关系

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

该项目将把 Graal core 合并到 JDK 中,并在 Linux/x64 构建中提供。