GraalVM系列(一):JVM的未来——GraalVM集成入门

Posted by Coding Ideal World on June 12, 2020

要说JVM的未来那有很多的可能,但在云原生如日中天、Serverless日渐成熟、新语言百花齐放的当下,跨语言、Native支持、高性能低资源占用的技术必定是其璀璨的明珠,而GraalVM正是这样一个承载了JVM未来,将Java带入下一波技术浪潮的弄潮儿,本文我们就来实践下GraalVM集成支持。

Java的问题

在讲GraalVM前我们先回看下Java当前遇到的问题,概括而言如下:

  1. 云时代的掉队者,由于Java启动的高延时、对资源的高占用、导致在Serverless及FaaS架构下力不从心,在越来越流行的边缘计算、IoT方向上也是难觅踪影

  2. 系统级应用开发的旁观者,Java语言在业务服务开发中孤独求败,但在系统级应用领域几乎是C、C++、搅局者Go、黑天鹅Rust的天下

  3. 移动应用、敏捷应用的追随者,移动应用中Android逐步去Java,前端又是JS的世界,敏捷开发方面前有Ruby、Python后有NodeJS

有人说Java吃老本,不思进取,也对也不对吧,毕竟Java作为企业级软件开发最主流的语言,兼容稳定远胜于创新求变,所以Java很苦恼,即便是看似激进的JDK版本策略也敌不过臃肿守旧的印象。

怎么办?要兼容稳定,那么别打语法、API、字节码创新的心思,Java本身就那样了,但它背后的JVM却有更多的选择。Java的问题可以让JVM来补救,说资源占用高那先来个JPMS模块化(但目前看貌似并不成功),说启动延迟大那咱支持AoT搞Native吧,说对系统级应用、移动应用、敏捷应用支持不好那你行你上,我把你们都包进来纳入到我JVM大生态中,这就是GraalVM正在做的。

GraalVM简述

GraalVM是一个新的JVM,原本用于替换HotSpot的C2编译器,后来独立成JVM的一个产品,它很新但架不住对Native Image、多语言集成、高性能特性的诱惑,就连“后知后觉”的Spring也着手相关的支持工作,而新新的框架诸如quarkus、micronaut都已提供了比较好的支持。

2020 06 12 13 27 58

但是问题来了,你说得这么好,为什么不见人用,国内外找了一圈都是些介绍性的文章?原因嘛,因为GraalVM要解决的问题有很多,现有的应用、框架都需要一定的改造。前面扯了这么多,接下来才是本文的重点:以实例切入带各位体验下GraalVM的集成改造。

实例:Dew-Common GraalVM集成

Dew-Common( https://github.com/gudaoxuri/dew-common )是笔者开源的一个Java基础工具包,包含了Json、Bean(反射)、Package Scan、JS交互、Shell调用等常用操作的支持,拿这个工具包做GraalVM的集成可以比较全面的检验集成的效果。

前置准备

安装GraalVM及相关的依赖,GraalVM支持Linux、Windows及MacOS,但一般推荐在Linux下操作。笔者使用的是Windows,Windows 10 2004版本的 WSL2 提供了完整的Linux内核,非常适合开发调试(Windows是最好的Linux发行版本😂)。

# https://github.com/graalvm/graalvm-ce-builds/releases 下载需要的版本
# 解压,设置PATH/JAVA_HOME
# 添加多语言环境(可选,gu是GraalVM Updater的命令)
# 没有JS?因为JS内置了,GraalVM带了Node环境,支持各类常用的NPM包!
gu install ruby
gu install r
gu install python
gu install wasm
# 添加Native Image(可选,一般都会安装,这是GraalVM的一大亮点)
gu install native-image
gu install llvm-toolchain

POM改造

<dependencies>
    <dependency> (1)
        <groupId>org.graalvm.sdk</groupId>
        <artifactId>graal-sdk</artifactId>
        <version>${graalvm.version}</version>
        <scope>provided</scope>
    </dependency>
    <!-- HotSpot 兼容处理 --> (2)
    <dependency>
        <groupId>org.graalvm.truffle</groupId>
        <artifactId>truffle-api</artifactId>
        <version>${graalvm.version}</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.graalvm.js</groupId>
        <artifactId>js</artifactId>
        <version>${graalvm.version}</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.graalvm.js</groupId>
        <artifactId>js-scriptengine</artifactId>
        <version>${graalvm.version}</version>
        <scope>runtime</scope>
    </dependency>
</dependencies>
...
<profiles>
    <profile>
        <id>native</id> (3)
        <dependencies>
            <!-- 去除 HotSpot 兼容处理 --> (4)
            <dependency>
                <groupId>org.graalvm.truffle</groupId>
                <artifactId>truffle-api</artifactId>
                <version>${graalvm.version}</version>
                <scope>provided</scope>
            </dependency>
            <dependency>
                <groupId>org.graalvm.js</groupId>
                <artifactId>js</artifactId>
                <version>${graalvm.version}</version>
                <scope>provided</scope>
            </dependency>
            <dependency>
                <groupId>org.graalvm.js</groupId>
                <artifactId>js-scriptengine</artifactId>
                <version>${graalvm.version}</version>
                <scope>provided</scope>
            </dependency>
        </dependencies>
        <build>
            <plugins>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-surefire-plugin</artifactId> (5)
                    <configuration>
                        <argLine>
                            -agentlib:native-image-agent=access-filter-file=${project.basedir}/src/main/resources/META-INF/native-image/com.ecfront.dew/common/agent-access-filter.json,config-output-dir=${project.basedir}/src/main/resources/META-INF/native-image/com.ecfront.dew/common/
                        </argLine>
                    </configuration>
                </plugin>
                <plugin>
                    <groupId>org.graalvm.nativeimage</groupId>
                    <artifactId>native-image-maven-plugin</artifactId> (6)
                    <version>${graalvm.version}</version>
                    <executions>
                        <execution>
                            <goals>
                                <goal>native-image</goal>
                            </goals>
                            <phase>package</phase>
                        </execution>
                    </executions>
                    <configuration>
                        <skip>false</skip>
                        <mainClass>${mainClass}</mainClass>
                        <imageName>${imageName}</imageName>
                    </configuration>
                </plugin>
            </plugins>
        </build>
    </profile>
</profiles>
  1. 一般情况下我们不需要引入额外的依赖,但如果需要执行跨语言操作就必须引入 graal-sdk 依赖,该依赖提供了GraalVM特有的语法API,注意scope为provided,即它只作用于编译、测试阶段,运行时不需要

  2. 下面的几个包是用于跨语言操作的兼容处理,在GraalVM环境不需要,但在HotSpot必须引入

  3. 使用特定的profile执行Native Image打包操作

  4. Native Image由GraalVM的SubstrateVM(定制轻量VM)运行,不需要第2步引入的兼容依赖,所以这里做了排除

  5. 调用 mvn test 附加执行参数,用于Native Image动态调用的代码收集,后文会细讲

  6. Native Image打包的核心插件,这里需要指定main方法,可指定镜像的名称

Tip
GraalVM没有集成 javax 包,所以如果需要诸如validation注解则需要手工引入 jakarta.validation-api 依赖

小结如下:

  1. 只是将程序运行在GraalVM下,那么只要把GraalVM缺失的依赖(如上面说的 javax 包)引入即可

  2. 要做跨语言操作,那么完成第1、2步骤即可

  3. 要支持Native Image则必须完成后续的步骤

跨语言调用

JSR 223规范下脚本调用
private static final ScriptEngineManager SCRIPT_ENGINE_MANAGER = new ScriptEngineManager(); (1)
private Invocable invocable;
private ScriptHelper(Invocable invocable) {
    this.invocable = invocable;
}
/**
 * Build script helper.
 *
 * @param jsFunsCode    the js funs code
 * @param addCommonCode the add common code
 * @return the script helper
 * @throws RTScriptException the rt script exception
 */
public static ScriptHelper build(String jsFunsCode, boolean addCommonCode) throwsRTScriptException {
    Compilable jsEngine = (Compilable) SCRIPT_ENGINE_MANAGER.getEngineByName("nashorn");
    if (addCommonCode) {
        jsFunsCode = "var $ = Java.type('com.ecfront.dew.common.$');\r\n" + jsFunsCode; (2)
    }
    try {
        CompiledScript script = jsEngine.compile(jsFunsCode);
        script.eval();
        return new ScriptHelper((Invocable) script.getEngine());
    } catch (ScriptException e) {
        throw new RTScriptException(e);
    }
}
/**
 * Execute.
 *
 * @param <T>       the type parameter
 * @param jsFunName the js fun name
 * @param args      the args
 * @return the t
 * @throws RTScriptException              the rt script exception
 * @throws RTReflectiveOperationException the rt reflective operation exception
 */
public <T> T execute(String jsFunName, Object... args) throws RTScriptException, RTReflectiveOperationException {
    try {
        return (T) invocable.invokeFunction(jsFunName, args);
    } catch (ScriptException e) {
        throw new RTScriptException(e);
    } catch (NoSuchMethodException e) {
        throw new RTReflectiveOperationException(e);
    }
}
  1. 用ScriptEngineManager定义脚本引擎管理器

  2. 添加对Java方法的调用支持

上面是JSR 223规范下的使用方式,使用了 nashorn 引擎,但在JDK11下已经标记过时,后期会移除,为什么移除?自然是因为了有GraalVM,Java官方也推荐使用GraalVM运行脚本。

GraalVM下的脚本调用
    private final Context context;
    private final ScriptKind scriptKind;

    /**
     * Build script helper.
     *
     * @param scriptKind     the script kind
     * @param scriptFunsCode the script funs code
     * @param addCommonCode  the add common code
     * @return the script helper
     * @throws RTScriptException the rt script exception
     */
    public static ScriptHelper build(ScriptKind scriptKind, String scriptFunsCode, boolean addCommonCode) throws RTScriptException {
        try {
            Context context = Context.newBuilder().allowAllAccess(true).build();
            if (addCommonCode) {
                switch (scriptKind) {
                    case JS:
                        scriptFunsCode = "const $ = Java.type('com.ecfront.dew.common.$')\r\n" + scriptFunsCode;
                        break;
                    case PYTHON:
                        // ...
                    default:
                        throw new RTScriptException("Script kind {" + scriptKind.toString() + "} NOT exist.");
                }
            }
            context.eval(Source.newBuilder(scriptKind.toString(), scriptFunsCode, "src.js").build());
            return new ScriptHelper(context, scriptKind);
        } catch (IOException e) {
            throw new RTScriptException(e);
        }
    }

    /**
     * Execute.
     *
     * @param <T>         the type parameter
     * @param funName     the fun name
     * @param returnClazz the return clazz
     * @param args        the args
     * @return the t
     */
    public <T> T execute(String funName, Class<T> returnClazz, Object... args) {
        return context.getBindings(scriptKind.toString()).getMember(funName).execute(args).as(returnClazz);
    }

在语法层面变动比较大,但套路类似。

Classpath相关

当我们打包成Native Image时GraalVM内置的SubstrateVM对Classpath相关的操作需要注意,这里举几个例子:

# 正常应该是当前的classpath,但输出为null
ClassLoader.getSystemResource("") > null
# 对于Jar包外打印,正常应该为当前的classpath,Native Image与Jar内打印一样,输出为空
new File("").getPath() >
# XX为某些Class
# 对于Jar包外打印,正常应该为当前的classpath
# 对于jar包内打印,正常应该是当前的Jar路径,但输出的是Native Image文件路径
XX.class.getProtectionDomain().getCodeSource().getLocation().getPath() > /mnt/c/Users/i/OneDrive/workspaces/1.personal/dew/dew-common/it/target/NativeImageTest
# 正常应该是当前的classpath,但输出为null
Thread.currentThread().getContextClassLoader().getResource("") > null

我们还需要注意在Native Image中好像没有package的概念( https://github.com/oracle/graal/issues/1108 ),导致我们无法对“jar包”做遍历,如 https://github.com/gudaoxuri/dew-common/blob/master/src/main/java/com/ecfront/dew/common/ClassScanHelper.java 下的 scan 就无法实现。

反射处理

看过GraalVM介绍的话大家都应该知道Native Image是基于静态代码可达分析,而对于反射方法的操作是无法自动发现的。这个影响很大,比如我们常用的BeanCopy、Json与Java对象的互转、动态代理等会有不同程度的限制。

这些动态调用需要我们来告诉GraalVM,GraalVM为我们提供了一个agent用于运行期自动收集相关的数据,收集时要确保所有动态调用都被执行到。

下面以Dew-Common为例子说明下如何操作:

  1. 所有相关的代码都写成单元测试

  2. 配置Native Image到/src/main/resources/META-INF/native-image/com.ecfront.dew/common/native-image.properties

    # Native Image的配置参数
    # 默认会从 META-INF/native-image/<groupId>/<artifactId>/native-image.properties 读取
    # 完整的参数信息见: https://www.graalvm.org/docs/reference-manual/native-image/
    Args = --no-fallback \
    --enable-all-security-services \
    --allow-incomplete-classpath \
    --report-unsupported-elements-at-runtime \
    --language:js
  3. 配置Agent的过滤器到/src/main/resources/META-INF/native-image/com.ecfront.dew/common/agent-access-filter.json

    // 用于配置Agent收集要排除的内容,由于是从测试发起的,所以要把test相关的代码(反射)排除掉
    // 这里使用的是Access Filters
    // 完整使用详见: https://github.com/oracle/graal/blob/master/substratevm/CONFIGURE.md
    {
      "rules": [
        {
          "excludeClasses": "com.ecfront.dew.common.test.**"
        },
        {
          "excludeClasses": "org.apache.maven.surefire.**"
        },
        {
          "excludeClasses": "org.junit.**"
        },
        {
          "excludeClasses": "com.oracle.truffle.**"
        },
        {
          "excludeClasses": "org.graalvm.**"
        },
        {
          "excludeClasses": "junit.framework.**"
        },
        {
          "excludeClasses": "sun.**"
        }
      ]
    }
  4. 为单元测试添加参数,更完整的见 POM改造 章节

    <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId> (5)
        <configuration>
            <argLine>
                -agentlib:native-image-agent=access-filter-file=${project.basedir}/src/main/resources/META-INF/native-image/com.ecfront.dew/common/agent-access-filter.json,config-output-dir=${project.basedir}/src/main/resources/META-INF/native-image/com.ecfront.dew/common/
            </argLine>
        </configuration>
    </plugin>
  5. 运行单元测试

上面的操作会在调用 mvn test -P native 后会把单元测试收集的包含反射、代理等动态操作写入config-output-dir指定的目录下。

这样我们可以配置 native-image-maven-plugin (见POM改造章节) , 该插件默认会去 META-INF/native-image/<groupId>/<artifactId>/ 找对应的Native Image配置及Agent收集信息,调用该插件 mvn package -P native 完成Native Image打包。

测试

经过上述操作,只要单元测试覆盖全面那么Native Image应该就可以正常工作了,但作为类库,我们还需要有集成测试以确保符合我们的预期。相关的操作可参见 Dew-Common it 目录下的测试工程。

总结

本文简单地介绍了GraalVM的使用,但GraalVM的Native Image目前并不完善,比如对Spring的支持还很有限,Spring有对应的 spring-graalvm-nativehttps://github.com/spring-projects-experimental/spring-graalvm-native )工程,该工程还没有Release,问题很多。不过在今年晚些时候应该可以Ready,届时我们再一起体现下Spring Native的魅力。