GraalVM系列(二):GraalVM核心特性实践

Posted by Coding Ideal World on January 21, 2021

GraalVM系列(一):JVM的未来——GraalVM集成入门 一文中我们实践了GraalVM如何集成到现有的系统中,本文我们将对GraalVM的各个核心功能展开讨论,力求读者对GraalVM能有更全面的认知。

GraalVM建立的初衷

GraalVM是基于Java语言的开发的虚拟机,最初用于替换C++写的Hotspot VM的JIT compiler,在可维护性及性能上都要比前者的C2更优秀,后来逐渐独立成为一个虚拟机产品。当然GraalVM的重要性远不是性能可维护性提升这么简单,在 GraalVM系列(一):JVM的未来——GraalVM集成入门中笔者认为它承载了JVM平台的未来。

GraalVM简介

2021 01 20 08 37 58

上图来自GraalVM官网,简而言之,它希望构建一个开放的生态,它支持所有的JVM语言(Java、Scala、Kotlin等)、Javascript、Ruby、Pyhton、R及基于LLVM的语言(C、C++、Rust等),可运行在OpenJDK、Node、Oracle数据库之中,也可以打包成本地镜像直接运行。

得益于GraalVM多语言的支持、更高的性能、更低的资源占用、更快的启动速度,Java本身的很多问题也都得以解决。接下来我们一起实践下GraalVM的几个核心特性。

实践环境准备

  1. 下载( https://www.graalvm.org/downloads/ )安装对应版本的GraalVM并在环境变量中设置为默认,确保java -version打印出OpenJDK Runtime Environment GraalVM CE信息

  2. 为了原生镜像实践更为顺利,Windows电脑建议在Linux虚拟机中执行,Windows10的用户推荐使用WSL2(也是笔者使用的环境)

  3. 执行git clone https://github.com/gudaoxuri/graalvm-train-example.git,用IDE打开工程

核心特性:生态建设:多语言协同

多语言协同是GraalVM最大的特色之一,我们以JS为例先看下在不使用GraalVM时Java与JS的交互。

Nashorn引擎实现

// 完整代码见/nashorn目录
public static void main(String[] args) throws ScriptException, NoSuchMethodException {
    var scriptEngineManager = new ScriptEngineManager();
    // 使用 Nashorn 引擎
    var jsEngine = scriptEngineManager.getEngineByName("nashorn");
    // 添加与Java交互的函数类
    var javaFuns = "var $ = Java.type('idealworld.train.graalvm.nashorn.NashornExample.JavaTools')\n";
    // 添加测试方法,该方法中调用了Java函数
    var testFuns = "function add(x, y){\n" +
            "  var result = x +y\n" +
            "  $.log('x + y = ' + result)\n" +
            "  return result\n" +
            "}";
    var script = ((Compilable) jsEngine).compile(javaFuns + testFuns);
    script.eval();
    // 执行测试
    var result = ((Invocable) script.getEngine()).invokeFunction("add", 10, 20);
    System.out.println("Invoke Result : " + result);
}

public static class JavaTools {
    public static void log(String message) {
        // 模拟在JS调用过程中调用Java的日志框架打印日志
        System.out.println("Log : " + message);
    }
}

// ---------- 输出 ----------
// Log : x + y = 30
// Invoke Result : 30.0

这是JSR223的实现,使用了ScriptEngineManager,GraalVM提供了兼容实现。

GraalVM JSR223兼容实现

// 完整代码见/jsr223目录
public static void main(String[] args) throws ScriptException, NoSuchMethodException {
    var scriptEngineManager = new ScriptEngineManager();
    // 使用 Graal 引擎,在Java11下默认使用Graal引擎
    var jsEngine = scriptEngineManager.getEngineByName("js");
    var bindings = jsEngine.getBindings(ScriptContext.ENGINE_SCOPE);
    // 添加安全策略
    bindings.put("polyglot.js.allowHostAccess", true);
    bindings.put("polyglot.js.allowHostClassLookup", (Predicate<String>) s -> true);
    // 添加与Java交互的函数类
    var javaFuns = "var $ = Java.type('idealworld.train.graalvm.jsr223.GraalExample.JavaTools')\n";
    // 添加测试方法,该方法中调用了Java函数
    var testFuns = "function add(x, y){\n" +
            "  var result = x +y\n" +
            "  $.log('x + y = ' + result)\n" +
            "  return result\n" +
            "}";
    var script = ((Compilable) jsEngine).compile(javaFuns + testFuns);
    script.eval();
    // 执行测试
    var result = ((Invocable) script.getEngine()).invokeFunction("add", 10, 20);
    System.out.println("Invoke Result : " + result);
}
public static class JavaTools {
    public static void log(String message) {
        // 模拟在JS调用过程中调用Java的日志框架打印日志
        System.out.println("Log : " + message);
    }
}

// ---------- 输出 ----------
// Log : x + y = 30
// Invoke Result : 30.0

可以看到这里需要加上一些安全策略,其它的与Nashorn区别不大,但特别说明的是这一做法是实验性质的,存在一定的局限性,并不推荐生产使用。详见 https://www.graalvm.org/reference-manual/js/ScriptEngine/ ,官方推荐使用polyglot方式实现多语言交互。

Polyglot编程

GraalVM基于Truffle提供了一套支持多语言交互的Polyglot API,详见: https://www.graalvm.org/reference-manual/polyglot-programming/ ,接下来我们分多个示例一步步介绍Polyglot编程。

使用Polyglot需要引入graal-sdk,如下:

<dependency>
    <groupId>org.graalvm.sdk</groupId>
    <artifactId>graal-sdk</artifactId>
    <version>${graalvm.version}</version>
    <scope>provided</scope>
</dependency>
// 完整代码见polyglot目录下的PolyglotBasicExample.java
var context = Context.newBuilder().allowAllAccess(true).build();
// 添加与Java交互的函数类
var javaFuns = "var $ = Java.type('idealworld.train.graalvm.polyglot.JavaTools')\n";
// 添加测试方法,该方法中调用了Java函数
var testFuns = "function add(x, y){\n" +
        "  var result = x +y\n" +
        "  $.log('x + y = ' + result)\n" +
        "  return result\n" +
        "}";
context.eval(Source.create("js", javaFuns + testFuns));
// 执行测试
var result = context.getBindings("js").getMember("add").execute(10, 20).asLong();
System.out.println("Invoke Result : " + result);

// ---------- 输出 ----------
// Log : x + y = 30
// Invoke Result : 30.0

上述代码实现了与前几个示例一样的功能,但这样做(包含前面的示例)会存在安全隐患:JS代码中可以调用很多的Java函数:

// 完整代码见polyglot目录下的PolyglotSafeExample.java
public static void main(String[] args) {
    var context = Context.newBuilder()
            .allowAllAccess(true)
            // 开启Java函数过滤以保障安全
            //.allowHostClassLookup(s -> s.equalsIgnoreCase(JavaTools.class.getName()))
            .build();
    // 添加与Java交互的函数类
    var javaFuns = "var $ = Java.type('idealworld.train.graalvm.polyglot.JavaTools')\n" +
            "var sys = Java.type('idealworld.train.graalvm.polyglot.PolyglotSafeExample.SystemHelper')\n";
    // 添加测试方法,该方法中调用了Java函数
    var testFuns = "function invade(){\n" +
            "  $.log('\\n---------------\\n'+sys.getProps()+'\\n---------------')\n" +
            "}";
    context.eval(Source.create("js", javaFuns + testFuns));
    // 执行测试
    context.getBindings("js").getMember("invade").execute();
}
public static class SystemHelper {
    // Java代码常见的辅助类
    public static Map<Object, Object> getProps() {
        return System.getProperties().entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
    }
}

// ---------- 输出 ----------
// Log :
/// ---------------
// {sun.desktop=windows,...}
// ---------------

Java与JS交互的场景一般是借用了JS的灵活简单的特性用于自定义某些功能,JS代码可由用户编写(比如流程、规则引擎),假如我们Java代码中存在诸如上述的辅助类,那么JS中可以任意调用,对系统的安全而言存在很大的隐患,所以我们需要限制这一功能,我们可以通过GraalVM的allowHostClassLookup过滤不合法的调用,将上述.allowHostClassLookup(s → s.equalsIgnoreCase(JavaTools.class.getName()))反注释后就能实现仅对日志打印的调用开放。

上述示例过于简单,生产场景下JS返回的往往都是异步结果,对应的我们可以这样操作:

// 完整代码见polyglot目录下的PolyglotPromiseExample.java
var context = Context.newBuilder()
        .allowAllAccess(true)
        .allowHostClassLookup(s -> false)
        .build();
// 添加测试方法,该方法中调用了Java函数
var testFuns = "async function add(x, y){\n" +
        "  return x + y\n" +
        "}";
context.eval(Source.create("js", testFuns));
// 执行测试
var result = new Object[2];
Consumer<Object> then = (v) -> result[0] = v;
Consumer<Object> catchy = (v) -> result[1] = v;
context.getBindings("js").getMember("add").execute(10, 20)
        .invokeMember("then", then).invokeMember("catch", catchy);
if (result[1] != null && !result[1].toString().trim().isBlank()) {
    throw new ScriptException((String) result[1]);
}
System.out.println("Invoke Result : " + result[0]);

// ---------- 输出 ----------
// Invoke Result : 30

我们可以通过invokeMember获取Promise下的thencatch成员对象并最终获取到正常或异常结果。

但是GraalVM在Promise场景下貌似类型转换存在问题:

// 完整代码见polyglot目录下的PolyglotDataTypeExample.java
var context = Context.newBuilder()
        .allowAllAccess(true)
        // 可以通过 targetTypeMapping 自定义转换类型
        /*.allowHostAccess(HostAccess.newBuilder(HostAccess.ALL)
                .targetTypeMapping(
                        List.class,
                        Object.class,
                        Objects::nonNull,
                        v -> v,
                        HostAccess.TargetMappingPrecedence.HIGHEST)
                .build())*/
        .allowHostClassLookup(s -> false)
        .build();
// 添加测试方法,该方法中调用了Java函数
var testFuns = "async function arr(){\n" +
        "  return ['1','2']\n" +
        "}";
context.eval(Source.create("js", testFuns));
// 执行测试
var result = new Object[2];
Consumer<Object> then = (v) -> result[0] = v;
Consumer<Object> catchy = (v) -> result[1] = v;
context.getBindings("js").getMember("arr").execute()
        .invokeMember("then", then).invokeMember("catch", catchy);
if (result[1] != null && !result[1].toString().trim().isBlank()) {
    throw new ScriptException((String) result[1]);
}
System.out.println("Invoke Result : " + ((List)result[0]).get(1));

// ---------- 输出 ----------
// Exception in thread "main" java.lang.ClassCastException: class com.oracle.truffle.polyglot.PolyglotMap cannot be cast to class java.util.List (com.oracle.truffle.polyglot.PolyglotMap is in module org.graalvm.truffle of loader 'app'; java.util.List is in module java.base of loader 'bootstrap')
//	at idealworld.train.graalvm.polyglot.PolyglotDataTypeExample.main(PolyglotDataTypeExample.java:47)

JS中返回了一个数组,但很遗憾GraalVM将之视为了Map(PolyglotMap),我们可以通过targetTypeMapping打个补丁,targetTypeMapping用于自定义语言间的类型映射转换,将上述相关代码反注释后即可得到正确的结果。

类型转换还有许多的问题,笔者根据自己的项目整理一份GraalVM的操作辅助类有兴趣的读者可以参考: https://github.com/ideal-world/dew-serviceless/blob/main/module/task/src/main/java/idealworld/dew/serviceless/task/process/ScriptProcessor.java

我们再考虑一个问题,JS是单线程模型而Java是多线程的,那么两者在交互上是否存在问题呢?请看下面的示例:

// 完整代码见polyglot目录下的PolyglotThreadExample.java
var context = Context.newBuilder().allowAllAccess(true).build();
var testFuns = "function add(x, y){\n" +
        "  var result = x +y\n" +
        "  return result\n" +
        "}";
context.eval(Source.create("js", testFuns));
// 执行测试
new Thread(() -> {
    while (true) {
        try {
            context.getBindings("js").getMember("add").execute(10, 20).asLong();
        } catch (IllegalStateException e) {
            System.err.println("Thread 1 Invoke Error : " + e.getMessage());
        }
    }
}).start();
new Thread(() -> {
    while (true) {
        try {
            context.getBindings("js").getMember("add").execute(10, 20).asLong();
        } catch (IllegalStateException e) {
            System.err.println("Thread 2 Invoke Error : " + e.getMessage());
        }
    }
}).start();
new CountDownLatch(1).await();

// ---------- 输出 ----------
// Thread 2 Invoke Error : Multi threaded access requested by thread Thread[Thread-4,5,main] but is not allowed for language(s) js.
// Thread 1 Invoke Error : Multi threaded access requested by thread Thread[Thread-3,5,main] but is not allowed for language(s) js.
// ……

由此可见,在并发访问的情况下的确有问题,官方的介绍见: https://www.graalvm.org/reference-manual/js/Multithreading/ ,从中可以发现可以通过worker实现多线程访问,但这块笔者尚未尝试,有兴趣的同学可以按介绍中的示例进行测试。对于性能要求不高的场景可以为每个Context实例加锁,反之可以考虑下面的做法。

上述Polyglot站在Java的工程下调用JS代码,这种做法一方面是并发存在问题,另一方面也不支持Nodejs的模块系统(需要通过browserify等工具消除模块),下面我们看一下站在JS的立场下怎么调用Java。

GraalVM的JS由GraalJS驱动,这里介绍ES4X(https://reactiverse.io/es4x ),ES4X集成了GraalJS及Vert.x,后者是著名的JVM响应式开发工具生态集合,也是笔者个人项目的基础框架,安利一下。

// 完整代码见es4x目录
import { Router } from '@vertx/web';

const app = Router.router(vertx);
const $ = Java.type('com.ecfront.dew.common.$');

app.route('/').handler(ctx => {
    ctx.response()
        .end($.security.digest.digest('Hello from Vert.x Web!','MD5'));
});

vertx.createHttpServer()
    .requestHandler(app)
    .listen(8080);

这是个简单的用ES4X启动的一个Web服务,调用了com.ecfront.dew.common.$.security.digest.digest这一Java方法,要实现这个功能只需要在package.json中添加如下信息:

{
  // 添加vertx依赖
  "dependencies": {
    "@vertx/core": "4.0.0",
    "@vertx/web": "^4.0.0"
  },
  // 添加Java依赖,来自Maven仓库
  "mvnDependencies": [
    "com.ecfront.dew:common:jar:3.0.0-beta3"
  ]
}

至此,关于Polyglot编程相关的介绍先告一段落,接下来我们一同关注GraalVM另一种重要的特性:Native Image !

核心特性:云原生架构:原生镜像支持

原生镜像(Native Image)称为GraalVM最重磅的特性也不为过,它是Java在云原生时代能否安生立命的关键。

开始本章节前我们先要做如下准备工作:

  1. 尽量使用Linux环境

  2. 执行gu install native-image安装原生镜像支持,详见 https://www.graalvm.org/reference-manual/native-image/

基础使用

我们来简单体验一下:

// 完整代码见image-basic目录
public class ImageBasicExample {

    public static void main(String[] args) {
        System.out.println("Hello Native Image.");
    }

}

非常简单的代码,然后我们执行如下命令:

# 编译Java类
javac ImageBasicExample
# 生成原生代码
native-image ImageBasicExample
# 执行生成的原生代码
./ImageBasicExample
# 输出结果
>Hello Native Image.

当然这个示例过于简陋,一般而言我们都会通过Maven等工具管理项目,下面我们看下Maven工程。

Maven集成

<!-- 完整代码见image-maven目录 -->
<!-- 添加native-image-maven-plugin插件 -->
<plugin>
    <groupId>org.graalvm.nativeimage</groupId>
    <artifactId>native-image-maven-plugin</artifactId>
    <version>${graalvm.version}</version>
    <executions>
        <execution>
            <goals>
                <goal>native-image</goal>
            </goals>
            <phase>package</phase>
        </execution>
    </executions>
    <configuration>
        <skip>false</skip>
        <mainClass>idealworld.train.graalvm.image.ImageMavenExample</mainClass>
        <imageName>ImageMavenExample</imageName>
        <buildArgs>
            --no-fallback
        </buildArgs>
    </configuration>
</plugin>
// 完整代码见image-maven目录
var user1 = new UserDTO();
var user2 = (UserDTO) Class.forName("idealworld.train.graalvm.image.UserDTO").getDeclaredConstructor().newInstance();
user1.setName("测试用户");
user2.setName("测试用户");
System.out.println(user1.getName());
System.out.println(user2.getName());

然后我们执行mvn package即可在target下生成ImageMavenExample可执行文件。

是不是很简单?当然如果都是这样的话那Native Image早就流行了,上面还只是测试代码,现实项目中会遇到非常大的阻力。请看下面的示例:

// 完整代码见image-json目录
private static ObjectMapper mapper = new ObjectMapper();

public static void main(String[] args) throws JsonProcessingException, ClassNotFoundException {
    var obj = mapper.readValue(args[0],Class.forName(args[1]));
    System.out.println(mapper.writeValueAsString(obj));
}

上述代码是接收两个参数,第一个是Json字符串,第二个是想要转换成的类名,最后输出转换成后的Json字符串。(什么屁逻辑?这里只是演示,实际上肯定没人会这么做哈。)

这个代码可以打包通过,你以为成功,但一执行就会出现让人抓耳挠腮的错误:

# 执行生成的原生代码
./ImageJsonExample {\"name\":\"测试\"} idealworld.train.graalvm.image.UserDTO
> Exception in thread "main" java.lang.ClassNotFoundException: idealworld.train.graalvm.image.UserDTO
        at com.oracle.svm.core.hub.ClassForNameSupport.forName(ClassForNameSupport.java:60)
        at java.lang.Class.forName(DynamicHub.java:1247)
        at idealworld.train.graalvm.image.ImageJsonExample.main(ImageJsonExample.java:16)

为什么会这样?通过上一个示例我们看到Native Image是支持Class.forName这种反射的,但它只提供了部分支持,并且需要提前知道被反射访问的程序元素,我们这个示例中要反射的对象是不确定的,Native Image要将JVM代码编译成本地代码,在编译过程中很重要的一步就是分析代码可达性,可这个示例从main函数入口无法找到UserDTO这个类,故没有将UserDTO编译进去,所以出现了这个错误。详见: https://www.graalvm.org/reference-manual/native-image/Reflection/

那怎么解决呢?对于这种无法通过分析触达的类我们必须显式地告诉GraalVM,让它强行编译,可以参见: https://www.graalvm.org/reference-manual/native-image/BuildConfiguration/#assisted-configuration-of-native-image-builds

当然这种做法并不友好,对于生产项目而言笔者更推荐以下做法。

推荐配置

首先我们添加一个测试类:

// 完整代码见image-json目录
@Test
public void testAll() throws JsonProcessingException, ClassNotFoundException {
    ImageJsonExample.main(new String[]{"{\"name\":\"测试\"}", "idealworld.train.graalvm.image.UserDTO"});
    Assertions.assertTrue(true);
}

实际就是正常的单元测试,尽可能覆盖所有代码。

然后我们配置单元测试Maven插件:

<!-- 完整代码见image-json目录 -->
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <configuration>
        <argLine>
            -agentlib:native-image-agent=access-filter-file=${project.basedir}/src/main/resources/META-INF/native-image/group.idealworld.train/graalvm-image-json/agent-access-filter.json,config-output-dir=${project.basedir}/src/main/resources/META-INF/native-image/group.idealworld.train/graalvm-image-json/
        </argLine>
    </configuration>
</plugin>

这一配置会在打包前的测试环节下自动发现代码依赖并将结果保存到${project.basedir}/src/main/resources/META-INF/native-image/group.idealworld.train/graalvm-image-json/

我们再来执行一下:

# 执行生成的原生代码
./ImageJsonExample {\"name\":\"测试\"} idealworld.train.graalvm.image.UserDTO
> {"name":"测试"}

关于这部分的详见描述见该系统的首篇文章《GraalVM系列(一):JVM的未来——GraalVM集成入门》。

核心特性:生产保障:性能

最后我们再来看一下GraalVM的性能表现,官方给出一个benchmarks: https://renaissance.dev/ ,但我们还是通过一个最简单的Http请求-响应场景,对比一下NodeJS(v12.20.1)、ES4X(Java11/Vertx4.0)、Java(Java11/Vertx4.0)、Native Image(Vertx4.0),使用wrk测试,完整代码见performance目录。

测试环境: Windows10 WSL2
执行命令: wrk -t8 -c20 -d120s http://127.0.0.1:8080

NodeJS:
--
Thread Stats   Avg      Stdev     Max   +/- Stdev
  Latency     3.54ms    7.93ms 184.25ms   96.84%
  Req/Sec   799.39    287.72     1.36k    72.56%
763156 requests in 2.00m, 97.53MB read
Requests/sec:   6354.65
Transfer/sec:    831.57KB
--

ES4X:
--
Thread Stats   Avg      Stdev     Max   +/- Stdev
  Latency   675.44us    3.55ms 182.78ms   99.73%
  Req/Sec     3.61k   838.46    24.28k    76.69%
3444651 requests in 2.00m, 160.97MB read
Requests/sec:  28681.71
Transfer/sec:      1.34MB
--

Java:
--
Thread Stats   Avg      Stdev     Max   +/- Stdev
  Latency   646.31us    3.82ms 192.36ms   99.82%
  Req/Sec     3.83k   840.91     9.62k    72.03%
3659281 requests in 2.00m, 174.49MB read
Requests/sec:  30468.76
Transfer/sec:      1.45MB
--

Native Image:
--
Thread Stats   Avg      Stdev     Max   +/- Stdev
  Latency   641.65us    4.15ms 201.18ms   99.84%
  Req/Sec     3.98k     0.96k   19.58k    73.90%
3797610 requests in 2.00m, 181.08MB read
Requests/sec:  31620.56
Transfer/sec:      1.51MB
--

由于测试环境为笔者的开发环境不够纯粹,测试的时长也不够,所以以上仅为参考,但在这个简单的场景下基本可以得出NodeJS << ES4X < Java = Native Image的性能表现。

这里有个很意思的结果,我们都说NodeJS的V8引擎性能很强,但基于GraalJS的引擎性能表现却要远好于V8,并且后者兼容绝大部分的NPM包,还可以与许多语言交互。所以使用Node的同学不妨尝试一下ES4X哦。

总结

本文我们从一个个示例出发介绍了GraalVM的几个核心功能,但也只能算是抛砖引玉,这个系列笔者还会更新,关于GraalVM的架构、性能的调优、使用的排雷等都会一一涉及。