近几年“原生”一词一直泛滥在云计算、边缘计算等领域中,而原生宠幸的语言也一直都是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即可。(但,我们为啥要这么做??)
到目前为止,几乎所有的语言都能在以JVM为基础,以Graal即时编译器为核心的虚拟机上运行起来了,但大家已经一定疑惑了,程序运行需要依赖JVM,而JVM必须提前安装JDK环境,而且自身启动慢,内存负载高,就不能把程序直接打包成平台相关可执行文件吗?答案是SubstrateVM,它借助Graal编译器,可以将Java程序AOT编译为可执行程序。所以万能的Graal编译器不仅能JIT,还能AOT。
好了,我们这些“CRUD仔”们了解这些基础魔法就足够了,至于SVM如何解决反射、GC等问题的高级魔法还是交给大牛们吧。现在进入我们的正题:用Spring Boot来编写一个原生应用。
制作过程
Step 1:安装GraalVM和依赖工具
因为大家都比较熟悉JDK安装过程,所以本过程带过了一些细节,不做重点讲解。首先我们需要安装GraalVM,笔者以自己的macOS系统为例,其他系统请参考官方安装文档。比较遗憾的是,GraalVM并没有提供针对M1优化的AArch64平台的包,我们只能使用AMD64平台,下载地址点击这里[1],我们使用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 -version和mvn -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 Boot 2.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文件如下:
4.0.0
org.springframework.boot
spring-boot-starter-parent
2.6.2
com.ajk
test-spring-native
0.0.1-SNAPSHOT
test-spring-native
test-spring-native
1.8
org.springframework.boot
spring-boot-starter-web
org.springframework.boot
spring-boot-starter-test
test
org.springframework.boot
spring-boot-maven-plugin
接下来我们开始配置Spring Boot Native,官方有两种方式实现编译原生应用:
用Spring Boot Buildpacks生成包含原生应用的OCI容器。
用GraalVM native image Maven plugin生成原生应用。
由于篇幅关系,这里只介绍第二种方式,即编译为原生应用。
首先增加包和插件依赖库:
spring-release
Spring release
https://repo.spring.io/release
spring-release
Spring release
https://repo.spring.io/release
再次确认我们的Spring Boot版本为2.6.2(因为Spring Native 0.11.1版本支持此版本),并添加如下依赖:
org.springframework.experimental
spring-native
0.11.1
添加Spring AOT部署插件:
org.springframework.experimental
spring-aot-maven-plugin
0.11.1
generate
generate
test-generate
test-generate
再添加原生编译插件,这里使用一个profile来更好的管理:
native
org.graalvm.buildtools
native-maven-plugin
0.9.9
true
build-native
build
package
org.springframework.boot
spring-boot-maven-plugin
exec
一切妥当!开始编译吧!
$ 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
如下表格:
FatJar包 | Native包 | |
应用大小 | 17.8M | 68.2M |
容器镜像大小 | 270M | 96M |
启动速度 | 8.2s | 5.6s |
空载CPU | 0.5% | 0.3% |
空载内存 | 528M | 85M |
总体来讲,原生应用从产物大小,启动速度,运行负载来讲都优与Jar包应用,这还是在没有针对arm的指令集做优化的基础上的,但对比官方宣传的内存使用20M内存占用还有一定差距。
总结
经过几天折腾,GraalVM的性能即使不编译为原生应用也优于HotSpot VM,在编译为原生应用后,性能也有一定的提升。但目前Spring Native还不够成熟,笔者想用undertow代替Tomcat Web容器而编译后的原生应用,始终无法运行。相信后面版本应该会修复一些问题。
本文总结了一种编译原生的方式,另一种生成原生镜像的方式大家可以自行研究(注意,编译成原生镜像需要阅读大量文章)。另外,由于时间有限,在两者的压测过程中,原生应用GC回收内存速度快于jar包应用,大家也可以深入研究原生内存回收方式。
所有代码可在GitHub[2]上参考。
相关链接:
https://github.com/graalvm/graalvm-ce-builds/releases
https://github.com/huang-kai/test-spring-native
参考资料:
https://www.graalvm.org/docs/introduction/
https://docs.spring.io/spring-native/docs/current/reference/htmlsingle/
https://openjdk.java.net/jeps/243
http://trufflesuite.com/truffle/
https://github.com/graalvm/labs-openjdk-17
作者:黄凯,58安居客新房技术部负责人。
Kubernetes线下实战与CKA培训
本次培训在深圳开班,基于最新考纲,理论结合实战,通过线下授课、考题解读、模拟演练等方式,帮助学员快速掌握Kubernetes的理论知识和专业技能,并针对考试做特别强化训练,让学员能从容面对CKA认证考试,使学员既能掌握Kubernetes相关知识,又能通过CKA认证考试,理论、实践、考证一网打尽,学员可多次参加培训,直到通过认证。点击下方图片或者阅读原文链接查看详情。