GraalVM 与 AOT

前置段落偏向于介绍 GraalVM 的基本概念,GraalVM 与 Spring Boot Native 的实践将放在文章偏后的位置

GraalVM 是 Oracle 开发的一款多语言虚拟机,也可以叫做 UVM(通用虚拟机),它可以运行多种语言,其中的核心为 Graal 即时编译器,由 Truffle framework 提供多语言支持

Graal 编译器是使用 Java 写的 JIT 编译器,大部分脚本语言或者有动态特效的语言都需要一个语言虚拟机运行,比如 CPython,Lua,Erlang,Java,Ruby,R,JS,PHP,Perl,APL 等等,但是这些语言的虚拟机实现差距很大,比如 CPython 的 VM 就不忍直视,JVM 的 HotSpotVM,C#的 CLR 和 JS 的 v8 却是 state of the art 级别的,GraalVM 的目标就是让所有语言都能使用 state of the art 级别的虚拟机

Truffle framework 是一个用于实现直译器的框架,它可以让我们使用 Java 实现直译器,从而让 GraalVM 支持更多的语言,只要实现了该语言的直译器,就可以在 GraalVM 中使用该语言,其中官方提供的语言构建示例可以在 GitHub 上的 graalvm/simplelanguage 仓库中找到

其实并不真正存在 GraalVM 这个语言虚拟机,GraalVM 是指以 Java 虚拟机为基础,以 Graal 即时编译器为核心,以能运行多种语言为目标,包含一系列框架和技术的大杂烩,包括 Truffle framework,Graal Compiler,JVM compile interface,Java HotSpot Runtime

Truffle framework 实现示例

需要使用多语言功能,需要为 GraalVM 安装对应的语言包,这里以 python 为例,如果在安装 GraalVM 时配置了环境变量,那么可以直接使用 gu 命令安装,否则需要进入 GraalVM 的 bin 目录下执行

1
./gu install python

安装对应的语言包后,通过观察命令行输出,可以发现 GraalVM 会自动安装 LLVM 等工具,例如安装 python 会为我们安装 LLVM Runtime Core、LLVM toolchain、GraalVM Python 等组件,这些组件实现了完整的 Truffle framework API 接口,从而让我们可以在 GraalVM 中使用 python

1
2
3
Installing new component: LLVM Runtime Core (org.graalvm.llvm, version 23.0.1)
Installing new component: LLVM.org toolchain (org.graalvm.llvm-toolchain, version 23.0.1)
Installing new component: GraalVM Python (org.graalvm.python, version 23.0.1)

在 java 中编写以下代码,多语言调用的功能是由 GraalVM 提供的,所以不需要额外引入外部依赖,在配置了 GraalVM 为项目 JDK 的情况下,导入org.graalvm.polyglot.Context即可(该依赖由 GraalVM 提供,使用其他 JDK 执行会找不到对应的类)

1
2
3
4
5
6
7
8
9
10
11
12
import org.graalvm.polyglot.Context;

public class Main {
public static void main(String[] args) {
System.out.println("Hello World from Java!");
try (Context context = Context.newBuilder().allowAllAccess(true).build()) {
context.eval("js", "print('Hello World from JavaScript!');");
context.eval("python", "print('Hello World from Python!')");
context.eval("ruby", "puts 'Hello World from Ruby!'");
}
}
}

上述代码中,我们使用了 GraalVM 提供的 Context 类来执行多语言代码,其中 allowAllAccess(true) 会为当前构建的 Context 对象授予与主机虚拟机相同的访问权限。如果主机 VM 在没有启用安全管理器的情况下运行,那么启用所有访问将使其能够完全控制主机进程,否则,Java 安全管理器将控制限制多语言上下文的权限。

如果向多语言 API 添加了新的权限限制,则它们将默认为完全访问。

上方示例最后的输出结果如下

1
2
3
4
Hello World from Java!
Hello World from JavaScript!
Hello World from Python!
Hello World from Ruby!

Ahead-of-time compile 特性

上文我们提到了,Graal 作为一个 JIT 编译器,需要在应用启动的时候执行 JIT 编译步骤。如果在集群动态扩容,弹性伸缩等应用环境下,JIT 编译带来的应用启动时间会变得较长,冷启动问题使得 Java 在 Serverless 场景下无法与 Node.js、Go 等具有快速启动优势的的语言的竞争中,落于下风。针对这个问题, GraalVM 为我们提供了 AOT 的解决方案。

AOT与JIT

Ahead-of-time compile (AOT) 是一种在应用启动前将代码编译成机器码的技术,这种技术可以让应用在启动时不需要再进行 JIT 编译,从而提升应用的启动速度

社区版 GraalVM 的静态编译后的 Java 应用的性能稳定地处于 OpenJDK C1 编译器的水平。而商业版的 GraalVM 静态编译甚至可以使程序达到 C2 编译器的编译后的性能水平

由于在 AOT 之后产生的字节文件已经不需要 JVM 来执行,GraalVM 给出的解决方案是为 Substrate VM(SVM)。Substrate VM 虽然名为 VM,但并不是一个虚拟机,而是一个包含了 垃圾回收、线程管理 等功能的运行时组件(Runtime Library),就好比 C++当中的 stdlib 一样。当 Java 程序被编译为 Native Image 运行的时候,并不是运行在 Substrate VM 里,而是将 SubstrateVM 当作库来使用其提供的各种基础能力,以保障程序的正常运行。

由 SVM 提供 Runtime, 再借助 Graal 编译器,可以将 Java 程序 AOT 编译为平台相关的可执行程序(Graal 编译器同时支持 JIT 与 AOT 的特性)。它的过程主要是两步:

  1. 通过静态分析找到 Java 程序用到的所有类,方法和字段,为这些文件链接一个非常小的 SVM 运行时

  2. 把静态分析产生的结果通过 AOT 编译,生成一个可执行文件。

AOT步骤

AOT 的困境——反射

GraalVM 这一套 AOT + SVM 的想法很美好,实际上从 Java 的微服务/FaaS 的应用角度来看,排除静态编译花费的大量时间,剩下的特性非常利好应用场景。只是现实很骨感,因为 Java 有反射这些动态类型。针对动态类型,SVM 和 AOT 基本上是无解的,静态分析再厉害也找不到运行时动态加载的类。

对于使用反射的类,只能手动写配置,或者运行时直接产生 Crash

针对目前 Java 开发与框架中反射的使用频率,这个问题是致命的,所以目前 AOT 还是一个实验性的特性,不建议在生产环境中使用

不过这一切也不是没有解决方案,Spring 框架已经提供了一整个 AOT 包: org.springframework.aot,其中的@RegisterReflectionForBinding注解可以让我们在编译时将所有可能出现的反射类都注册到 SVM 中,从而让 SVM 在运行时能够找到这些类,这样就可以解决反射类的问题了,同时采取 Native 编译的 SpringBoot-web 模块也会自动注册入参和返回值的反射类,这样就可以解决大部分运行中使用反射类的问题了

Spring Native

众所周知,在 Java 领域,Spring 家族其实已经是事实上的标准了,甚至提到 Java 就已经约等于 SpringBoot 的的地步,所以 Spring 社区也在积极的推进 Spring Native 的开发,让 Spring 应用也能够享受到 AOT 特性带来的优势

Spring Native 是 Spring 官方提供的一个新的特性,它可以将 Spring 应用编译成机器码,从而提升应用的启动速度

实际上 Java 做 AOT 特性已经挺久了,比如 Excelsior JET,但是这些都是商业产品,而且都是基于 OpenJDK 的,所以性能上也不会有太大的提升,而 GraalVM 的出现,让我们可以在社区版的基础上实现 AOT 特性,此次 Spring Native 也选择 GraalVM 作为 AOT 的基础实现。

Spring Native 的使用

Spring Native 的使用非常简单,只需要在项目中引入对应的依赖即可,由于 Gradle 相较于 Maven 更加灵活,故官方推荐使用 Gradle 作为构建工具。

Native 插件与项目依赖

首先为项目添加常见的插件依赖,注意最下方的org.graalvm.buildtools.native插件,这个插件是 Spring Native 的核心插件,它会在构建时自动调用 GraalVM 的 AOT 编译功能,可以观察 Gradle 的构建任务在引入了这个插件之后多出了 Native 相关的任务,例如 nativeBuildprocessAot 等与 AOT 编译相关的任务

1
2
3
4
5
6
plugins {
id 'java'
id 'org.springframework.boot' version '3.1.4'
id 'io.spring.dependency-management' version '1.1.3'
id 'org.graalvm.buildtools.native' version '0.9.27'
}

其余的依赖于 Spring Boot 项目的构建方式一致,这里不再赘述,此处构建一个简单的 CRUD 应用,使用的依赖如下,直接使用 H2 内存数据库,方便快速构建与预览 Native 特性

1
2
3
4
5
6
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'com.h2database:h2'
}

Native 配置

快速书写一组 CRUD 接口,在此处实现的是“标签”相关的接口,包括标签的增删改查。

1
2
3
4
# native方式运行
Started MainApplication in 0.644 seconds (process running for 0.663)
# 传统jit方式运行
Started MainApplication in 3.724 seconds (process running for 4.386)

笔者使用的 M2Pro 版本的 MacBook Pro,由于 Gradle 在这个时间节点还没有支持 JDK 21,所以使用的是 x86_64 的 Native 构建,在 Apple Silicon 架构的 Mac 上,可以使用之后发布的 GraalVM 21,采用 arm64 架构的 Native 构建,这样可以获得更好的性能

`Warning: The host machine does not support all features of ‘x86-64-v3’. Falling back to ‘-march=compatibility’ for best compatibility.``

从启动日志上可以看到,当使用 Native 方式运行时,启动时间从 4.386s 提升到了 0.663s,启动时间提升了 6.6 倍(这还是在 Rosetta 2 将 x86 机器码转译成 arm64 的情况下得到的性能提升)

这个提升对于 Java 而言是非常可观的,在 Serverless 的场景下,这个提升可以让我们的应用更加快速的响应请求,传统的 Java 程序由于 JIT 的机制,需要大量的预热时间,这个时间在 Serverless 场景下是无法接受的,所以 Spring Native 的出现,让 Java 也能够在 Serverless 场景下发挥出更好的性能。

使用到反射的类处理

在这个 demo 中,我遇到了不少与 Native 相关的错误,比较经典的就是注解实现报错。

在后续的版本更新中似乎已经能够检测到自定义注解中的反射并正确配置了,但若有其他的反射类报错,可以参考下面的解决方案

我的具体使用场景是如下:我定义了一个注解处理器,使用validation来验证前端传入的字段值是否存在于我定义的一个枚举类内,这个注解处理器的代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class EnumValueValidator implements ConstraintValidator<EnumValue, String> {
private final Set<String> values = new HashSet<>();

@Override
public void initialize(EnumValue annotation) {
Class<? extends Enum<?>> enumClass = annotation.enumClass();
Enum<?>[] enumConstants = enumClass.getEnumConstants();
for (Enum<?> enumConstant : enumConstants) {
values.add(enumConstant.toString());
}
}

@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) {
return true;
}
return values.contains(value);
}
}

上面的代码的 initialize 方法中,通过 annotation.enumClass() 获取了注解 EnumValue 中的枚举类的信息,然后使用 getEnumConstants() 方法通过反射获取了该枚举类的所有枚举常量,并将它们的字符串表示添加到 values 集合中。

故这个注解会在运行时被反射调用,所以需要在 Native 编译时将这些注解处理器注册到 SVM 中,否则会报错

由于 GraalVM 的 AOT 功能还是试验性功能,这些类的注册随着版本更新可能会被检测到并自动注册,请根据自己的情况来确定需要注册的反射类

这个时候可以使用 Spring Native 提供的 @RegisterReflectionForBinding 注解,将所有的反射类都注册到 SVM 中,在声明了@SpringBootConfiguration的类上添加该注解即可

1
2
3
4
5
6
7
@SpringBootConfiguration
@RegisterReflectionForBinding({
EnumValueValidator.class,
PropertyNamingStrategies.SnakeCaseStrategy.class,
PropertyNamingStrategies.LowerCamelCaseStrategy.class
})
public class NativeReflectionConfig {}