WebAssembly在Java极客眼中的解读

发表时间: 2023-07-06 16:35

作者 | EDOARDO VACCHI

译者 | 盖磊

策划 | 冬雨

不少 Java 开发人员在面对 WebAssembly 一词时,首先会想到这是一种“浏览器技术”,之后可能会认为“还是归结为 JVM”。毕竟浏览器内应用对他们而言是一种“史前生物”。


最近数周内,围绕 WebAssembly,多项技术呈密集发布,例如Docker+wasm技术预览等。作为一名 Java 极客,我认为不应视WebAssembly为一时风尚而置若罔闻。


文如其名,WebAssembly(wasm)的确可称为“一种用于 Web 的字节码”。Java 和 wasm 二者间的相似性也仅限于此。这里“wasm”是小写的,表示它是一个缩略词,而非首字母缩略语。


如果有兴趣了解我们如何定义了 WebAssembly 标准,欢迎翻阅我写过的一篇博文,其中解释了来龙去脉。本文阐述的重点是,为什么说 WebAssembly 并不仅仅局限于 Web。


首要一点,WebAssembly 运行时仅是貌似 JVM。其中一点,WebAssembly 的长远目标,是成为适合各种编程语言的编译目标。但 JVM 并非如此,至少最初没有做如此考虑。

第一个神话:JVM 是一种多语言编译目标

必须承认,JVM 是最为丰富的、可互操作的语言生态系统之一。Java 生态还包括了 Scala,Jython,JRuby,Clojure,Groovy,Kotlin 等编程语言。


但现实非常可悲,Java 字节码从未真正地成为一种通用的编译目标。不少文献资料都对此做了清晰的阐述。例如,John Rose 在“字节码与组合选择的结合:JVM中的invokedynamic”一文中写道:

Java 虚拟机(JVM)被广泛采用,可部分归因于 class 文件格式是可移植的、紧凑的、模块化的和可验证的,并且非常易于使用。然而,class 文件在设计上仅针对 Java 这一种语言,用于表达其它语言编写的程序时,常常出现一些阻碍开发和执行的“痛点”。


这篇文章阐释了invokedynamic操作码引入 JVM 中的原因和方式。事实上,引入该操作码就是专为支持使用 JVM 运行时的动态语言。虽然 JRuby,Jython,Groovy 等一些语言在运行时中添加了该操作码,并不是 JVM 在设计中考虑了如何支持这些语言,而是因为这些语言已经这样做了。木已成舟,只能去认可它!


换句话说,时过境迁,JVM 依然未成为这些动态语言合适的编译目标。甚至可以说,以 JVM 为编译目标并非因为它是最好的,而是考虑到 JVM 的采纳度和支持情况,人们希望能与 JVM 互操作。正如 JavaScript 那样!

GraalVM:一统各方的虚拟机

最近GraalVM项目大行其道。该项目中包括针对例常 Java 字节码的 JIT 编译器,以及用于构建高效语言解释器的 API,还新添加了原生镜像编译器。


成为“一统所有VM的虚拟机”,是 GraalVM 的最初目标之一,也就是说成为一种多语言运行时。

但 Truffle 并未定义多语言编译目标,而是通过 Truffle API 实现一种极高层级表示,进而构建基于 AST 的高效 JIT 解释器。对感兴趣的读者,可自行去深入了解“抽象语法树”(AST)。


“致编程大神”:漫游编程语言的奇境,所有一切都变“神奇”。使用 Truffle,的确可以为其它“适当”的字节码格式编写 JIT 解释器。


事实上,已有用于 LLVM(Sulong)的 Truffle 解释器。当然,LLVM 位码也的确是多平台/多目标编译目标。依此类推,是否可以说 GraalVM/Truffle 同样支持多平台编译目标?


从技术角度看,可以这么说,甚至可以说是“完全正确的”。但依然存在不少可商榷之处,对此本文不一一展开讨论。简而言之,LLVM 位码只是作为一种编译目标,并未完全考虑作为一种跨平台的运行时语言。例如,针对不同的 CPU 和操作系统,LLVM 可能必须要调用不同的指令。此外,不同于作为多厂商标准的 WebAssembly,GraalVM 和 Truffle 目前为止仍然是开源的、社区驱动的、单厂商实现的项目。但将GrallvVM纳入OpenJDK的工作近期已经启动,并可能进入Java语言规范。


毕竟,WebAssembly 只是一种得到 GraalVM/Truffle 支持的语言。如果要使用 GraalVM,甚至可以考虑 wasm!

第二个神话:WebAssembly 只是另一种 Stack-based VM(栈机)

WebAssembly 定义为一种结构化栈机使用的虚拟指令集架构(ISA)。


上述定义中,关键在于“结构化”(structured)一词,它表明 WebAssembly 与 JVM 的工作方式大相径庭。结构化栈机在实际运行中,大部分计算使用值栈,控制流却使用块、if 和循环等结构化结构表示。WebAssembly 语言则更进一步,一些指令可同时使用“简单”和“嵌套”表示。


下面给出一个例子。wasm 栈机中有如下表达式:

( x + 2 ) * 3



 int exp(int);    Code:       0: iload_1       1: iconst_2       2: iadd       3: iconst_3       4: imul       5: ireturn


该表达式可被翻译为下述一系列指令:

(local.get $x) (i32.const 2) i32.add (i32.const 3) i32.mul


其中:* local.get在栈中加入本地变量$x;* 然后i32.const将 32 位整数(i32)常量2推送入栈;* i32.add从栈中弹出两个值,并将$x+2结果推送入栈;* 整数常量3被推送入栈;* i32.mul弹出两个整数值,并将($x+2)*3i32乘法结果推送入栈。


大家应该能注意到,用括号括起来的,是含有一个以上参数的指令。上面给出的“线性化”版本的 WebAssembly,在.wasm 文件中直接转换为二进制表示。此外还有在语义上等效的另一种“嵌套”表示:

(i32.mul   (i32.add     (local.get $x)       (i32.const 2))     (i32.const 3))


嵌套表示别具特色。操作的嵌套和编写有别于 JVM 等字节码类型,而是类似于一种“传统”编程语言。这里所说的“传统”,就是指操作读起来类似于 LISP 家族中的 Scheme 语言。显而易见,其中使用的括号约定,就是在向 Scheme 致敬。当然,事出必有因。对 JavaScript 的神奇起源稍有了解,就一定知道 JavaScript 最初是在 10 天内写成的,而且 Brendan Eich 一开始的任务是去开发另一种 Scheme 方言。

至少对我而言,嵌套序列更有趣的细节在于,它能自然地线性化为其它版本。事实上,遵循括号表达式的优先规则,须从最内层的括号开始。例如:

 (i32.add     (local.get $x)     (i32.const 2))


上面的例子首先获取$x,然后为常量赋值2,进而将二者相加。此后,再去处理外层的表达式:

(i32.mul   (i32.add ...)   (i32.const 3))


对其中的i32.add求值,需对常量赋值3,并将二者相乘。这与栈机的操作顺序相同。


这里提出结构化控制流,同样是考虑了安全性,以及简单性:

WebAssembly 栈机仅限于结构化控制流和结构化栈的使用。这一方面极大地简化了“一次通过”(one pass)验证,避免了 JVM(栈映射推出前)等栈机的固定点(fixpoint)计算;另一方面,也简化了其他工具编译和操作 WebAssembly 代码。

下面看一个例子:

 void print(boolean x) {    if (x) {        System.out.println(1);    } else {        System.out.println(0);    }}


上述代码翻译为如下字节码:

 void print(boolean); Code: 0: iload_1 1: ifeq 14 4: getstatic #7 // java/lang/System.out:Ljava/io/PrintStream; 7: iconst_1 8: invokevirtual #13 // java/io/PrintStream.println:(I)V11: goto 2114: getstatic #7 // java/lang/System.out:Ljava/io/PrintStream;17: iconst_018: invokevirtual #13 // java/io/PrintStream.println:(I)V21: return


在如上等价的 WebAssembly 定义中可看到,非结构化跳转指令ifeqgoto并未出现,而是恰如其分地被语句块if...then...else所替代。

(module ;; 导入浏览器控制台对象,需要将此从JavaScript传递进来。 (import "console" "log" (func $log (param i32))) (func   ;; 如运行if代码块,更改为True。   (i32.const 0)    (call 0))  (func (param i32)   local.get 0    (if     (then       i32.const 1       call $log ;; 应该记录'1'     )     (else       i32.const 0       call $log ;; 应该记录'0'     ))) (start 1) ;; 自动运行第一个func)


原例可在Mozilla Developer Network上查看和运行。


当然,上述例子也可线性化,形成如下的非嵌套版本:

(module  (type (;0;) (func (param i32)))  (type (;1;) (func))  (import "console" "log" (func (;0;) (type 0)))  (func (;1;) (type 1)    i32.const 1    call 0)  (func (;2;) (type 0) (param i32)    local.get 0    if  ;; label = @1      i32.const 1      call 0    else      i32.const 0      call 0    end)  (start 1))


更多差异:内存管理

另一处 WebAssembly 虚拟机和 JVM 大相径庭,在于各自的内存管理,虽然难以评价孰优孰劣。大家应该知道,Java 不需要开发人员显式地分配和释放内存,也无需去操心栈和堆的分配。但开发人员通常需要了解一些内存管理知识,在真正需要时能使用一些方法做显式处理,虽然现实中很少有人这么做。


事实上该特性并非语言层级上的,而是 VM 的工作机制。在 VM 层级,并没有内存处理的原语操作。堆分配原语是以 JDK API 的方式提供。开发人员无法缺省禁用内存管理,不能说“我不需要 GC Heap,我将自己实现内存管理”。


当前,WebAssembly 做法恰恰相反。大多数语言在以 WebAssembly 为编译目标时,的确是自行管理内存,这并非巧合。有些语言的确能做 GC,但其 VM 并不提供 GC 功能,因此自身的例程在 GC 时必须回滚。

WebAssembly 的做法是,为使用者分配一小片支持分配、释放甚至是随意移动等操作的“线性内存”(linear memory)。虽然在一定程度上要比 JVM 提供的功能更强大,但在使用中也需谨慎。


例如,JVM 不需要开发人员显式指定对象的内存布局,结构体打包(structure packing)、字节对齐(word alignment)等内存空间优化工作已交由 VM 处理。但在 WebAssembly 中,这些工作需要开发人员处理。


这在一方面,使得 WebAssembly 成为手动管理内存的编程语言的理想编译目标。因为这类语言需要并期望对内存更高程度上的控制。但在另一方面,增加了语言间互操作的难度。


当前,结构和对象布局是 ABI(Application Binary Interface,应用二进制接口)关注的问题。但ABI对JVM开发人员都已成为昨日黄花,除了一些极为有限和需注意的例外情况。


值得关注的是,最近WebAssembly垃圾回收规范草案已向前推进。规范草案中不仅声明了 GC,而且有效地描述了结构体,以及与原始语言无关的结构体间互操作方式。尽管该草案尚未准备好,但事情是在不断发展的,多个关注问题正得到解决。

并非局限于 Web

看到大家应该注意到,本文至此还从未提起过“Web”。


经过上文的铺垫,下面给出本文的重点,就是阐明 Java 极客应该关注 WebAssembly。


即使你不关注前端技术,也不应将 WebAssembly 纯粹视为前端技术。在 WebAssembly 的设计和规范中,没有任何一处规定其是专门绑定到前端的。事实上,当前的大多数主流的 JavaScript 运行时,都能够加载和链接 WebAssembly 二进制文件,甚至在浏览器之外。因此,可在 Node.js 运行时中运行 wasm 可执行文件,并且使用薄薄一层 JS 胶水代码,就能与平台其它部分交互。


但目前也存在一些纯 WebAssembly 运行时,例如wasmtime、wasmEdge、wasmCloud、Wazero等。纯运行时完全脱离开 JavaScript 主机,并且比成熟的 JavaScript 引擎更轻量级,更易于嵌入到更大型的项目中。


事实上,许多项目正开始采纳 WebAssembly,将其作为托管扩展和插件的多语言平台。


Envoy proxy正是其中一个著名项目。其代码库以 C++为主,虽然支持插件,但存在和浏览器插件一样的问题,即必须做编译、必须做发布、插件可能无法以正确的权限级别运行,甚至在发生严重故障时可能破坏整个过程。现在,开发人员可以通过嵌入 Lua 或 JS 解释器,支持用户通过编写脚本方式成功运行。解释器更为安全,因为它与主要业务逻辑隔离,并且仅采用安全方式与主机环境交互。但不足之处是必须为用户选择一种语言。


另一种做法是嵌入 WebAssembly 运行时,让用户自己选择语言,然后编译成 wasm。该做法可实现同样的安全保证,用户也更乐意为之。


纯 WebAssembly 运行时不仅用于实现扩展。一些项目正在创建 wasm 原生 API 薄层,以提供独立的平台。

例如,Fastly开发了边端的无服务器计算平台。其中,无服务器功能由用户提供的 WebAssembly 可执行文件实现。


初创公司Fermyon正开发一个丰富的生态,实现仅使用 wasm 编写 Web 应用。该生态由各种工具和基于 Web 的 API 组成。最新发布的产品是Fermyon Cloud。


这些解决方案已为特定用例提供定制的即席 API,这确实是 WebAssembly 的一类使用方式。不止于此,Docker 创始人 Solomon Hykes 在 2019 年就写道:


如果 wasm+WASI 在 2008 年就出现了,那么我们就不需要去创建 Docker。这足以说明其重要性。服务器端 WebAssembly 是计算的未来,但标准化的系统接口是缺失的一环。希望 WASI 能够胜任这项任务!

— Solomon Hykes (@solomonstre) March 27, 2019


抛开具体场景,人们的第一反应不免是“wasm 到底与 Docker 有什么关系?”。当然也会想,“WASI 是什么鬼?”


WASI 指“WebAssembly System Interface”,可视其为支持 wasm 运行时与操作系统交互的一组(类 POSIX)API 集合。WASI 是否类似于 JDK 类库?并不完全如此。WASI 是薄薄一层面向功能的 API,用于与操作系统交互,详见Mozilla公告博客。简而言之,WASI 补上了缺失的一环。WASI 允许定义与操作系统直接交互的后端应用,无需任何额外的层,也无需即席 API。目前 WASI 的工作是推进其被广泛采纳,能在某种程度上成为后端开发的事实标准。


WASI API 包括文件系统访问、网络乃至线程 API 等。这些 API 与运行时的底层功能协同工作,可简化平台的迁移。

移植 Java

尽管存在各种挑战,但 WebAssembly 依然是首个有潜力成为真正的多供应商、多平台、安全和多语言的编程平台。我认为各位 Java 极客应把握机会参与其中。


WebAssembly 规范和 WASI 工作仍在不断地发展变化。点滴汇成江海,这些工作铺就了通往简化任意编程语言的移植之路,且不仅局限于支持手动内存管理的语言。


事实上,部分使用垃圾回收的语言已实现移植,尽管它们所采用的方式方法各有千秋。例如,Go 采取了编译为 wasm(虽然存在部分限制);Python 移植采取了解释器的移植,即将 CPython 解释器编译为 wasm,之后和传统的执行环境一样去执行 Python 脚本。


当然,实现向 Java 的移植依然面对很多问题,内存管理只是其中之一。我们当然可以为可执行文件中添加 GC,这实际上也正是 GraalVM 原生镜像目前的工作方式。但在我看来,更难之处在于对其它一些 CPU 功能或系统调用的支持。这些功能目前仍然不稳定,或尚未得到广泛支持,诸如:

  • 大多数独立 wasm 运行时依然缺乏对线程的支持,或是仍是实验性的;
  • 甚至浏览器支持也是实验性的,是通过 WebWorkers 模拟的;
  • 套接字访问缺少标准化支持:所有支持编写自定义 HTTP handler 的服务,通常都提供了预先配置的套接字,对低层级访问是受限的;
  • 更难模拟的实验性功能是异常处理,因为 wasm 字节码中缺乏非结构化跳转。实现该功能,应需在 wasm VM 中提供适当的支持。
  • 每种语言对内存布局和对象形状都有自己的约束。因此,各语言更难跨边界共享数据,这阻碍了不同语言间的兼容性,限制了 wasm 作为多语言平台的适用性(但该问题已列入 GC 规范本身之中)。


简而言之,无论对于浏览器之内还是之外的 WebAssembly 平台,移植 Java 依然存在诸多挑战。

WebAssembly 对 Java 的支持

当前,已有一些面向 WebAssembly 和 Java 的项目和软件库。下面将列出我在网上发现的一些资源,虽然其中很多只能称为兴趣爱好项目。

浏览器中运行 Java

一些项目针对将 Java 转换为 WebAssembly。但其中大多数项目生成的代码是不兼容更精简的 wasm 运行时的,通常只适用于浏览器中运行。

  • Bytecoder、JWebAssembly和TeaVM等转换器项目,都是将 Java 字节码转换为 WebAssembly,但在将 Java 字节码转换为浏览器友好代码的技术上略有差异。其中,TeaVM项目相对而言更具前景。我们看到在Fermyon分支中,包括了对 WASI Bytecoder 的初步支持。
  • CheerpJ是一个非常有前途的专有软件项目。CheerpJ 意在提供对全部 Java 特性的支持,甚至包括 Swing。还有Chrome扩展使用 Web 技术运行很赞的 applet。

一些项目也值得关注,它们针对浏览器运行时,部分提供实验性 wasm 支持:

  • J2CL(GWT的后续)是 Java 到 JavaScript 的源到源编译器,即 transpiler,最近提供了对 wasm 的支持。该编译器支持最新的 GC 规范。
  • Bck2Brwsr也是针对 JavaScript 和浏览器的字节码编译器。
  • Kotlin/Native也支持通过 LLVM 编译为 wasm。它也继承了 Kotlin/Native 存在的所有问题,例如并非支持所有的 Java 库。
  • DoppioJVM项目值得一提。它采用了完全不同的技术路径,类似Python,不是将字节码编译为 wasm,而是提供一种用 JavaScript 编写的浏览器内 VM,去解释 JVM 字节码。但不幸的是,该项目当前不再维护。

在 JVM 上运行 WebAssembly

前面一直讨论的是如何让 Java 程序运行在 wasm 运行时上,我们当然也希望能反其道而行之。平心而论,JVM 编程语言已颇具规模的,并且当前大多数 wasm 运行时所提供的编程模型(使用手动内存管理)在 JVM 上运行也有些别扭。为周全起见,在此仍有必要提及,当然这些项目也值得介绍。

  • 首选显然是前文提及的GraalVM Truffle实现的WebAssembly解释器。GraalVM/Truffle 平台博采众 JIT 之长,具有多语言互操作性。
  • asmble提供了一组工具,包括将 wasm 编译为字节码的编译器、wasm 解释器等。
  • Happy New Moon With Report (JVM)是 WebAssembly 的 JVM 运行时。列举于此,仅是因为我喜欢这个憨憨的命名。
  • 原生 wasm 运行时绑定,例如kawamuray/wasmtime-java。
  • 最近推出的Extism项目跨多种宿主语言,提供原生 WebAssembly 运行时 wasmtime 接口统一的 API。
  • Katai WebAssembly是一个由我维护的 wasm 解析器项目,它使用Katai Struct二进制解析生成器编写,欢迎大家反馈问题请求(PR)。项目设计上并非针对在 JVM 上运行 wasm,而是针对用户操作或查询 wasm 可执行文件信息的需求。事实上,Kaitai 语法支持对所有受支持语言生成二进制解析器,不局限于 Java,还包括 Python、Ruby、Go 和 C++等。

结束语

希望本文能激发大家对 WebAssembly 的兴趣。Java-on-wasm 依然是一个新生事物,欢迎大家以开放心态去探索这一全新的世界,并从中收获惊喜。

作者简介

Edoardo Vacchi,博士毕业于米兰大学,研究方向是编程语言设计与实现。在 UniCredit 银行研发部门工作三年后,加入 Red Hat 公司,先后参与 Drools规则引擎、jBPM工作流引擎和Kogito云原生业务自动化平台项目。关注 WebAssembly 等新语言技术,并在KIE organization和个人博客上撰写文章。

原文链接: WEBASSEMBLY FOR THE JAVA GEEK