WebAssembly
Updated
12 min read

Running Wasm on the JVM

How to run Wasm in Java - how does Wasm interact with the JVM and what options are there for Java Wasm runtimes?

Running Wasm on the JVM

Wasm is a key part of Arcjet’s architecture. Our SDK bundles Wasm components to help developers build security features like bot detection, rate limiting, email validation, and PII detection. 

To ensure that our security analysis does not slow down your applications, our SDK makes decisions as close to your business logic as possible. We use the same logic both when evaluating rules locally (which we do whenever possible, to keep things fast) and within our cloud API (when we need to enrich decisions with additional metadata). This guarantees that protections are applied consistently and correctly. We do this by writing the rules in Rust which delivers not just speed and correctness, but offers excellent tooling for cross-compiling to Wasm.

Because Wasm sits at the heart of our platform, we have recently been researching ways to run Wasm on the other languages and runtimes Arcjet plans to support.  Our first client SDK is written in TypeScript, and most JavaScript runtimes that run TypeScript also run Wasm out of the box. Our cloud API is primarily written in Go, which has excellent support for Wasm in the form of the wazero runtime (albeit with some interesting design constraints, which I’ll discuss a bit as I go in this post). 

Our goal is to eventually support all languages, but based on customer requests we’ve been spending time in the Java ecosystem. This post will share what we’ve learned along the way.

To start, I’ll need to spend some time talking about some of the challenges of joining together the very different worlds of Wasm and the Java Virtual Machine: what the current options are for running Wasm in Java applications, some of the challenges specific to providing consistent, typed interfaces to code written in one language and called to another when the Wasm bytecode provides only a low-level, “raw” calling convention, and some of the challenges of deploying Wasm inside a third-party component. 

Finally, I’ll wrap up with what remains to be done to make all this work well, by talking about some tooling that Arcjet has built for Go that would be very useful to have in Java.

Let’s get started.

A Tale of Two Bytecodes: How Wasm and the JVM Differ

Java and Wasm are both languages intended to be run on virtual machines. They share use cases and constraints, but have very different histories and were intended to solve very different problems. Combining the two can be challenging.

Java and the Java Virtual Machine

Java has been around for a while now so it’s easy to lose sight of how innovative it was when it was new. One of the most interesting aspects of its design is that it was one of the first languages that was meant to be run in a managed runtime. This means that instead of interpreting source text, or compiling the language into machine code, javac (the Java compiler) compiles Java source programs into bytecodes for the Java Virtual Machine.

The JVM specification describes how the JVM was designed to run securely on relatively constrained hardware. As a result the instruction set implemented by Java bytecodes is simple, relatively high level, but tailored to the specific needs of Java. It’s not really like the object code produced by modern compiler frameworks like LLVM, nor is it like the abstract virtual instruction sets like the p-code produced by older compilers, like the UCSD Pascal system.

If you read any history of Java, you’ll quickly see that when it was first introduced it was meant to run on small, simple devices, but where it found success was running at scale. To do that it needed to be faster and take advantage of much more powerful hardware. As a result, the original Java runtimes, which were mostly simple bytecode interpreters, quickly gave way to much more complicated programs that leaned heavily on just-in-time (JIT) optimization.

JavaScript, asm.js, and Wasm

JavaScript is another language with a long and fascinating history. It is also a platform that has grown significantly in scope since its original rollout. It also had a need for high performance (for both frontend and backend applications), and a lot of that performance was provided by using a lot of the same JIT work used to make the JVM faster. By design JavaScript does not compile to a bytecode, so there are JIT techniques available to JVM implementations that are not available to JS runtimes (and vice versa. This will be important in just a bit!)

If you’re making applications like games or near-realtime APIs, you need consistent latency guarantees and you can’t afford to have arbitrary pauses for things like garbage collection. You might want to write your code in a language suited to low-level graphics and stream processing. Transpilation from other languages to JavaScript is possible, but people who were writing applications for the web platform were dissatisfied with what they could do with transpilers targeting JS alone, and so the big browser vendors got together and put together the WebAssembly (Wasm for short) specification in 2015-2017. WebAssembly, put simply, is a portable bytecode format that represents a streamlined subset of JavaScript instructions that is designed to be both low-level and safe.

You’ve probably noticed that both the JVM and Wasm runtimes produce bytecodes and rely heavily on JIT optimization for performance, so you might think that getting the one to run on the other should be straightforward. It’s a reasonable conclusion! Unfortunately, the parallel evolutions of the two platforms have not been convergent. There is a lot of overlap between what makes for a fast JVM and a fast Wasm runtime (something the GraalVM team has used to their advantage). The differences are significant enough that every attempt to run Wasm on the JVM brings with it significant tradeoffs.

The Current Wasm / JVM Landscape

For a variety of reasons, including those discussed in the previous section, Wasm runtimes came early to JavaScript and late to the JVM. Even so, there’s a decent number of options, each with their own strengths and weaknesses. To help us narrow down these options, we set out a framework of requirements for our ideal runtime.

A JVM-based Wasm runtime is good for Arcjet if it:

  1. Is easy to distribute. Arcjet runs with your code, and your code may be running on anything from standalone systems to tiny micro-backends running on PaaS platforms like Vercel or Fly.io. We can’t require that you deploy additional binaries, agents, or containers to run our code.
  2. Has no native dependencies. Similarly to the previous point, we can’t make any assumptions that your application’s hosting environment has a particular toolchain or other supporting libraries available. Besides, Java is the original “write once, run anywhere” language, so it should just work, right?
  3. Is fast. As our SDK runs with your code and can be configured to run with every request coming into your backend, it’s extremely important to minimize overhead. Our goal is for Arcjet protection to add no more than 20-30 milliseconds of overhead per request. Simple interpreters or transpilers that don’t produce optimized code, or too much runtime overhead is unacceptable. One of the original reasons for picking Rust to compile to Wasm is the lack of any garbage collection overhead, because we don’t want Arcjet to be the cause of any performance problems.

In addition to those requirements, it’s also pretty important that the runtimes make it straightforward to provide a high-quality developer experience. This means that it should be easy to create and maintain Wasm-to-Java bindings that feel natural and Java-like. This sounds simple, but it turns out to be quite complicated, for reasons that we’ll get into a little later.

Historically, JVM implementations have struggled with startup times. This makes Java unsuitable for use in cloud functions and other applications where the runtime will be started and stopped on an as-needed basis. This doesn’t have a lot to do with running Wasm, but since running Wasm code inside the JVM requires bootstrapping a second runtime hosted within the JVM, that does add some additional overhead. Unless the Wasm runtime is using a technique like ahead-of-time (AOT) compilation, described below, this is additional overhead to keep in mind.

With our requirements laid out, here are the four best options I was able to find, sorted in the order of which ones I thought would best fit Arcjet’s needs. It’s not intended to be an exhaustive list, but if you know of something that you think we need to hear about, let us know!

Chicory

Chicory is a zero-dependency Wasm runtime intended to be embedded into Java applications. It supports AOT compilation of Wasm modules into JVM .class files, further simplifying distribution (and greatly improving performance of Wasm functions). It also offers a good developer experience for Java developers seeking to integrate Wasm support into their applications. 

It is a simple and focused product, and as such is the option I spent the most time vetting for Arcjet’s use case.

GraalWasm

GraalVM is both a JVM runtime and a set of technologies intended to support writing fast bytecode runtimes. GraalWasm is a Wasm runtime built using these technologies. It can be installed as either a standalone distribution, or as a set of Java classes that provide an interface to Wasm modules using the GraalVM project’s Polyglot API.

GraalVM itself does offer support for AOT compilation, but that’s something that’s meant to be used by application developers when building their applications for distribution, not something that Arcjet, as a tool vendor, can use. This is due to our requirement to avoid native dependencies (a Java application built using GraalVM’s AOT support is indistinguishable from any other natively-compiled code). Aside from additional startup overhead, GraalWasm’s Wasm host doesn’t significantly affect the responsiveness of the runtime. 

asmble

asmble is a transpiler that takes Wasm modules as input and produces JVM bytecode (.class) files as output. It is essentially the build-time compilation functionality of Chicory as a standalone tool (although it was released first).

wasmer-java

wasmer-java is a Java wrapper around the mature wasmer runtime, which is written in Rust. Because it uses JNI (Java Native Interface) to link to the compiled Rust objects, it violates the requirement of having no native dependencies. 

Calling Conventions and Component Models

Wasm is intended to be portable and fast. It is not necessarily meant to be convenient. This is most evident when it comes to calling Wasm functions. 

Compared to other bytecode formats, Wasm has a relatively small number of data types. For primitives you have one of four numeric types (i32, i64, f32, and f64). More recently, newer versions of the Wasm spec include a vector type (v128) meant to be used to pass numeric values to vector operations. There are some other types, but they are not meant to be used outside of the scope of Wasm host development. That’s a short list!

This makes sense for a portable, low-level runtime intended to be a compilation target for natively-compiled languages. For example, almost every compiled language has some kind of string type, but string representations vary widely:

  • C and languages using C ABI and FFI conventions represent strings with pointers to sequences of bytes terminated by a null (0x00) byte.
  • Pascal-style strings are represented by a structure with a reference to the start of the string combined with a string length.
  • Although this is not generally exposed to developers writing JavaScript, many JavaScript runtimes actually represent strings using a completely different data structure.
  • Many languages include attributes to mark the encoding of the string: is it treated as an opaque sequence of bytes? Is it UTF-8? Is it UCS-2? (Is it WTF-16?).

It doesn’t make sense for the low-level bytecode to dictate how a guest language represents its data structures, and so Wasm opts to provide the lowest common denominator. If you want strings and other higher-level data types, you’re going to have to build translations for those types in either the language(s) supported by the host environment or in Wasm itself. You’re also going to need to maintain a set of bindings if you want a usable, higher-level API into your Wasm functions.

This is something that all but the simplest applications need, and there have been a lot of, um, spirited discussions about how to meet that need. Most of these conversations revolve around various frameworks for defining the interface between calling code in other languages and Wasm in a structured way. The most widely-used of these is the Wasm Component Model, which was preceded by the WebAssembly System Interface preview1 (WASIp1) and, way back when Wasm was still asm.js, Emscripten.

Interface definition tools have two parts:

  1. Some kind of interface specification. For the Wasm component model, this is a WIT (Wasm Interface Types) definition.
  2. Some means of generating the bindings necessary to adapt the calling conventions of the host and guest languages to the much more bare-bones types offered by Wasm.

There has been a long conversation between proponents of the various ways to handle calling conventions, and between proponents of these higher-level interface definitions and people developing Wasm-based applications for browsers, who typically don’t see the need for interface definitions in the first place. These conversations are not the point of the post, however. The most important results of the debate for the purposes of this post are a lot of fragmentation and not a lot of generally usable tools.

What this means for Arcjet is that we’ve had to develop our own tools. The maintainers of wazero, the Wasm runtime for Go we use in our back end services, are in the camp that is not interested in providing a high-level interface definition language for its platform. To get what we need we have developed Gravity, a tool for using WIT definitions and producing Go language bindings that allow our rules to be called from Go.

Because the source language for our Wasm functions is Rust, we can take advantage of Bytecode Alliance’s wit-bindgen project to handle the necessary binding generation for the Rust functions. We also use the jco suite’s transpilation support to do the type lowering necessary to run Wasm components (defined via WIT definitions) inside the JavaScript runtimes (Node, Deno, and Bun) supported by our TypeScript SDK.

This means both that we can use a single interface definition in three different languages and also that any new platforms we support need to have some means for supporting the Wasm Component Model. Unfortunately the only JVM Wasm runtime that offers any support for high-level Wasm definitions is Chicory providing WASIp1 bindings. So our alternatives are either to hand-write bindings against our WIT definitions (which can probably be done, as we are not doing anything particularly complicated, but which would entail an ongoing maintenance cost) or to develop another tool like Gravity that supports the JVM.

At the risk of editorializing, this is a difficulty for all developers running Wasm code from multiple host languages. I would welcome some kind of convergence of approach that allows Wasm users to consolidate their efforts on one set of tools.

How We Evaluated Runtimes

Our evaluation process was very simple: how much work does it take to get an Arcjet rule (written in Rust and compiled to Wasm) working with a JVM runtime? The rules are relatively small, the calling conventions are documented in .wit files, and we already have them running on both JavaScript engines and wazero. I picked the rule with the smallest object file (Arcjet’s Email Validation) to start.

In both Chicory and GraalWasm, it was easy to load, run, and invoke the rule. Next, I built a small Wasm object that bundled the compiled rule with its unit tests and a simple test runner, and this also ran without issue. After that, I tried to call the compiled function from Java, and this is where I ran into real difficulty.

The API of the email validation rule is simple, but the parameters passed to the call are strings and other reference types, and this is where the absence of component model support made itself felt. Hand-writing the necessary binding boilerplate is prohibitively time-consuming for any but the most basic function signatures, and it immediately became clear that in order to do any meaningful functional evaluation or performance testing, I would need to build at least a rudimentary WIT code generator for Java. Unfortunately, at this point I had to switch my attention to other things.

Future Work: What’s Needed for First-Class Java Support

The good news is that there are production-ready solutions for running Wasm from the JVM. The bad news is that none of them are truly batteries-included. Anyone wanting to do anything even moderately complex using Wasm is going to have to put in some work.

For Arcjet, there are two major problems to solve.

Calling Arcjet’s Wasm Functions From Java

In order to create a Java SDK for Arcjet that is easy to use, and that we can support, we need a simple way to call into our Wasm rules from Java. We need the ability to generate Java bindings for Wasm from some kind of interface definition language, as described in the previous section. It’s not a hard requirement, but it would be much simpler if those interfaces were based on the Wasm Component Model, and allowed us to use our existing WIT specs. The simplest solution would be to write another tool like Gravity that targets the JVM rather than Go. It’s a significant but not insurmountable obstacle to being able to make our SDKs run consistently across languages.

Making Arcjet Easy to Use in Java

The other thing we’d need to do is come up with a way to build and bundle all of the necessary dependencies such that deploying Arcjet into Java applications is a simple matter of cut and paste. This shouldn’t require much more effort than adding a stanza or two to a Maven manifest or a Gradle script, then configuring and integrating the Arcjet rules into a Java web application or API server.

Creating a simple developer experience is not a huge amount of work, but there are some questions we need to answer first:

  • Do we require users to write rules directly in their application in Java?
  • Do we create a DSL in Groovy or Kotlin?
  • Do we support embedding rule configuration separate from files in Spring configuration files?

I hope that you’ve enjoyed this overview of running Wasm on the JVM, and if you have any questions or notice something that I’ve overlooked (or want to try Arcjet for Java), please get in touch!

Related articles

Subscribe by email

Get the full posts by email every week.