Skip to content

VOL 09 / CH 05 / LESSON 01

5.1 Class Files and Stack Frames: Understanding javap Output ​

A source statement such as total += price becomes load, arithmetic, and store instructions. javap displays that bytecode and the surrounding class-file attributes, giving you evidence for boxing, bridge methods, and exception handling. The compiler does plenty of work on your behalf; an occasional audit helps.

First, clarify three levels:

text
Java source code ── javac ──> class file ── JVM implementation ──> interpret or native machine code
JLS Constraints            JVMS Constraints            Implementation Strategy

The class file and JVM instructions are defined by the JVMS; JIT compilation, object layout, and register allocation are part of the specific implementation. Seeing bytecode does not mean seeing all the instructions ultimately executed by the CPU.

Set up a minimal experiment ​

Save the following as PriceMath.java. Use a JDK that supports Java 17 as a compilation target, and specify that target explicitly:

java
public final class PriceMath {
    public static int addTax(int price, int tax) {
        int total = price + tax;
        return total;
    }
}
bash
javac --release 17 -g PriceMath.java
javap -c -v -p PriceMath

Common parameters:

  • -c: Disassemble method bytecode;
  • -v: Display detailed information such as version, constant pool, and attributes;
  • -p: Contains non-public members;
  • -l: Show line number table and local variable table.

Actual output may vary depending on the compiler and target version. Do not treat any single result as a permanent rule, reproduce the experiment under your own build parameters.

What's Inside a ClassFile ​

The top-level structure defined by JVMS includes:

text
ClassFile
├── magic / minor_version / major_version
├── constant_pool
├── access_flags / this_class / super_class
├── interfaces
├── fields
├── methods
└── attributes

The magic number is 0xCAFEBABE. The version number determines class file format compatibility; the Java 17 target above produces major version 61. Java SE 26 uses major version 70, with minor version 65535 for class files that depend on preview features. Preview class files require the matching runtime release with preview enabled; ordinary backward-compatibility assumptions do not apply. Projects deploying on older runtimes should explicitly specify the target version via build tools, rather than rely on whether the locally installed javac can compile alone.

The constant pool isn't just a string pool. It can include:

  • numeric and string literals;
  • Symbol names of classes and interfaces;
  • Field and method references;
  • Method handles, method types;
  • invokedynamic and information required for dynamic constants.

Bytecode often references symbols via indices in the constant pool. The width of the index operand is determined by the specific instruction, and JVM instructions are not all of fixed length.

Method descriptors come before source-level generics ​

The JVM uses descriptors to express field and method types:

text
I                    int
J                    long
Ljava/lang/String;   java.lang.String
[I                   int[]
(II)I                Receive two ints, return an int

The method descriptor of addTax(int, int) is (II)I. An instance-method descriptor does not include the implicit this receiver, which is supplied at invocation.

Generic information in field and method declarations can be stored in the Signature attribute; execution descriptors reflect type erasure. A List<String> field, for example, has descriptor Ljava/util/List;. Signature is separate from ordinary debug information controlled by -g: it can remain with -g:none, while line numbers, the source-file name, and local-variable debug tables are omitted.

Stack Frame: Local Variable Table and Operand Stack ​

Each method call creates a frame. Abstractly, a frame contains:

  • Local variable array;
  • Operand stack;
  • A reference to the current class's runtime constant pool;
  • Bookkeeping for normal return or exception propagation.

The core bytecode of addTax might be:

text
0: iload_0
1: iload_1
2: iadd
3: istore_2
4: iload_2
5: ireturn

Step-by-step tracking:

OffsetInstructionLocal VariablesOperand Stack
0iload_0[price, tax, ?][price]
1iload_1Same as above[price, tax]
2iaddSame as above[price + tax]
3istore_2[price, tax, total][]
4iload_2Same as above[total]
5ireturnCurrent frame exitsResult passed to caller

Static methods place the first parameter in local variable slot 0; instance methods typically place this in slot 0. long and double occupy two consecutive slots in the local variable array, but don't think of "slots" as physical CPU registers.

The JVM instruction set is operand-stack based, which doesn't mean that specific JVMs or generated machine code can't use registers. JIT compilers will ultimately allocate registers and stack locations according to the target CPU.

How branches and loops land on offsets ​

Add this method inside PriceMath, then recompile and disassemble it:

java
public static int clamp(int value) {
    if (value < 0) return 0;
    return value;
}

A typical result is:

text
0: iload_0
1: ifge          6
4: iconst_0
5: ireturn
6: iload_0
7: ireturn

ifge 6 pops the top int and branches to bytecode offset 6 within the method if the value is at least zero; otherwise execution continues with the next instruction. javap displays the target position, while the instruction encodes an offset relative to its own position. The source code block structure in the class file becomes a control flow graph; the line number table merely maps offsets back to source code lines, and optimizations or syntactic sugar may make the mapping non-one-to-one.

Why the Verifier Needs Type State ​

When the JVM loads untrusted class files, it can't assume the compiler is always correct. The verification phase checks type and stack state on control flow, for example:

  • Do instruction operand types match;
  • Is the jump target within a valid instruction boundary;
  • When any path reaches the same point, are the operand stacks compatible;
  • Does the return instruction conform to the method descriptor?
  • Are uninitialized objects being used in accordance with rules?

Modern class files often contain StackMapTable, providing type state at key control flow locations to help the verifier check without having to derive all paths from scratch.

Format checking, bytecode verification, and symbol resolution are distinct processes. A correct magic number only indicates "it looks like a class file", it doesn't guarantee that referenced methods exist or that the code has passed verification.

Exceptions Aren't Ordinary Return Instructions ​

try/catch is typically represented by the exception table in the Code attribute:

text
from  to  target  type
0     10  13      java/lang/ArithmeticException

The protected interval is [0, 10), excluding offset 10. The JVM searches the exception table in order for the first handler covering the throwing instruction with a matching exception type. It clears the operand stack, pushes only the exception object, and jumps to that handler. Without a match in this method, the exception propagates to its caller. The specific bytecode shape of finally is determined by compiler strategies and source code structure, possibly duplicating cleanup code or generating additional paths in conjunction with an exception table.

Completion Check ​

Write a short method containing if, a loop, and try/catch, then:

  1. Use javap -c -v -p to find its method descriptor;
  2. Manually track the operand stack for a basic block;
  3. Identify branch targets and exception tables;
  4. Compare the attribute differences between -g and -g:none;
  5. Explain which content comes from JVMS and which is merely the result of javac's generation strategy.

References ​

Built with VitePress | Software Systems Atlas