Spring Native初探


近几年“原生”一词一直泛滥在云计算、边缘计算等领域中,而原生宠幸的语言也一直都是Golang,Rust等脱离Sandbox运行的开发语言。Java得益于上世纪流行的一次编译,到处执行的理念,流行至今,但也因为这个原因,导致Java程序脱离不了JVM运行环境,使得不那么受原生程序的青睐。在云原生泛滥的今天,臃肿的JVM使Java应用程序对比其他语言显得无比的庞大,各路大神也想了很多方式让Java变的更“原生”。最近Spring推出了Spring Native概念,并参考了其他大牛的文章后,今天我们就一探如何让用Spring Boot编写原生应用。

Spring Native借助GraalVM native-image编译器来编译Spring应用,所以我们需要先来了解一下GraalVM。大部分脚本语言或者有动态特效的语言都需要一个语言虚拟机运行,比如CPython,Lua,Erlang,Java,Ruby,R,JS,PHP,Perl,APL等等,但是这些语言的虚拟机水平参差不齐,例如JVM的HotSpotVM、JS的V8都是“艺术”级别的,但CPython的VM就不忍直视。那能不能用一个“艺术”级别的虚拟机跑所有的语言呢?GraalVM就是这么一个高性能的救世主,它使用运行在JVM上的Truffle语言框架,将AST节点编译为机器代码,使用户只需要实现具体语言AST解释器,就能实现性能足够好的虚拟机,而实现这个编译器也是一个Java写的即时编译器Graal,GraalVM也因此得名。

也许有同学会问了怎么用Java语言编译Java代码呢,而且还是这么高性能?这我们就要说说JEP 243的JVMCI。众所周知,HotSpot JVM内置了两个C++写的即时编译器(JIT)C1和C2,一般频繁的代码先用C1编译,如果热点继续,那么会使用C2编译。JVMCI相当于把本该交给C2编译的代码交给高级JIT:Graal编译,说到底就是将一段byte[]在运行时换成另一段byte[]

那像Go和C/C++这类语言是否也能运行在JVM上呢?答案是肯定的。解决方案是将C/C++这些语言用一些工具(如Clang)转换为LLVM IR,然后使用基于Truffle的AST解释LLVM IR即可。(但,我们为啥要这么做??)
1.png

到目前为止,几乎所有的语言都能在以JVM为基础,以Graal即时编译器为核心的虚拟机上运行起来了,但大家已经一定疑惑了,程序运行需要依赖JVM,而JVM必须提前安装JDK环境,而且自身启动慢,内存负载高,就不能把程序直接打包成平台相关可执行文件吗?答案是SubstrateVM,它借助Graal编译器,可以将Java程序AOT编译为可执行程序。所以万能的Graal编译器不仅能JIT,还能AOT。
2.png

好了,我们这些“CRUD仔”们了解这些基础魔法就足够了,至于SVM如何解决反射、GC等问题的高级魔法还是交给大牛们吧。现在进入我们的正题:用Spring Boot来编写一个原生应用。

制作过程

Step 1:安装GraalVM和依赖工具

因为大家都比较熟悉JDK安装过程,所以本过程带过了一些细节,不做重点讲解。首先我们需要安装GraalVM,笔者以自己的macOS系统为例,其他系统请参考官方安装文档。比较遗憾的是,GraalVM并没有提供针对M1优化的AArch64平台的包,我们只能使用AMD64平台,下载地址点击这里,我们使用Java 17版本的darwin压缩包,解压至:
/Library/Java/JavaVirtualMachines/

并且设置JAVA_HOME:
export GRAALVM17_HOME=$(/usr/libexec/java_home -v 17)
export JAVA_HOME=$GRAALVM17_HOME

为了使用方便也可以设置Alias:
alias java17g='export JAVA_HOME=$GRAALVM17_HOME;java -version'

由于macOS的安全限制,需要删除quarantine
$ sudo xattr -r -d com.apple.quarantine $GRAALVM17_HOME

我们依然需要Maven作为本次探索的打包工具,请大家自行安装Maven,这里不再赘述。一切安装完成,我们可以运行java -versionmvn -v来验证一下安装是否成功。

$ java -version
openjdk version "17.0.1" 2021-10-19
OpenJDK Runtime Environment GraalVM CE 21.3.0 (build 17.0.1+12-jvmci-21.3-b05)
OpenJDK 64-Bit Server VM GraalVM CE 21.3.0 (build 17.0.1+12-jvmci-21.3-b05, mixed mode, sharing)

最后,我们需要安装native-image作为原生代码编译工具:
$ cd $GRAALVM_HOME/bin
$ ./gu install native-image

当然,Xcode工具包因为包含GCC等工具,也必须安装,如已经安装可跳过。
$ sudo xcode-select --install

Step 2:建立Spring Boot应用

按着官方的向导建立一个基于Spring Boot2.6.2版本,Java版本使用1.8的Web应用。注意一定要使用最新的2.6.2+版本,否则不支持AOT功能。并且,Java版本也只支持1.8。目录如下:
.
├── HELP.md
├── pom.xml
├── src
│   ├── main
│   │   ├── java
│   │   │   └── com
│   │   │       └── ajk
│   │   │           └── testspringnative
│   │   │               └── TestSpringNativeApplication.java
│   │   └── resources
│   │       ├── application.yml
│   │       ├── static
│   │       └── templates
│   └── test
│       └── java
│           └── com
│               └── ajk
│                   └── testspringnative
│                       └── TestSpringNativeApplicationTests.java

其中TestSpringNativeApplication代码如下:
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@SpringBootApplication
@RestController
public class TestSpringNativeApplication {

public static void main(String[] args) {
    SpringApplication.run(TestSpringNativeApplication.class, args);
}

@GetMapping("/hello")
public String hello(@RequestParam(value = "name", defaultValue = "World") String name) {
    return String.format("Hello %s!", name);
}



配置文件application.yml代码如下:
server:
port: 9000
shutdown: graceful

spring:
profiles:
active: default

logging:
level:
root: info

Step 3:配置Maven

为了方便演示,我们使用了最简单的代码和配置,重点是Maven的配置,以至于我需要用整个Step来说明。

由于使用了官方向导生成的项目,所以基础pom.xml文件如下:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.6.2</version>
    <relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.ajk</groupId>
<artifactId>test-spring-native</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>test-spring-native</name>
<description>test-spring-native</description>
<properties>
    <java.version>1.8</java.version>
</properties>
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
<dependencies>
<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
    </plugins>
</build>
</project>

接下来我们开始配置Spring Boot Native,官方有两种方式实现编译原生应用:
  • 用Spring Boot Buildpacks生成包含原生应用的OCI容器。
  • 用GraalVM native image Maven plugin生成原生应用。


由于篇幅关系,这里只介绍第二种方式,即编译为原生应用。

首先增加包和插件依赖库:
<repositories>
<!-- ... -->
<repository>
    <id>spring-release</id>
    <name>Spring release</name>
    <url>https://repo.spring.io/release</url>
</repository>
</repositories>
<pluginRepositories>
<!-- ... -->
<pluginRepository>
    <id>spring-release</id>
    <name>Spring release</name>
    <url>https://repo.spring.io/release</url>
</pluginRepository>
</pluginRepositories>

再次确认我们的Spring Boot版本为2.6.2(因为Spring Native 0.11.1版本支持此版本),并添加如下依赖:
<dependencies>
<!-- ... -->
<dependency>
    <groupId>org.springframework.experimental</groupId>
    <artifactId>spring-native</artifactId>
    <version>0.11.1</version>
</dependency>
</dependencies>

添加Spring AOT部署插件:
<build>
<plugins>
    <!-- ... -->
    <plugin>
        <groupId>org.springframework.experimental</groupId>
        <artifactId>spring-aot-maven-plugin</artifactId>
        <version>0.11.1</version>
        <executions>
            <execution>
                <id>generate</id>
                <goals>
                    <goal>generate</goal>
                </goals>
            </execution>
            <execution>
                <id>test-generate</id>
                <goals>
                    <goal>test-generate</goal>
                </goals>
            </execution>
        </executions>
    </plugin>
</plugins>
</build>

再添加原生编译插件,这里使用一个profile来更好的管理:
<profiles>
<profile>
    <id>native</id>
    <build>
        <plugins>
            <plugin>
                <groupId>org.graalvm.buildtools</groupId>
                <artifactId>native-maven-plugin</artifactId>
                <version>0.9.9</version>
                <extensions>true</extensions>
                <executions>
                    <execution>
                        <id>build-native</id>
                        <goals>
                            <goal>build</goal>
                        </goals>
                        <phase>package</phase>
                    </execution>
                </executions>
                <configuration>
                    <!-- ... -->
                </configuration>
            </plugin>
            <!-- Avoid a clash between Spring Boot repackaging and native-maven-plugin -->
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <classifier>exec</classifier>
                </configuration>
            </plugin>
        </plugins>
    </build>
</profile>
</profiles>

一切妥当!开始编译吧!
$ mvn clean -Pnative -DskipTests package

官方推荐编译的机器不能少于8核8G内存,否则编译工具会报错。在我的M1的机器上,编译大概需要10分钟左右,编译时CPU峰值使用率大概在50%,内存占用6.9GB。

简单评测

首先看一下编译文件大小对比:
  • fatjar包文件为17.8M(不包含JRE),原生可执行文件为68.2M。
  • 使用spring-boot-maven-plugin生成包含JRE运行环境的容器镜像大小为270M,而使用Tiny Core Linux+原生应用的形式,镜像大小可以控制在100M以内,为96M。压缩比达到35%之多 。


再来看看启动速度对比:
  • fatjar启动时间为8.2s
  • 原生文件启动时间为5.6s


程序使用CPU和内存对比:
  • fatjar空载CPU 0.5%,内存使用528M
  • 原生应用空载CPU 0.3%,内存使用85M


如下表格:

b.png

总体来讲,原生应用从产物大小,启动速度,运行负载来讲都优与Jar包应用,这还是在没有针对arm的指令集做优化的基础上的,但对比官方宣传的内存使用20M内存占用还有一定差距。

总结

经过几天折腾,GraalVM的性能即使不编译为原生应用也优于HotSpot VM,在编译为原生应用后,性能也有一定的提升。但目前Spring Native还不够成熟,笔者想用undertow代替Tomcat Web容器而编译后的原生应用,始终无法运行。相信后面版本应该会修复一些问题。

本文总结了一种编译原生的方式,另一种生成原生镜像的方式大家可以自行研究(注意,编译成原生镜像需要阅读大量文章)。另外,由于时间有限,在两者的压测过程中,原生应用GC回收内存速度快于jar包应用,大家也可以深入研究原生内存回收方式。

所有代码可在GitHub上参考。

参考资料:
  1. https://www.graalvm.org/docs/introduction/
  2. https://docs.spring.io/spring- ... ngle/
  3. https://openjdk.java.net/jeps/243
  4. http://trufflesuite.com/truffle/
  5. https://github.com/graalvm/labs-openjdk-17


作者:黄凯,58安居客新房技术部负责人。拥有10多年Java/Go研发经验,多年从事云计算研究和架构设计经验,对系统架构和业务架构都有一定研究。加入58安居客之前曾任职于百姓网、拼多多、沪江教育、IBM、HP等互联网和外企公司。

0 个评论

要回复文章请先登录注册