写点什么

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

用户头像
孤岛旭日
关注
发布于: 2021 年 05 月 19 日
GraalVM系列(二):GraalVM核心特性实践

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


GraalVM 建立的初衷

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

GraalVM 简介


上图来自 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.javavar 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.javapublic 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.javavar 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.javavar 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用于自定义语言间的类型映射转换,将上述相关代码反注释后即可得到正确的结果。


此 Bug 的讨论见:https://github.com/oracle/graaljs/issues/214 ,比较有用的targetTypeMapping见 https://github.com/reactiverse/es4x/blob/develop/es4x/src/main/java/io/reactiverse/es4x/ECMAEngine.java#L130-L272 。

类型转换还有许多的问题,笔者根据自己的项目整理一份 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.javavar 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 驱动,这里介绍ES4Xhttps://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"  ]}
复制代码

ES4X 的安装使用见: https://reactiverse.io/es4x/get-started/ Java 依赖引用见: https://reactiverse.io/es4x/advanced/jars.html

至此,关于 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目录@Testpublic 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 readRequests/sec: 6354.65Transfer/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 readRequests/sec: 28681.71Transfer/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 readRequests/sec: 30468.76Transfer/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 readRequests/sec: 31620.56Transfer/sec: 1.51MB--
复制代码

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

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

总结

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


关注我的公众号:GraalVM系列(二):GraalVM核心特性实践

发布于: 2021 年 05 月 19 日阅读数: 19
用户头像

孤岛旭日

关注

努力成为一个好爸爸、好丈夫。 2018.10.30 加入

10年前,毕业时,芳华正茂,立志Coding完美世界,10年后,35,志向未成但理想依旧,勉励之、践行之。

评论

发布
暂无评论
GraalVM系列(二):GraalVM核心特性实践