A bytecode → WebAssembly JIT
The Zero VM is a pure interpreter — you can't emit machine code inside the wasm sandbox. So WasmJit compiles hot Java methods into a fresh WebAssembly module at run time, installs the function in the indirect table, and the interpreter calls that instead of interpreting.
The dispatch path
At method_entry the interpreter asks WasmJit::compiled_entry(m).
For an eligible method the compiler emits wasm, does new WebAssembly.Module
+ addFunction, caches the table index per Method, and returns it.
The hook in bytecodeInterpreter.cpp reads the arguments from the frame,
calls the wasm function, pushes the result, and jumps to handle_return —
exactly as an ireturn would.
- The JVM and wasm are both stack machines, so arithmetic maps almost 1:1
(
iadd→i32.add). - Control flow uses a dispatch loop: each basic block becomes
if (i32.eq $bb b) { …; $bb = next; br $L }inside a wasmloop, withselectchoosing the next block. This handles loops and if/else without a relooper.
Compiler pipeline
The compiler lives in the JDK tree at
src/hotspot/share/interpreter/wasm/, organised as a small pipeline of
per-concern translation units rather than one monolith. A compile threads a
compile context (Ctx) — the method, its constant pool, the
local/oop layout, the operand value-type stack, and spill bookkeeping — through
three stages, all writing wasm into a growable byte buffer (Buf).
bytecode ─▶ analysis ──▶ emit (per-op) ──▶ module assembly ─▶ wasm module bytes
(Ctx) (Ctx, Buf) (Buf)
| Unit | Source | Responsibility |
|---|---|---|
| Driver | core/wasmJit.cpp |
Eligibility gate, orchestration, module creation, and the per-Method
table-index cache. Kept lock-free on the ineligible/warming hot path — a global
mutex per dispatch melts down across emscripten pthreads. |
| Analysis | compiler/wasmAnalysis.cpp |
Pre-passes: classify_locals (wasm local types),
analyze_oop_slots (which slots hold oops → GC-scanned spill array),
and compile_cf, which lays out the basic-block dispatch loop and the
per-throw-site exception handler dispatch. |
| Stack map | compiler/wasmStackmap.cpp |
op_consumed (entries a throwing op pops) and stack_delta
(net operand-stack delta in JVM words) — keep the value-type stack and spill
state correct across ops. |
| Emit | compiler/wasmEmit.cpp |
The per-bytecode translation: emit_op (the main opcode switch) plus
helpers for field access, calls, allocation, ldc, synchronization
unlock, and intrinsics. |
| Resolver | compiler/wasmResolver.cpp |
Constant-pool / callee resolution behind the call and field-access emitters. |
| Assembler | assembler/wasmAssembler.cpp |
The Buf buffer, LEB128 primitives, the instruction emitters, and
emit_module (wraps the body in a complete wasm module — type,
function, memory, and code sections). |
| Opcode table | assembler/wasmOpcodes.hpp |
enum WOp — every opcode the backend emits, named per the wasm core
spec, valued at its encoded byte. |
Two headers make the boundaries explicit: wasmCompiler.hpp is the
public entry the driver calls, while wasmCompilerInternal.hpp holds
the cross-unit calls the stages make to one another — so the per-concern
.cpp files need not be one translation unit.
The emitter layer
Emit sites don't write raw opcode bytes. The assembler exposes a named, composite
emitter for each instruction — much like a HotSpot MacroAssembler — so a
translation reads as the wasm it produces:
// null-check the top-of-stack oop, then load a field: tee_local(c, x->TMPI); // keep the oop bput(c, op_i32_eqz); // oop == 0 ? if_void(c); // if null: emit_call(c, Imp::THROW_NPE); // throw NPE emit_exc(x, c, pc); // dispatch / propagate emit_end(c); get_local(c, x->TMPI); mem_op(c, op_i32_load, 2, off); // i32.load align=2 offset=off
Single-byte ALU / compare / conversion ops go through bput(c, op_*) with a
named WOp; multi-byte idioms have dedicated emitters —
i32_const/i64_const, emit_call, mem_op
(load/store with {align, offset}), the structured-control forms
if_void/if_type/block_void/loop_void/emit_end,
br/br_if, ret, drop, and trunc_sat.
WOp equals its encoded byte, adopting a name is byte-identical.
One subtlety the emitters hide: the value-type bytes a block/if/loop
takes as a blocktype (i64 = 0x7e) collide with arithmetic opcodes
(i64.mul = 0x7e). if_type(c, vt_i64) names the blocktype
case so the two are never confused.
What it compiles
- All primitive types — int/long/float/double ALU, NaN-correct compares,
every conversion, numeric
ldc. Integerdiv/remthrow on /0 and handle the MIN/-1 overflow inline. - Control flow — all conditional branches,
goto,tableswitch/lookupswitch, loops, full stack shuffles, in-methodtry/catch. - Objects & arrays — instance & static fields (primitive and reference,
with the write barrier), array load/store with bounds checks,
new,checkcast/instanceof,athrow. - Calls —
invokestatic/special/virtual/interface, one uniform i64-widened ABI into JIT'd or interpreted callees. - GC safety — oops are re-read from GC-scanned frame slots, produced oops use a GC-scanned spill array, and loops emit a per-back-edge safepoint poll.
Methods named jit* are compiled by default; WASMJIT_ALL=1 JITs
every eligible method. Correctness is pinned by differential benches: each JITs
its jit* methods and interprets p* twins in the same run, so a
mismatch is a test failure. All pass.
Live benchmark — interpreter vs JIT
The same hot kernel, run twice in one VM. Under the default gate,
jitKernel() is compiled to WebAssembly on its first call while the
identical pKernel() stays in the Zero interpreter — so timing the two is
a direct interpreter-vs-JIT comparison of the same code. Press Run to compile
and execute it right here in your browser (the first run boots the JVM, ~5 s;
the VM then stays warm).
(output appears here)
Results
- Straight-line kernel (60 ops, 10M calls): ~6× over the interpreter.
- Loop kernel (40M-iteration loop): ~12× — the whole loop runs as one wasm function.
- Swing app in JIT mode (
WASMJIT_ALL=1): renders at ~37 fps while the JIT compiles real JDK methods (Math.floorMod,Integer.hashCode, AQS lock helpers, …).
wasm-jvm/docs/jit.md. Try it live in the
REPL (compile Java in-browser) or watch it drive the
Swing demo.