bytecode
a stack machine. watch the operand stack while a method runs.
- chapter
- chapter 13.03
- reading time
- 8 min read
the JVM is a stack machine. every method call gets a frame with two things in it: a local variable array (numbered slots for this, the parameters and your locals) and an operand stack. instructions push values onto the stack, pop them off, and push results back. there are no registers.
a + b is "push a, push b, add". that's bytecode.
the picture
you know how a calculator with an "enter" key (the old HP kind) never needs parentheses? you type 3 enter 4 + and it works, because every number goes on a stack and every operation eats the top of it. that's reverse polish notation, and it's exactly how the JVM thinks.
the local variable array is the calculator's memory buttons (named slots you can store into and recall from). the operand stack is the display stack (where the actual math happens). every instruction is one button press.
so int c = a + b; becomes four presses: recall a, recall b, add, store into c.
watch it run
pick a method, then press step. one step is one instruction. the bytecode is real: it's what javap -c prints for these methods on jdk 25.
- 1static int add(int a, int b) {
- 2 int c = a + b;
- 3 return c;
- 4}
- 0:iload_0
- 1:iload_1
- 2:iadd
- 3:istore_2
- 4:iload_2
- 5:ireturn
add(3, 4) is called. the JVM makes a frame: a local variable array with a in slot 0 and b in slot 1, and an empty operand stack. c has a slot reserved, but nothing in it yet.
square runs again. in new Point(1, 2), the object exists with zeroed fields four instructions before its constructor starts, and the blue values are references.three things to notice:
- locals are numbered, not named.
ais slot 0,bis slot 1. in an instance method slot 0 is alwaysthis(step intoPoint.<init>to see it). the names only survive in an optional debug table (javac -g), which is how your debugger shows them. - the stack has a fixed max depth. javac computes it per method (
stack=2above) and writes it into the class file, so the JVM can allocate the frame up front.new Point(1, 2)needs 4: two references and two ints. - calls pass values by copying them.
invokestaticpops the arguments off the caller's stack and they become the callee's first locals. that's pass-by-value, at the instruction level.
reading javap
here's the add method, as javap -c -p prints it:
static int add(int, int);
Code:
0: iload_0
1: iload_1
2: iadd
3: istore_2
4: iload_2
5: ireturnthe number on the left is the byte offset of the instruction inside the method (the pc). most instructions are one byte, so they count up by one; if_icmpge 19 is three (an opcode plus a two-byte jump offset), which is why the loop above goes 6, 9, 10.
the first letter of an instruction is its type. the JVM never figures out types at runtime: javac picked the instruction for you, and the verifier checked it before anything ran.
| prefix | type | e.g. |
|---|---|---|
i | int (and boolean, byte, char, short) | iload, iadd, ireturn |
l | long | lload, lmul, lreturn |
f | float | fadd, fcmpl |
d | double | dadd, dcmpg |
a | reference | aload, areturn, aastore |
b c s | byte, char, short arrays | baload, castore, saload |
and the families, roughly 200 opcodes in all (an opcode is one byte, so there's room for 256):
- ├── locals ↔ operand stack
- │ └── iload_0 aload_1 istore_2 ...
- ├── push a literal
- │ └── iconst_0..5 bipush sipush ldc aconst_null
- ├──
- │ └── iadd lmul ddiv irem ineg ishl i2l d2i ...
- ├── all control flow is this
- │ └── if_icmpge ifeq ifnull goto tableswitch lookupswitch
- ├──
- │ └── new getfield putfield getstatic newarray arraylength checkcast instanceof
- ├──
- │ └── dup dup_x1 pop swap
- ├── the next chapter
- │ └── invokestatic invokevirtual invokeinterface invokespecial invokedynamic
- ├──
- │ └── ireturn areturn return
- └──
- └── athrow monitorenter monitorexit
why a stack machine?
real CPUs have registers. why didn't the JVM?
- portability. a stack machine assumes nothing about the CPU underneath. x86 has 16 general-purpose registers, ARM64 has 31. bytecode doesn't care.
- size.
iload_0doesn't need to name a register, so it fits in one byte. java was designed in the 90s to ship code over slow networks. - verification. with a fixed stack depth and typed instructions, the JVM can check a method in one pass before running it: no stack underflow, no
intused as a reference.
the catch: stack code is slow to interpret literally. but nothing runs it literally for long. the JIT turns hot methods into register-based machine code, and the operand stack mostly disappears. Android's runtime went the other way and used a register-based bytecode (dex) for its interpreter.
int i = 5;
i = i++;
IO.println(i);void main() {
int i = 5;
i = i++;
IO.println(i);
}5
long and double take two slots
in the spec's model, one local slot (or one operand stack entry) holds an int, a float or a reference. a long or a double takes two of each.
static long twice(long);
stack=4, locals=2, args_size=1
0: lload_0
1: ldc2_w #7 // long 2l
4: lmul
5: lreturnlong after an int parameter lands in slots 1 and 2, and the next parameter starts at 3.tableswitch vs lookupswitch+
a switch on ints compiles to one of two instructions. with dense keys (1, 2, 3) javac emits tableswitch: a jump table indexed by key - low, so finding the case is one array lookup. with sparse keys (1 and 1000) it emits lookupswitch: a sorted list of key/offset pairs the JVM can binary-search. javac picks by comparing the estimated cost of both. switch on strings compiles to a lookupswitch on hashCode() followed by equals checks, and pattern switch uses invokedynamic (SwitchBootstraps.typeSwitch).
- a frame is a local variable array plus an operand stack. that's the whole machine.
- instructions push and pop.
a + bis iload, iload, iadd. - the type is in the opcode: iadd, ladd, dadd. nothing is checked at runtime.
- loops are jumps.
newis allocate, dup, invokespecial. calls copy arguments into the callee's locals. javap -c -pshows you all of it. read it whenever java surprises you.