什么是GraalVM、AOT 和 JIT?

用本指南来了解 GraalVM 是什么、它的工作原理以及即时 (JIT) 编译与提前 (AOT) 编译的比较。

如果 Graal 的本机可执行文件几乎立即启动、更小并且消耗更少的资源 , 为什么您会想在 Java/JVM 项目中使用其他的呢?

为了给您一个正确的答案,我们必须快速浏览一下 Java 的编译器环境。与往常一样,我们将从最基础的开始。

什么是 Javac?
默认的 java 编译器称为javac,它采用您的 Java 源代码(您的 .java 文件),如下所示:

//...public static void main etc

public static int add(int a, int b) {
  return a + b;
}

并将其转化为 Java 字节码,即你的类文件,如 Main.class。您可以在任何安装了 JVM 的机器上运行这些文件。

这就是上述方法的字节码(通过 javap -c Main.class 命令生成):
0: iload_0
1: iload_1
2: iadd
3: ireturn

字节码会发生什么变化?
当您尝试运行 Java 类/应用程序(例如 java Main.class)时,上面的字节码尚未编译成机器代码,因此 JVM 需要解释字节码。为此,JVM 需要使用模板解释器(TemplateInterpreter),如果您对此感兴趣,可以阅读此处。

模板解释器的作用是什么?
简单来说:可以把它想象成逐条查看上面的语句(例如 istore_1),并找出在当前运行的特定操作系统和架构下需要执行的语句。

什么是 JIT 编译器?
然而,JVM 是聪明的,它并不只想无休止地解释字节码。它还会记住程序经常执行的代码(所谓的热路径),然后直接将字节码编译成机器码(好奇者可查阅 Java 的 C1 和 C2 编译器以及分层编译)。

通过大量的静态代码分析和运行时信息,JIT 编译器可以输出特定平台的优化机器代码。

JIT 方法有哪些优势?
短小精悍:

  • Java 最初的承诺:一次编写,随处运行(安装 JVM)。
  • 经过预热解释期后,在运行时获得 "卓越 "性能 → "Just In Time"(及时)。

什么是 AOT 编译器?
之前的JIT路径是:
.java -> javac -> bytecode -> jvm -> interpreter -> JIT

AOT 编译器还可以走以下路线:
.java -> AOT magic -> native executable (think .exe / elf)

从本质上讲,AOT 编译器会进行大量静态代码分析(在编译时,而不是运行时/JIT),然后为特定平台创建本地可执行文件:例如,你会得到一个 Main.exe。

这意味着你不必在启动程序后进行字节码解释/编译,而是可以全速启动应用程序。反过来说,你必须为每一个你希望程序运行的平台 x 架构组合创建一个特定的可执行文件(还有其他一大堆限制,我们稍后再谈)。

这基本上与 Java 最初的承诺背道而驰。


什么是 GraalVM?
GraalVM 同时提供 JIT 和 AOT 编译器,但人们可能会错误地将 Graal 的所有功能与其本地镜像功能混为一谈。

  • GraalVM是一个 Java 虚拟机 (JVM),它可以运行 Java(字节)代码并由 Oracle 维护。
  • GraalVM不仅可以运行Java,还可以通过Truffle Framework运行JS、Python、Ruby和其他我忘记的语言。
  • Graal Compiler是一种即时 (JIT) 编译器。
  • 还有Native Image,它是 Graal 的 Ahead-of-Time (AOT) 编译器。

本质上:
  • Graal Compiler (JIT) 本质上是 C2 (JIT) 编译器的替代品。
  • Native Image 是 AOT 编译器,可以为 Java 源文件创建本地可执行文件。


AOT 是否存在问题?
是的,有。

当 Graal 或任何 AOT 编译器创建本地可执行文件时,都需要根据所谓的封闭世界假设进行静态代码分析。实际上,这意味着编译器需要知道在构建时运行时可触及的所有类,否则代码将不会出现在最终的可执行文件中。

这就意味着,所有涉及动态加载的东西,如反射、JNI 或代理(很多 Java 库和项目使用的所有好东西),都是潜在的问题。

public static void main(String[] args) {
  if (isFriday()) {
    String myClass = "com.marcobehler.MyFancyStartupService";
    MyFancyStartupService instance = (MyFancyStartupService)
                                Class.forName(myClass)
                                .getConstructor()
                                .newInstance();
    instance.cashout();
  }
}

静态代码分析不会执行你的代码,因此编译器不知道你的代码是否真的是星期五代码,因此你的 MyFancyStartupService 不会被编译器看到,也不会出现在最终的可执行文件中。

有一些解决方法:在这种情况下,您可以指定 JSON 文件形式的元数据,让 AOT 编译器知道 MyFancyStartupService。这也意味着,您想包含在项目中的任何库都需要 "AOT ready",并在适用时提供此元数据。

Spring案例
根据启动 Spring 应用程序时设置的特定属性或配置文件,运行时加载的 Bean 可能会有所不同。

请看下面的自动配置(AutoConfiguration),它只会在特定属性被设置(例如在应用程序启动时的配置文件中)的情况下创建 FlamegraphProvider Bean。

同样,在构建过程中,Graal 编译器无法知道是否会出现这种情况,因此 Spring (Boot) 完全不支持 @Profiles 和 @ConditionalOnProperties 的本地镜像。

@AutoConfiguration
@ConditionalOnProperty(prefix = "flamegraphs",
                       name =
"enabled",
                       havingValue =
"true")
public static class FlamegraphConfiguration {

  @Bean
  public FlamegraphProvider flamegraphProvider() {
     
// ...
  }

}

还有其他潜在问题吗?
有。

  • AOT 编译需要大量资源。就 Spring 的原生映像而言,编译需要占用大量内存和 CPU。不过,CI/CD 提供商会很高兴的!
  • 与创建字节码相比,创建本地可执行文件也需要更多时间。以 Spring Boot 骨架应用程序为例,我们需要花费几分钟(AOT),而不是几秒钟(JIT)。
  • 根据平台的不同(Windows!),设置所有必要的 SDK 和库也非常麻烦,甚至无法启动本地编译。
  • 如果您无法控制目标环境,就像您的传统桌面应用程序一样:最终,您将需要一个疯狂的 CI/CD 矩阵,为各种支持的架构/平台创建本地可执行文件。您还需要支持和维护 CI/CD 矩阵。
  • 例如,将服务器可执行文件放入 Docker 容器中时,这就不是问题了,但稍后再详述。