Skip to content

Worked example - Week 5: reading bytecode

Companion to Java Mastery → Month 02 → Week 5: Class Loading and Bytecode. The week explains what bytecode is and how class loading works. This page walks one tiny method through the toolchain so you can see every step concretely.

The source

// Adder.java
public class Adder {
    public int addOne(int x) {
        return x + 1;
    }
}

Three lines that do something. Compile it:

$ javac Adder.java
$ ls
Adder.class  Adder.java

Adder.class is the bytecode. It's not text - it's a binary format. We never read it as bytes; we use javap (a JDK tool) to print a human-readable disassembly.

The bytecode, narrated

$ javap -v -p Adder.class

(-v is verbose; -p shows private members too.)

The interesting part:

public int addOne(int);
  descriptor: (I)I
  flags: (0x0001) ACC_PUBLIC
  Code:
    stack=2, locals=2, args_size=2
       0: iload_1
       1: iconst_1
       2: iadd
       3: ireturn
    LineNumberTable:
      line 3: 0

Walk it line by line:

  • descriptor: (I)I - the method's type, written in JVM internal notation. (I)I means "takes one int, returns an int." (Ljava/lang/String;)V would mean "takes a String, returns void." The shorthand letters never go away; you'll see them in stack traces and bytecode forever.

  • stack=2, locals=2 - at runtime this method needs at most 2 slots on the operand stack and uses 2 local variable slots. The JVM verifier checked these limits when the class was loaded; if they're wrong the class won't load.

  • args_size=2 - two arguments: this (instance methods get an implicit this as local 0) and x (local 1). A static method would have args_size=1 for this signature.

  • 0: iload_1 - push local variable 1 (x) onto the operand stack. The i prefix is "integer." iload_1 is a single-byte opcode optimized for the common case "load int from local 1."

  • 1: iconst_1 - push the integer constant 1. Again a single-byte opcode for a tiny common case.

  • 2: iadd - pop the top two ints from the stack, add them, push the result. After this instruction the stack has one int: x + 1.

  • 3: ireturn - pop the top int and return it to the caller.

That's the whole method: 4 bytes of bytecode plus a 1-entry line number table.

What this tells you

  • The JVM is a stack machine. Operations consume and produce values on an evaluation stack, not registers. The JIT will later allocate those stack slots to real CPU registers, but the bytecode itself is stack-based for portability.
  • There are dozens of "constant int" opcodes. iconst_0 through iconst_5, plus iconst_m1, bipush (one-byte int), sipush (two-byte), ldc (load from constant pool). Each is one byte; the verbose ones are last-resort.
  • this is just local 0. Instance methods are static methods with an extra implicit argument. The invokevirtual opcode you'd see at a call site pushes this first, then the arguments.

The trap

You might assume the JVM interprets this bytecode and that's why Java is "slower than C." That's true for the first ~10,000 calls. After that, HotSpot's C2 compiler typically inlines addOne into the caller and emits one CPU instruction (add eax, 1 or similar). The bytecode is the source for the JIT, not the runtime program.

This is why the curriculum's later weeks on the JIT matter: bytecode is the contract, but it's almost never what actually runs.

Exercise

  1. Write a class with two methods: addOne(int) and addOne(long). Compile and javap -v -p. Compare the bytecode. What changed?
  2. Add a third method addOne(Integer). Predict the bytecode before running javap. Then look. What does autoboxing produce?
  3. Use the new Class-File API (JDK 22+, java.lang.classfile) to generate the same Adder.class programmatically and write it to disk. Confirm javap output matches the compiler's.

Comments