从零放弃学习 Spring - Native Image 与 Spring

GraalVM Native Image

GraalVM 是 Oracle 开发的 JDK,本身就提供了更好性能的 JVM,GraalVM Native Image 可以通过 AOT 把 Java 代码直接编译成二进制可执行文件,编译后的二进制不再依赖 JVM,更快的启动速度,更小的运行内存,并且不会对性能有大影响。

Native Image 会执行静态分析,从应用入口(即 main)开始,找到所有会被访问的方法,并且只会编译找到的代码。完全按照 JVM 执行的逻辑去静态分析是不现实的,所以 Native Image 静态分析基于一个叫*封闭世界假设(closed-world assumption)*的约束:只考虑构建时的已知代码,不允许在运行时增加加载新代码。

下载安装 GraalVM 后你还需要安装必要的构建工具和 z-lib,例如 Ubuntu 下:

1
apt-get install build-essential zlib1g-dev

Native Image 构建 GRPC 应用

完整代码参考 native-grpc

代码基本上就是 grpc-from-scratch,为了 Native Image 编译简单,去掉了 logback。

对于这种比较简单的项目,只需要简单修改 pom.xml 就可以了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<build>
...
<plugins>
...
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<version>0.9.22</version>
<configuration>
<mainClass>org.example.Main</mainClass>
<buildArgs>
--initialize-at-run-time=io.netty.handler.ssl.BouncyCastleAlpnSslUtils
</buildArgs>
</configuration>
</plugin>
</plugins>
</build>

使用 Maven 构建可执行文件:

1
mvn native:compile

这里有一个参数 --initialize-at-run-time=io.netty.handler.ssl.BouncyCastleAlpnSslUtils,如果不加上的话会出现 Unable to initialize BouncyCastleAlpnSslUtils 异常。不过因为我们代码里没有使用加密,这个异常不会影响代码正常运行。关于 run-time 和 build-time 概念我就不翻译文档了,参考 Build Time vs Run Time

在日常开发时可以加入 -Ob 构建参数加速构建

Build Configuration

Native Image 可以通过 Build Configuration 指示正确的构建行为。

官方推荐的布局如下:

1
2
3
4
5
META-INF/
└── native-image
└── groupID
└── artifactID
└── native-image.properties

Native Image 构建会扫描 META-INF/native-image 目录下的构建配置 native-image.properties,使用 groupIDartifactID 是为了多个模块都可能生成各自的配置文件,构建时要避免它们产生冲突,但很扯的是,如果你按照这个方式放置你自己项目需要的构建配置,可能会和插件为你的项目生产的配置文件冲突。

元数据配置

Native Image 静态分析没办法处理 Java 动态特性,如果用到了这些动态特性,要在构建配置中添加对元数据的配置。

当然,手动编写这类配置是非常繁琐的,所以 GraalVM 提供了一个 Agent 自动收集元数据信息,并且生成对应的配置文件,使用方法如下:

1
2
3
mvn package
cd target/
java -agentlib:native-image-agent=config-output-dir=./out-config -jar xxx.jar

上面命令会把结果生成到 target/out-config 下,把这些文件复制到 META-INF/native-image 下就可以了。

官方文档 中建议结合 config-merge-dir 多次执行以覆盖到更全面的动态特性。

Native Image 构建 Spring Boot 应用

像 Spring 这样的框架往往就会用到很多动态特性,所以会涉及到大量构建配置,好在 Spring 已经支持 Native Image,只需要引入 GraalVM Native Support 构建插件,开箱即用:

1
mvn -Pnative native:compile

指定 native Profile,Spring 构建插件会在构建时自动生成需要的元数据配置,无需任何额外配置。

除了上面这种方式构建 Spring 项目为可执行文件,Spring 构建插件还支持直接构建出含可执行文件的 docker 镜像:

1
mvn -Pnative spring-boot:build-image

使用这种方式构建(默认)会下载 paketobuildpacks/builder:tiny 镜像作为 builder 镜像,并且它自己会准备一套构建环境,例如 BellSoft Liberica JDK,native-image 等等,构建完成后还会基于 paketobuildpacks/run:tiny-cnb 镜像制作应用镜像,paketobuildpacks/run 是一个类似 distroless 的精简镜像,最后进程运行在用户 1000 上。

PGO

Native Image 支持 PGO,虽然官方文档中说社区版不可用。

下面是官方文档中示例代码的运行结果,运行环境是腾讯云 S6.2XLARGE16,SDK 是 graalvm-jdk-17.0.8+9.1(不用在意数据,我在后面总结了):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
$ javac Streams.java
$ java Streams 100000 200
Iteration 1 finished in 227 milliseconds with checksum e6e0b70aee921601
Iteration 2 finished in 79 milliseconds with checksum e6e0b70aee921601
Iteration 3 finished in 39 milliseconds with checksum e6e0b70aee921601
...
Iteration 6 finished in 28 milliseconds with checksum e6e0b70aee921601
...
Iteration 9 finished in 22 milliseconds with checksum e6e0b70aee921601
...
Iteration 20 finished in 31 milliseconds with checksum e6e0b70aee921601
TOTAL time: 859

$ native-image Streams
$ ./streams 100000 200
Iteration 1 finished in 163 milliseconds with checksum e6e0b70aee921601
...
Iteration 5 finished in 144 milliseconds with checksum e6e0b70aee921601
...
Iteration 20 finished in 147 milliseconds with checksum e6e0b70aee921601
TOTAL time: 2952

$ native-image --pgo-instrument Streams
$ ./streams 100000 20
Iteration 1 finished in 416 milliseconds with checksum e6e0b70aee921601
...
Iteration 4 finished in 399 milliseconds with checksum e6e0b70aee921601
...
Iteration 20 finished in 401 milliseconds with checksum e6e0b70aee921601
TOTAL time: 8019

$ native-image --pgo=default.iprof Streams
$ ./streams 100000 200
Iteration 1 finished in 31 milliseconds with checksum e6e0b70aee921601
...
Iteration 3 finished in 30 milliseconds with checksum e6e0b70aee921601
...
Iteration 20 finished in 31 milliseconds with checksum e6e0b70aee921601
TOTAL time: 613

$ native-image Streams --gc=G1 -march=native
$ ./streams 100000 200
Iteration 1 finished in 169 milliseconds with checksum e6e0b70aee921601
...
Iteration 5 finished in 156 milliseconds with checksum e6e0b70aee921601
...
Iteration 20 finished in 157 milliseconds with checksum e6e0b70aee921601
TOTAL time: 3194

$ native-image --pgo-instrument Streams --gc=G1 -march=native
$ ./streams 100000 200
Iteration 1 finished in 433 milliseconds with checksum e6e0b70aee921601
...
Iteration 8 finished in 415 milliseconds with checksum e6e0b70aee921601
...
Iteration 20 finished in 417 milliseconds with checksum e6e0b70aee921601
TOTAL time: 8430

$ native-image --pgo=default.iprof Streams --gc=G1 -march=native
$ ./streams 100000 200
Iteration 1 finished in 35 milliseconds with checksum e6e0b70aee921601
...
Iteration 4 finished in 34 milliseconds with checksum e6e0b70aee921601
...
Iteration 20 finished in 35 milliseconds with checksum e6e0b70aee921601
TOTAL time: 693

可以看到 GraalVM JIT 的性能极限是 22ms,PGO 前耗时 141-159ms,PGO 后耗时完全稳定在 30-31ms。我还测了使用 --gc=G1-march=native 的情况,如果单用任何一个选项,性能是不会有大变化的,但是两个选项一起用以后,性能下降了一点,这点让我非常疑惑。

--pgo-instrument 为了生成 default.iprof,但是在一次测试中我发现 checksum 值变了,不知道是什么原因。

代码参考 native-pgo

最后

根据我之前测试的观察,Native Image 构建后的可执行文件启动内测开销 60MiB,对应 JVM 上运行时 210MiB,执行过 bench 后 Native Image 版内存 80MiB,对应 JVM 上 600MiB,虽然性能会比 JIT 极限性能差,至少内存节约上时非常可观的。

不过 Native Image 在构建时 CPU 和内存开销非常大,云上 8 核 16GiB 机器构建一个简单的 GRPC 的应用,native-image 执行耗时 2min(其中大部分时间 CPU 处于满负荷),最高时内存开销 5GiB。如果想在 CI 中集成也必须考虑到构建机器的成本,并发任务的限制还有其他开销。

参考