Virtual Machines and Bytecode

Imagine shipping a program that must run identically on a phone, a browser, a server, and a smart card. Compiling directly to each machine's native instructions means maintaining dozens of backends. Trusting arbitrary native code on a shared host is also a security nightmare. The solution adopted by Java, Python, WebAssembly, and countless scripting languages is different. It is to compile once to a portable intermediate format — bytecode. This bytecode is then executed with a virtual machine: a software layer that behaves like a processor but is implemented in software. It has full control over what instructions mean and how much trust they deserve. This design point sits deliberately between two extremes already familiar from Interpreters versus Compilers. A tree-walking interpreter is simple but slow, repeatedly re-decoding the same syntax. A native compiler is fast but ties the program to one instruction set. It also gives up runtime control. Bytecode virtual machines compile source (or an intermediate representation) down to a small, dense, machine-like instruction set once. Then they execute that instruction set very quickly with a dispatch loop. They also retain the ability to sandbox, verify, garbage-collect, and eventually recompile the code at runtime. The two dominant organizing principles are the stack machine and the register machine. In a stack machine, instructions operate on an implicit operand stack (as in the JVM, CPython's CPython bytecode, and WebAssembly). In a register machine, instructions name virtual registers explicitly (as in Lua's VM and the Dalvik/ART VM for Android). Both styles must solve the same underlying problems. How to represent instructions compactly. How to manage the call stack and activation records for method calls. How to interact with a garbage collector to track live references. How to dispatch instructions efficiently despite doing so millions of times per second. This article builds a bytecode VM from first principles. It covers what an instruction set looks like, and how the interpreter loop executes it. It explains why stack versus register designs trade code density for dispatch overhead. It also shows how correctness is established via an operand-stack invariant. It closes by tracing the path that many production VMs eventually take: interpreting bytecode as a stepping stone toward Just-In-Time Compilation.

Key takeaways
  • A bytecode VM compiles source to a compact, portable instruction set, then executes it with a software interpreter instead of the native CPU.
  • Stack machines (JVM, Python, Wasm) push/pop an implicit operand stack; instructions are short but execution needs more dispatches per operation.
  • Register machines (Lua, Dalvik) name virtual registers in each instruction; fewer instructions and data movements, but larger instruction encodings.
  • The classic decode–dispatch loop with a `switch`/computed-goto is the reference implementation; a well-formed program keeps the operand stack depth statically predictable at every point.
  • Correctness rests on a type-and-stack invariant verified once (bytecode verification) so the interpreter can skip runtime checks in the hot loop.
  • Interpreting bytecode is the low-latency-startup baseline that Just-In-Time Compilation later augments or replaces for hot code.
The core insight

A bytecode virtual machine trades one-time compilation cost for portability and runtime control, by executing a compact, verifiable instruction set on a software-defined stack or register machine instead of the real hardware.

The idea

A bytecode instruction set is a machine language for a machine that does not physically exist. Each instruction is a small, densely encoded operation — push a constant, add two values, invoke a method, branch — chosen so that a simple interpreter can execute it quickly and so that a compiler can emit it directly from an abstract syntax tree or an intermediate representation. Unlike source code, bytecode is already linearized, has resolved scoping into slot or register indices, and often carries auxiliary metadata (constant pools, type descriptors, exception tables) that the runtime needs but a human never sees. A virtual machine (VM) in this context is the runtime program that fetches, decodes, and executes bytecode instructions in a loop, exactly as a physical CPU fetches, decodes, and executes native instructions. Two design families dominate. A stack-based VM keeps operands on an implicit operand stack; an instruction like `iadd` takes no operands in its encoding — it simply pops two values off the stack and pushes their sum. A register-based VM instead gives every instruction explicit operand fields that name virtual registers (really, slots in the current activation record), so an add instruction looks like `add r3, r1, r2`. The stack design yields extremely compact, easy-to-generate code (a compiler never has to allocate registers); the register design yields fewer total instructions and less data shuffling, at the cost of larger instruction words and a real register-allocation problem for the compiler. Both families share the same runtime scaffolding familiar from Runtime Organization: The Call Stack and Activation Records: each method invocation creates a frame holding local variables/registers, a return address, and (in stack machines) its own operand stack; frames are pushed and popped on calls and returns exactly like native call frames, and the VM's garbage collector must be able to scan every live frame to find object references — which is precisely why bytecode formats are designed to make the location and lifetime of references statically determinable.

How it works

Compilation to bytecode. A front end parses source into an AST or a lower IR, then a code generator walks that structure emitting instructions. For a stack machine, expression compilation is a straightforward postorder traversal: compile the operands (which leaves their values on the stack), then emit the operator instruction (which consumes them and pushes the result). For a register machine, the compiler must additionally choose which registers hold each subexpression's result, either via a simple stack-like counter of "next free register" (simple, sometimes wasteful) or a real register allocator.

The interpreter loop. At its core, every bytecode VM runs the same fetch–decode–execute cycle: ``` function RUN(frame): pc := frame.entryPoint loop: opcode := frame.code[pc] pc := pc + 1 switch opcode: case PUSH_CONST: idx := frame.code[pc]; pc := pc + 1 frame.stack.push(frame.constPool[idx]) case ADD: b := frame.stack.pop() a := frame.stack.pop() frame.stack.push(a + b) case LOAD_LOCAL: slot := frame.code[pc]; pc := pc + 1 frame.stack.push(frame.locals[slot]) case STORE_LOCAL: slot := frame.code[pc]; pc := pc + 1 frame.locals[slot] := frame.stack.pop() case JUMP_IF_FALSE: target := frame.code[pc]; pc := pc + 1 cond := frame.stack.pop() if cond == false: pc := target case CALL: methodRef := frame.code[pc]; pc := pc + 1 argc := lookupArity(methodRef) args := frame.stack.popN(argc) callee := newFrame(methodRef, args) result := RUN(callee) // recursive call, or an explicit call stack frame.stack.push(result) case RETURN: return frame.stack.pop() default: trap("illegal opcode") ``` A register-based version replaces `stack.push`/`pop` with direct reads and writes to indexed registers, and the operand fields are embedded in the instruction word itself: ``` case ADD_R: dst := frame.code[pc]; pc := pc + 1 src1 := frame.code[pc]; pc := pc + 1 src2 := frame.code[pc]; pc := pc + 1 frame.regs[dst] := frame.regs[src1] + frame.regs[src2] ```

Dispatch mechanics. The `switch` shown above is the textbook implementation but is not the fastest one. Three standard dispatch strategies, in increasing order of typical speed: 1. Switch dispatch — a bounds-checked jump table generated by the compiler from a `switch` statement; simple, portable, but incurs a bounds check and sometimes poor branch prediction because one branch instruction serves every opcode. 2. Computed-goto (threaded) dispatch — each opcode handler ends by jumping directly to the address of the next opcode's handler (`goto dispatchTable[opcode]`), spreading the indirect branch across many call sites, which modern branch predictors handle far better since each site tends to see a more stable pattern of successor opcodes. 3. <strong>Direct/inline threading</strong> — the bytecode stream itself is rewritten (once, at load time) to store handler addresses instead of opcode numbers, eliminating the table lookup entirely. All three compute the same* sequence of operations; they differ only in dispatch overhead, which matters because dispatch is the one piece of work repeated for every single instruction executed.

Constant pools and symbolic references. Bytecode formats (notably the JVM's `.class` format) avoid embedding raw addresses because the program must be relocatable and safely loadable from an untrusted source. Instead, instructions reference an index into a constant pool holding class names, method signatures, and literal values as symbolic entries; the VM resolves these lazily on first use and caches the resolution, a technique called quickening or inline caching at the bytecode level.

Bytecode verification. Before running untrusted bytecode, a security-sensitive VM statically checks that the instruction stream cannot violate type safety or stack discipline — no popping an empty stack, no jumping into the middle of an instruction, no treating an integer as an object reference. This is done once, at load time, via a data-flow analysis called the verifier (conceptually an abstract interpretation over a lattice of stack/local types), so the interpreter's hot loop can omit these checks entirely and simply trust the invariant.

Figure
Stack-machine frame layout and instruction dispatch
Call stack (frames) Frame: main() locals: [x=3, y=5] op-stack: [] pc → CALL add return addr: — Frame: add(a,b) locals: [a=3, b=5] op-stack: [3, 5] pc → ADD return addr: main:0x0A grows ↓ on CALL, shrinks ↑ on RETURN Fetch–decode–execute loop pc=0x08 PUSH_CONST 3 pc=0x0A PUSH_CONST 5 pc=0x0C CALL add ── new frame pushed ── pc=0x00 LOAD a pc=0x02 LOAD b pc=0x04 ADD pc=0x06 RETURN ── frame popped, result pushed ── pc=0x0E STORE_LOCAL sum Operand stack during ADD | 5 | | 3 | pop b=5, pop a=3 push a+b = 8 | 8 |

Complexity & correctness

Time. Each executed bytecode instruction costs O(1)O(1) amortized work in the interpreter: one fetch, one decode/dispatch, and a constant amount of stack/register manipulation, plus occasional O(1)O(1)-amortized constant-pool resolution (memoized after the first hit). If a program executes nn total instructions at runtime, the interpreter runs in O(n)O(n) time. The constant hidden inside that O(1)O(1) — the dispatch overhead — is exactly what separates switch dispatch from threaded dispatch; it does not change the asymptotic bound but easily accounts for a 2–3× difference in wall-clock throughput, which is why production VMs care about it.

Space. Each active frame uses O(k)O(k) space for kk locals/registers plus O(d)O(d) for the maximum operand-stack depth dd reached within that method — both are statically known at compile time from the method's bytecode, so the VM preallocates exactly the right frame size with no dynamic growth needed. Total space is O(F⋅(k+d))O(F \cdot (k+d)) for FF live frames, i.e., proportional to call depth, matching the space behavior of native call stacks discussed under Runtime Organization: The Call Stack and Activation Records.

Correctness — the stack-depth invariant. The central correctness property of a stack-machine bytecode program is: > At every program point, the operand-stack depth reached is the same along every control-flow path that reaches that point, and every value's static type at that point is determined independent of path. This is provable by induction over the control-flow graph: the base case is the method entry (stack depth 0), and the inductive step checks, for each instruction, that its effect on stack depth (push/pop counts, which are fixed per opcode) is consistent regardless of which predecessor block was taken to reach it — exactly the analysis a bytecode verifier performs as an abstract interpretation, propagating an abstract stack-shape lattice until it reaches a fixed point (a standard forward data-flow analysis, terminating because the lattice has finite height and updates are monotone). Once verified, the interpreter can assume the stack never underflows and every pop's type matches the consuming instruction, which is what allows the hot loop pseudocode above to omit bounds and type checks entirely — the checks happened once, at load time, instead of on every one of the nn executed instructions.

Tradeoffs, honestly. Stack machines produce smaller bytecode (no operand fields to encode) and trivial code generation, but need more instructions overall — pushing and popping intermediate results that a register machine could keep put — and each of those extra instructions pays a dispatch cost. Register machines cut instruction count substantially (empirically, Lua's register VM executes roughly 40–50% fewer instructions than an equivalent stack VM for the same program) but instructions are wider (more bits for register fields), code generation needs a register allocator, and instructions can update several registers at once, complicating GC root-scanning at arbitrary points. Neither design is strictly better; the choice trades code density and compiler simplicity against instruction count and interpretation speed, and both remain slower than compiled native code by roughly one to two orders of magnitude for computation-heavy code — the gap Just-In-Time Compilation exists to close.

Worked example

Compile and trace the expression `(x + 1) * y` with `x = 4`, `y = 3`, for a stack machine.

Bytecode (with a 2-slot local array `[x, y]`): ``` 0: LOAD_LOCAL 0 ; push x 2: PUSH_CONST 1 ; push 1 4: ADD 5: LOAD_LOCAL 1 ; push y 7: MUL 8: RETURN ```

pcInstructionStack beforeActionStack after
0`LOAD_LOCAL 0``[]`push locals[0]=4`[4]`
2`PUSH_CONST 1``[4]`push const 1`[4, 1]`
4`ADD``[4, 1]`pop 1, pop 4, push 5`[5]`
5`LOAD_LOCAL 1``[5]`push locals[1]=3`[5, 3]`
7`MUL``[5, 3]`pop 3, pop 5, push 15`[15]`
8`RETURN``[15]`pop and return 15`[]`

Result: 15, matching `(4 + 1) 3`. Note the max stack depth reached is 2 — this is exactly the value the verifier and the frame allocator compute statically, so the VM can allocate a fixed 2-slot operand stack for this method with no runtime resizing. Now the <strong>register-machine</strong> version of the same expression, using registers `r0=x, r1=y`, `r2` scratch: ``` ADD_RI r2, r0, #1 ; r2 := x + 1 MUL_RR r2, r2, r1 ; r2 := r2 y RETURN r2 ```

Instructionr0 (x)r1 (y)r2Note
initial43—
`ADD_RI r2, r0, #1`435one instruction replaces load+push+add
`MUL_RR r2, r2, r1`4315one instruction replaces load+mul
`RETURN r2`4315returns 15

Same result, 3 instructions instead of 6 — the register form eliminates the explicit push/pop traffic entirely, at the cost of each instruction now carrying three operand fields instead of zero.

Common pitfalls & traps

PitfallWhy it happensConsequence
Assuming stack depth is dynamicForgetting the verifier requires static, path-independent max depthFrame allocation under- or over-provisions the operand stack
Off-by-one in `pc` advancementForgetting to skip operand bytes after a multi-byte instructionInterpreter misinterprets an operand byte as the next opcode
Treating GC roots casually in a register VMRegisters can hold live references anywhere in the frame, not just a stack topGarbage collector scans stale/incorrect roots, causing crashes or leaks
Confusing dispatch style with semanticsSwitch, threaded, and inline dispatch are implementation choicesMistakenly believing threaded dispatch changes program behavior, not just speed
Skipping verification for "trusted" bytecodeAssuming the compiler that produced the bytecode is the only sourceHand-crafted or corrupted bytecode can violate the stack invariant and crash or exploit the VM

Pioneers & history

Origins · 1 minJames Gosling and how this was discovered
JG
James Gosling
1955– · Designer of the Java programming language and lead architect of the Java Virtual Machine at Sun Microsystems

Gosling's team designed the JVM's bytecode format and class-file structure specifically to support portable, verifiable, network-mobile code — "write once, run anywhere" — making bytecode verification a first-class security mechanism rather than an afterthought.

History
1958
— Donald Knuth and contemporaries formalize stack-based expression evaluation via reverse Polish notation, the conceptual root of stack-machine bytecode.
1978
— the UCSD Pascal system popularizes "p-code," a portable bytecode compiled once and interpreted on many machines.
1995
— Sun Microsystems releases the Java Virtual Machine with a verifiable stack-based bytecode format designed for network-delivered, sandboxed execution.
2003
— CPython's bytecode-based interpreter and Lua's register-based VM mature in parallel, illustrating the stack-versus-register design split in production.
2017
— the WebAssembly (Wasm) standard is adopted, bringing a verifiable, portable stack-based bytecode format to browsers and beyond.

Where this matters

The Java Virtual Machine executes `.class` bytecode for Java, Kotlin, Scala, and Clojure, using exactly the verifier-then-interpret-then-JIT pipeline this article describes. The CPython interpreter compiles Python source to a stack-based bytecode executed by a switch-dispatch loop, which is why CPython performance discussions center on dispatch overhead. WebAssembly ships a verifiable, portable stack-based bytecode as a compilation target for C, C++, Rust, and Go, letting browsers and edge runtimes sandbox near-native-speed code from untrusted sources. Android's ART/Dalvik runtime uses a register-based bytecode explicitly because register VMs execute fewer instructions per operation, an important tradeoff on battery-constrained mobile hardware. The .NET Common Language Runtime executes a stack-based Common Intermediate Language shared across C#, F#, and VB.NET. In every one of these systems, the bytecode-interpretation stage described here is the fallback and bootstrap layer beneath a tiered execution strategy that escalates to Just-In-Time Compilation for hot methods, blending the portability of bytecode with the speed of native code.

Ask the tutor: How would you redesign the interpreter loop to support a JIT compiler that promotes hot methods to native code mid-execution?

Why not just compile straight to native machine code and skip bytecode entirely?

Native compilation ties the program to one instruction set architecture, so every target (x86, ARM, RISC-V, ...) needs its own build, and native code carries no built-in safety boundary — a bug or malicious payload can access memory arbitrarily. Bytecode solves both problems: it is portable across any host that implements the VM, and it can be statically verified before execution, giving a sandboxing guarantee that native code cannot offer without heavyweight external mechanisms like OS process isolation. The price is the interpretation overhead this article quantifies, roughly one to two orders of magnitude slower than optimized native code for compute-heavy loops, which production systems address with Just-In-Time Compilation rather than by abandoning bytecode.

Is a stack machine or a register machine strictly faster?

Neither, in general — it depends on what you're optimizing for. Register machines execute fewer instructions per operation because they avoid explicit push/pop traffic, which tends to win on raw interpretation throughput and is attractive on power-constrained devices like phones (hence Dalvik/ART). Stack machines produce smaller, simpler-to-generate bytecode and impose a lighter burden on the compiler (no register allocation), which matters for fast startup and simple toolchains. Real-world benchmarks show register VMs winning on CPU-bound loops and stack VMs remaining competitive once dispatch is well-optimized, so the choice is usually driven by target constraints (mobile power budget, compiler simplicity, code size) rather than a universal speed verdict.

What exactly does bytecode verification check, concretely?

A verifier performs a static data-flow analysis over the method's control-flow graph, tracking an abstract "stack shape" (the sequence of value types believed to be on the operand stack) at every program point. It checks that every instruction's required operand types are available and correctly typed, that the stack never underflows, that branch targets land on valid instruction boundaries, and that the stack shape agrees along every path into a merge point (so no instruction sees an ambiguous stack state). This is a forward fixed-point computation over a finite-height lattice of type states, guaranteeing termination, and it lets the interpreter trust the invariant afterward instead of checking it per instruction.

How does the VM know where object references live for garbage collection?

Because bytecode formats are designed so that the location and lifetime of references are statically determinable from the method's type signature and its verified stack/local type map at every instruction. In a stack machine, at any safe point (typically a call site or loop back-edge) the verifier's abstract stack-shape map tells the GC exactly which stack slots and locals currently hold live object references versus raw integers. In a register machine, the same information comes from a per-instruction map of which registers hold references. This is precisely why bytecode is preferred over raw native code for managed runtimes: native code loses this typed structure, forcing much more conservative and expensive root-finding, as covered under Garbage Collection Strategies.

What is a constant pool and why does bytecode use one instead of embedding literals directly?

A constant pool is a table, stored alongside the bytecode, holding literal values, string constants, and symbolic references to classes, fields, and methods that instructions refer to by index rather than by embedding directly. This indirection lets the bytecode stay relocatable (the same class file can load at different memory addresses across runs or machines) and lets symbolic references — like "call method `foo` on class `Bar`" — be resolved lazily the first time they are executed, with the resolved result cached for subsequent hits. It also keeps instruction encodings small and uniform, since a constant-pool index is a fixed-width field regardless of how large or complex the actual constant is.

How does this relate to Just-In-Time compilation?

Interpreting bytecode gives instant startup (no compilation delay) and small memory footprint, but pays per-instruction dispatch overhead on every execution, which is wasteful for code executed millions of times. A JIT compiler observes the interpreter's execution counts, identifies "hot" methods or loops, and compiles just those to native machine code at runtime, replacing the interpreted path for future calls while cold code stays interpreted. This tiered strategy — bytecode interpretation as the baseline, native compilation as the escalation for hot paths — is exactly the architecture used by the JVM's HotSpot, V8 for JavaScript, and CPython's newer adaptive interpreter, and is the subject that naturally follows an understanding of bytecode VMs.

Why do some VMs use recursion for CALL/RETURN in the interpreter loop, and is that safe?

Implementing `CALL` as a recursive call into the interpreter's own `RUN` function (as shown in the pseudocode) is simple and mirrors the native call stack, letting the host language's own stack discipline manage VM frames. It is safe as long as the VM either relies on the host stack having enough depth for the guest program's maximum recursion, or — more robustly — maintains its own explicit frame stack in heap-allocated storage and turns the interpreter loop into an iterative trampoline. Production VMs typically choose the explicit-stack approach precisely to control stack-overflow behavior, report guest-language stack-overflow exceptions gracefully, and avoid crashing the host process when guest recursion is deep but legal.

Before this

Where this leads