SpringBoot 3 进阶教程之二 GraalVM 与 AOT

大纲

前言

本文主要介绍 SpringBoot 3 如何使用 AOT 技术,包括在 Windows、Linux 平台使用 GraalVM 将 SpringBoot 应用编译成原生镜像(二进制可执行文件)。

AOT 与 JIT

  • AOT:Ahead of Time(提前编译),程序执行前,全部被编译成机器码
  • JIT:Just in Time(即时编译),程序边编译边运行

编译器与解释器

编程语言的分类

  • 编译型语言:依赖编译器 (Complier),如 C、C++
  • 解释型语言:依赖解释器 (Interpreter),如 JavaScrpt、Python

对比项目编译器 (Complier) 解释器 (Interpreter)
机器执行速度快,因为源代码只需被转换一次慢,因为每行代码都需要被解释执行
开发效率慢,因为需要耗费大量时间编译快,无需花费时间生成目标代码,更快的开发和测试
调试难以调试编译器生成的目标代码容易调试源代码,因为解释器一行一行地执行
可移植性(跨平台)不同平台需要重新编译目标平台代码同一份源码可以跨平台执行,因为每个平台会开发对应的解释器
学习难度相对较高,需要了解源代码、编译器以及目标机器的知识相对较低,无需了解机器的细节
错误检查编译器可以在编译代码时检查错误解释器只能在执行代码时检查错误
运行时增强可以动态增强

AOT 与 JIT 对比

在 OpenJDK 的官方 Wiki 上,介绍了 HotSpot 虚拟机一个相对比较全面的、即时编译器(JIT)中采用的 优化技术列表。Java 应用可以使用 JVM 参数 -XX:+PrintCompilation 打印 JIT 编译信息。

JVM 编译原理

JVM 整体架构

JVM 既有解释器,又有编译器,因此可以说 Java 是半编译半解释的编程语言。

Java 执行流程

流程概要

详细流程

JVM 编译器

  • JVM 中集成了两种编译器,分别是 Client Compiler 和 Server Compiler

    • Client Compiler:注重启动速度和局部的优化
    • Server Compiler:更加关注全局优化,性能更好,但由于会进行更多的全局分析,所以启动速度会慢
  • Client Compiler

    • HotSpot 虚拟机带有一个 Client Compiler C1 编译器
    • 这种编译器启动速度快,但是性能比较 Server Compiler 来说会差一些
    • 编译后的机器码执行效率没有 C2 的高
  • Server Compiler

    • Hotspot 虚拟机中使用的 Server Compiler 有两种: C2 和 Graal
    • 在 Hotspot 虚拟机中,默认的 Server Compiler 是 C2 编译器

分层编译

在 Java 7 以前,需要研发人员根据服务的性质去选择编译器。对于需要快速启动的,或者一些不会长期运行的服务,可以采用编译效率较高的 C1,对应参数 -client。长期运行的服务,或者对峰值性能有要求的后台服务,可以采用峰值性能更好的 C2,对应参数 -server。Java 7 开始引入了分层编译的概念,它结合了 C1 和 C2 的优势,追求启动速度和峰值性能的一个平衡。分层编译将 JVM 的执行状态分为了五个层次。五个层级分别是:

  • 解释执行
  • 执行不带 profiling 的 C1 代码
  • 执行仅带方法调用次数以及循环回边执行次数 profiling 的 C1 代码。
  • 执行带所有 profiling 的 C1 代码
  • 执行 C2 代码

  • 图中第 ① 条路径,代表编译的一般情况,热点方法从解释执行到被 3 层的 C1 编译,最后被 4 层的 C2 编译。
  • 如果方法比较小(比如 Java 服务中常见的 gettersetter 方法),3 层的 profiling 没有收集到有价值的数据,JVM 就会断定该方法对于 C1 代码和 C2 代码的执行效率相同,就会执行图中第 ② 条路径。在这种情况下,JVM 会在 3 层编译之后,放弃进入 C2 编译,直接选择用 1 层的 C1 编译运行。
  • 在 C1 忙碌的情况下,执行图中第 ③ 条路径,在解释执行过程中对程序进行 profiling ,根据信息直接由第 4 层的 C2 编译。
  • 由于 C1 中的执行效率是 1 层 > 2 层 > 3 层,第 3 层一般要比第 2 层慢 35% 以上,所以在 C2 忙碌的情况下,执行图中第 ④ 条路径。这时方法会被 2 层的 C1 编译,然后再被 3 层的 C1 编译,以减少方法在 3 层的执行时间。
  • 如果编译器做了一些比较激进的优化,比如分支预测,在实际运行时发现预测出错,这时就会进行反优化,重新进入解释执行,图中第 ⑤ 条执行路径代表的就是反优化。

总的来说,C1 的编译速度更快,C2 的编译质量更高,分层编译的不同编译路径,也就是 JVM 根据当前服务的运行情况来寻找当前服务的最佳平衡点的一个过程。从 JDK 8 开始,JVM 默认开启分层编译。

云原生介绍

在云原生 (Cloud Native) 的背景下,Java 应用的运行条件发生了变化。

  • 存在的问题

    • Java 应用如果用 Jar 启动,解释执行后热点代码才被编译成机器码,会导致初始启动速度慢,初始处理请求数量少。
    • 大型云平台,要求每一种应用都必须秒级启动,每个应用都要求高效率。
  • 希望的效果

    • Java 应用也能提前被编译成机器码,随时急速启动,一启动就急速运行,追求最高性能
    • 编译成机器码的优点
      • 服务器不需要安装 Java 运行环境
      • 应用编译成机器码后,可以在 Windows x64 等平台直接运行

原生镜像是什么?

  • 原生镜像 (Native Image):机器码、本地镜像
  • 把应用打包成能适配本机平台的可执行文件(机器码、本地镜像)

GraalVM 介绍

GraalVM 是一个高性能的 JDK,旨在加速用 Java 和其他 JVM 语言编写的应用程序的执行,同时还提供 JavaScript、Python 和许多其他流行语言的运行时。GraalVM 提供了两种运行 Java 应用程序的方式:

  • 第一种运行方式:在 HotSpot JVM 上使用 Graal 即时编译器(JIT)
  • 第二种运行方式:作为预先编译(AOT)的本机可执行文件运行(原生镜像)

值得一提的是,GraalVM 的多语言能力使得在单个应用程序中混合多种编程语言成为可能,同时消除了外部语言调用的成本。

整体架构

工作原理

GraalVM 跨平台提供原生镜像的原理如下图所示:

特别说明

目前并不是所有 Java 代码都支持直接使用 GraalVm 编译成原生镜像(二进制可执行文件),具体存在的兼容问题如下:

  • 动态能力损失:

    • 问题描述:GraalVM 不支持直接编译反射代码,包括动态获取构造器、反射创建对象、反射调用等
    • 解决方案:额外使用 SpringBoot 提供的一些注解,提前告知 GraalVm 反射会用到哪些构造器、方法等
  • 配置文件损失:

    • 问题描述:以二进制可执行文件的方式运行 Java 应用,项目内原有的配置文件会失效
    • 解决方案:额外处理(如使用配置中心、使用配置文件的相对路径等),提前告知 GraalVM 配置文件怎么处理

值得一提的是,SpringBoot 可以保证 Spring 应用都能在使用 AOT 特性的时候,提前告知 GraalVm 怎么处理,但并不是所有框架都适配了 AOT 特性,尤其是第三方框架。

Linux 平台 编译原生镜像

在 Linux 平台,使用 GraalVM 编译原生镜像,需要提前安装 GCC 和 GraalVM。

GCC 安装

安装 GCC/G++ 的目的是为了可以编译 C/C++ 代码。

1
yum install -y gcc gcc-c++ glibc-devel kernel-devel zlib-devel

Graalvm 安装

下载软件

根据开发平台和 JDK 版本,在 GraalVM GitHub Releases 页面上下载 GraalVM 与 Native Image 的软件包(如下图)。值得一提的是,GraalVM 有两种版本,分别是社区版和商业版,开发环境一般使用社区版即可。

配置环境

解压 GraalVM 的软件包(如 graalvm-ce-java17-linux-amd64-22.3.3.tar.gz),然后添加或更改系统环境变量 JAVA_HOMEPath,其中 JAVA_HOME 指向 GraalVM 的解压目录,PATH 则指向 GraalVM 的 bin 目录路径。

1
2
3
4
5
6
7
8
9
10
11
12
# 解压文件
tar -zxvf graalvm-ce-java17-linux-amd64-22.3.2.tar.gz -C /opt/java/

# 添加环境变量
vim /etc/profile

export JAVA_HOME=/opt/java/graalvm-ce-java17-22.3.2
export CLASSPATH=.:$JAVA_HOME/lib
export PATH=$PATH:$JAVA_HOME/bin

# 使环境变量生效
source /etc/profile

在命令行终端输入 java -version 命令,验证 JDK 环境是否为 GraalVM 提供的。

提示

上述配置 GraalVM 环境变量的方式,与平时配置 OpenJDK 或者 Oracle JDK 的环境变量并没有任何区别。

安装工具

安装 Native Image 工具,用于编译生成原生镜像,安装方式分为在线安装和离线安装两种,如下所示:

  • 网络环境好,可以选择在线安装 Native Image
1
gu install native-image
  • 网络环境不好,可以使用上面下载好的 Native Image Jar 包(如 native-image-installable-xxxx.jar)进行离线安装
1
gu install --file native-image-installable-svm-java17-linux-amd64-22.3.3.jar
  • 验证工具的安装结果
1
native-image --help

提示

更多关于 Native Image 的安装说明,可以参考 GraalVM 官方文档

编译原生镜像

本章节完整的案例代码可以直接从 GitHub 下载对应章节 spring-boot3-18

创建项目

创建普通 Maven 项目,编写 Main 主类

  • 使用 mvn clean package 命令进行打包
  • 使用 java -jar xxx.jar 命令确认 Jar 包是否可以执行
  • 若 Jar 包不能正常执行,则需要通过 Maven 插件指定主类的全类名,如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.1.2</version>
<configuration>
<archive>
<manifest>
<mainClass>com.clay.MainApplication</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
</plugins>
</build>

编译镜像

在命令行终端使用 native-image 工具编译原生镜像(二进制可执行文件),如下所示:

  • 第一种编译方式
1
2
3
4
5
# 只编译某个类,该类必须有 main 方法,否则无法编译(下面的 classes 目录一般是在 Maven 项目编译生成的 target 目录下)
native-image -cp classes com.clay.boot.MainApplication -o graalvm-demo

# 执行生成的原生镜像(二进制可执行文件)
./graalvm-demo
  • 第二种编译方式
1
2
3
4
5
# 从主类开始,编译整个 Jar 包(下面的 Jar 包一般是在 Maven 项目编译生成的 target 目录下)
native-image -cp spring-boot3-18-1.0.jar com.clay.boot.MainApplication -o spring-boot3-18

# 执行生成的原生镜像(二进制可执行文件)
./spring-boot3-18

Windows 平台 编译原生镜像

在 Windows 平台,使用 GraalVM 编译原生镜像,需要提前安装 GraalVM 和 Visual Studio。

GraalVM 安装

下载软件

根据开发平台和 JDK 版本,在 GraalVM GitHub Releases 页面上下载 GraalVM 与 Native Image 的软件包(如下图)。值得一提的是,GraalVM 有两种版本,分别是社区版和商业版,开发环境一般使用社区版即可。

配置环境

解压 GraalVM 的软件包(如 graalvm-ce-java17-windows-amd64-22.3.3.zip),然后添加或更改系统环境变量 JAVA_HOMEPath,其中 JAVA_HOME 指向 GraalVM 的解压目录,PATH 则指向 GraalVM 的 bin 目录路径。

在 CMD 窗口输入 java -version 命令,验证 JDK 环境是否为 GraalVM 提供的。

提示

上述配置 GraalVM 环境变量的方式,与平时配置 OpenJDK 或者 Oracle JDK 的环境变量并没有任何区别。

安装工具

安装 Native Image 工具,用于编译生成原生镜像,安装方式分为在线安装和离线安装两种,如下所示:

  • 网络环境好,可以选择在线安装 Native Image
1
gu install native-image
  • 网络环境不好,可以使用上面下载好的 Native Image Jar 包(如 native-image-installable-xxxx.jar)进行离线安装
1
gu install --file native-image-installable-svm-java17-windows-amd64-22.3.3.jar
  • 验证工具的安装结果
1
native-image --help

提示

更多关于 Native Image 的安装说明,可以参考 GraalVM 官方文档

Visual Studio 安装

安装 Visual Studio 的目的是为了使用 VS 提供的工具链编译 C/C++ 代码。值得一提的是,可以使用 MinGW、Cygwin 等编译工具替代 Visual Studio。

安装步骤

Visual Studio 安装教程

在 Visual Studio 安装时,一般选择安装 Microsoft.VisualStudio.Workload.NativeDesktopMicrosoft.VisualStudio.Workload.Universal 这两大组件即可,分别对应下图红框内的组件。

语言包必须选择 英语,不能选择中文,否则在 GraalVM 编译原生镜像时,可能会出现各种莫名奇妙的问题。

安装步骤完成后,如果使用管理员身份可以正常运行 x64 Native Tools Command Prompt for VS 20xx 工具,则说明 Visual Studio 安装成功。

编译原生镜像

本章节完整的案例代码可以直接从 GitHub 下载对应章节 spring-boot3-18

创建项目

创建普通 Maven 项目,编写 Main 主类

  • 使用 mvn clean package 命令进行打包
  • 使用 java -jar xxx.jar 命令确认 Jar 包是否可以执行
  • 若 Jar 包不能正常执行,则需要通过 Maven 插件指定主类的全类名,如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.1.2</version>
<configuration>
<archive>
<manifest>
<mainClass>com.clay.MainApplication</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
</plugins>
</build>

编译镜像

运行 Visual Studio 的 x64 Native Tools Command Prompt for VS 20xx 工具,使用 native-image 工具编译原生镜像(二进制可执行文件),如下所示:

  • 第一种编译方式
1
2
3
4
5
# 只编译某个类,该类必须有 main 方法,否则无法编译(下面的 classes 目录一般是在 Maven 项目编译生成的 target 目录下)
native-image -cp classes com.clay.boot.MainApplication -o graalvm-demo

# 执行生成的原生镜像(二进制可执行文件)
./graalvm-demo.exe
  • 第二种编译方式
1
2
3
4
5
# 从主类开始,编译整个 Jar 包(下面的 Jar 包一般是在 Maven 项目编译生成的 target 目录下)
native-image -cp spring-boot3-18-1.0.jar com.clay.boot.MainApplication -o spring-boot3-18

# 执行生成的原生镜像(二进制可执行文件)
./spring-boot3-18.exe

SpringBoot 编译原生镜像

这里将演示 SpringBoot 项目如何使用 GraaVM 编译生成原生镜像,本章节完整的案例代码可以直接从 GitHub 下载对应章节 spring-boot3-19

特别注意

在执行以下步骤之前,必须确保在 Windows/Linux 平台配置好了 GraalVM 编译所需要的环境,包括 GraalVM 安装、Visual Studio 安装或者 GCC 安装等。

引入依赖

  • 添加 GraalVM 编译插件和 SpringBoot 打包插件的依赖
1
2
3
4
5
6
7
8
9
10
11
12
 <build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

编译镜像

  • 第一步:运行 Maven 的打包命令 mvn clean package

  • 第二步:运行 AOT 的提前处理命令 mvn spring-boot:process-aot

  • 第三步:运行 Native Image 的编译命令 mvn -Pnative native:build,其中的 -Pnative 表示激活 native 环境的 Profile 配置文件,建议都带上这参数进行编译

启动镜像

SpringBoot 应用成功编译原生镜像后,在项目的 target 目录下,可以看到编译生成的原生镜像(二进制可执行文件)。

在项目的 target 目录下,直接运行 ./spring-boot3-19.exe 就可以快速启动镜像,观察可以发现应用的整个启动过程仅仅耗费 0.137 秒。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  .   ____          _            __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v3.0.5)

2023-08-30T22:03:53.297+08:00 INFO 41976 --- [ main] com.clay.boot.MainApplication : Starting AOT-processed MainApplication using Java 17.0.8 with PID 41976
2023-08-30T22:03:53.298+08:00 INFO 41976 --- [ main] com.clay.boot.MainApplication : No active profile set, falling back to 1 default profile: "default"
2023-08-30T22:03:53.339+08:00 INFO 41976 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port(s): 8080 (http)
2023-08-30T22:03:53.342+08:00 INFO 41976 --- [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat]
2023-08-30T22:03:53.342+08:00 INFO 41976 --- [ main] o.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/10.1.7]
2023-08-30T22:03:53.357+08:00 INFO 41976 --- [ main] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext
2023-08-30T22:03:53.357+08:00 INFO 41976 --- [ main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 59 ms
2023-08-30T22:03:53.400+08:00 INFO 41976 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http) with context path ''
2023-08-30T22:03:53.401+08:00 INFO 41976 --- [ main] com.clay.boot.MainApplication : Started MainApplication in 0.137 seconds (process running for 0.15)

常见问题

Windows 平台

编译原生镜像失败

问题描述:GraalVM 编译生成原生镜像时,可能出现如下各种错误

  • 出现乱码
  • 出现 cl.exe 找不到错误
  • 提示 no include path set
  • 提示 fatal error LNK1104: cannot open file 'LIBCMT.lib'
  • 提示 LINK : fatal error LNK1104: cannot open file 'kernel32.lib'
  • 提示各种其他找不到的内容

解决方案:修改三个环境变量:PathINCLUDElib请自行根据 Visual Studio 的实际安装路径来更改环境变量的值

  • PATH 环境变量添加如下值:
1
C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.33.31629\bin\Hostx64\x64
  • 新建 INCLUDE 环境变量,值为:
1
C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.33.31629\include;C:\Program Files (x86)\Windows Kits\10\Include\10.0.19041.0\shared;C:\Program Files (x86)\Windows Kits\10\Include\10.0.19041.0\ucrt;C:\Program Files (x86)\Windows Kits\10\Include\10.0.19041.0\um;C:\Program Files (x86)\Windows Kits\10\Include\10.0.19041.0\winrt

  • 新建 lib 环境变量,值为:
1
C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.33.31629\lib\x64;C:\Program Files (x86)\Windows Kits\10\Lib\10.0.19041.0\um\x64;C:\Program Files (x86)\Windows Kits\10\Lib\10.0.19041.0\ucrt\x64