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:
Java source code ── javac ──> class file ── JVM implementation ──> interpret or native machine code
JLS Constraints JVMS Constraints Implementation StrategyThe 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:
public final class PriceMath {
public static int addTax(int price, int tax) {
int total = price + tax;
return total;
}
}javac --release 17 -g PriceMath.java
javap -c -v -p PriceMathCommon 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:
ClassFile
├── magic / minor_version / major_version
├── constant_pool
├── access_flags / this_class / super_class
├── interfaces
├── fields
├── methods
└── attributesThe 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;
invokedynamicand 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:
I int
J long
Ljava/lang/String; java.lang.String
[I int[]
(II)I Receive two ints, return an intThe 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:
0: iload_0
1: iload_1
2: iadd
3: istore_2
4: iload_2
5: ireturnStep-by-step tracking:
| Offset | Instruction | Local Variables | Operand Stack |
|---|---|---|---|
| 0 | iload_0 | [price, tax, ?] | [price] |
| 1 | iload_1 | Same as above | [price, tax] |
| 2 | iadd | Same as above | [price + tax] |
| 3 | istore_2 | [price, tax, total] | [] |
| 4 | iload_2 | Same as above | [total] |
| 5 | ireturn | Current frame exits | Result 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:
public static int clamp(int value) {
if (value < 0) return 0;
return value;
}A typical result is:
0: iload_0
1: ifge 6
4: iconst_0
5: ireturn
6: iload_0
7: ireturnifge 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:
from to target type
0 10 13 java/lang/ArithmeticExceptionThe 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:
- Use
javap -c -v -pto find its method descriptor; - Manually track the operand stack for a basic block;
- Identify branch targets and exception tables;
- Compare the attribute differences between
-gand-g:none; - Explain which content comes from JVMS and which is merely the result of
javac's generation strategy.