How the JIT works

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.

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)
UnitSourceResponsibility
Drivercore/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.
Analysiscompiler/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 mapcompiler/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.
Emitcompiler/wasmEmit.cpp The per-bytecode translation: emit_op (the main opcode switch) plus helpers for field access, calls, allocation, ldc, synchronization unlock, and intrinsics.
Resolvercompiler/wasmResolver.cpp Constant-pool / callee resolution behind the call and field-access emitters.
Assemblerassembler/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 tableassembler/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.

Because each 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

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).


  
idle
(output appears here)
Runs entirely client-side — no server. Absolute times vary by machine and browser; the JIT typically shows a several-× speedup on integer- and loop-heavy code, because the whole loop becomes one wasm function instead of a per-bytecode dispatch.

Results

Full write-up in the repo: wasm-jvm/docs/jit.md. Try it live in the REPL (compile Java in-browser) or watch it drive the Swing demo.