Creating a Javap Tool
In this lesson, you will build a small version of javap: a program that opens a compiled class and prints its version, fields, methods and the bytecode instructions of each method. Doing it yourself shows that a .class file is just structured bytes with a documented layout, and it introduces the Class-File API (java.lang.classfile), the JDK's standard library for reading and writing class files, which became final in Java 24 (JEP 484). This is an optional, advanced lesson that ties together "Javap Tool in Java" and "Bytecode Basics in Java".
The Layout of a Class File
Every class file has the same parts, in this order:
| Part | Size | Contents |
|---|---|---|
| Magic number | 4 bytes | Always CA FE BA BE |
| Minor and major version | 2 + 2 bytes | For example 0 and 69 (Java 25) |
| Constant pool | Variable | Every name, descriptor, string and number used by the class |
| Access flags | 2 bytes | public, final, interface, abstract, ... as bits |
| This class, super class | 2 + 2 bytes | Constant pool indexes |
| Interfaces | Variable | Constant pool indexes |
| Fields | Variable | Flags, name, descriptor and attributes for each field |
| Methods | Variable | Flags, name, descriptor and attributes; the Code attribute holds the bytecode |
| Attributes | Variable | Extras such as the source file name and record components |
The first eight bytes are easy to read by hand with a DataInputStream. Everything after that depends on parsing the constant pool, which has more than a dozen entry kinds. That is the work the Class-File API does for you.
The Class-File API
| Type | Description |
|---|---|
ClassFile.of().parse(bytes) | It is used to parse a class file into a ClassModel. |
ClassModel | The whole class: thisClass(), superclass(), flags(), fields(), methods(). |
FieldModel | One field: fieldName(), fieldType(), flags(). |
MethodModel | One method: methodName(), methodType(), flags(), code(). |
CodeModel | A method's body; iterating it yields CodeElements. |
Instruction | A code element that is a bytecode instruction, with an opcode(). |
Instructions come in kinds such as LoadInstruction, FieldInstruction, InvokeInstruction and ConstantInstruction. They are a sealed hierarchy, so a pattern-matching switch is the natural way to handle them.
The Program
import java.io.ByteArrayInputStream;
import java.io.DataInputStream;
import java.io.InputStream;
import java.lang.classfile.ClassFile;
import java.lang.classfile.ClassModel;
import java.lang.classfile.CodeElement;
import java.lang.classfile.FieldModel;
import java.lang.classfile.Instruction;
import java.lang.classfile.MethodModel;
import java.lang.classfile.instruction.ConstantInstruction;
import java.lang.classfile.instruction.FieldInstruction;
import java.lang.classfile.instruction.InvokeInstruction;
import java.lang.classfile.instruction.LoadInstruction;
public class MiniJavap {
static class Counter {
private int count;
int next(int step) {
count += step;
return count;
}
static boolean isEven(int n) {
return n % 2 == 0;
}
}
static byte[] bytesOf(Class<?> c) throws Exception {
String file = c.getName().replace('.', '/') + ".class";
try (InputStream in = c.getClassLoader().getResourceAsStream(file)) {
return in.readAllBytes();
}
}
static void header(byte[] bytes) throws Exception {
DataInputStream in =
new DataInputStream(new ByteArrayInputStream(bytes));
int magic = in.readInt();
int minor = in.readUnsignedShort();
int major = in.readUnsignedShort();
System.out.printf("magic %X, version %d.%d (Java %d)%n",
magic, major, minor, major - 44);
}
static String describe(Instruction i) {
String name = i.opcode().name().toLowerCase();
return switch (i) {
case FieldInstruction f -> name + " "
+ f.name().stringValue() + ":" + f.type().stringValue();
case InvokeInstruction v -> name + " "
+ v.owner().asInternalName() + "."
+ v.name().stringValue() + v.type().stringValue();
case LoadInstruction l -> name + " (slot " + l.slot() + ")";
case ConstantInstruction c -> name + " ("
+ c.constantValue() + ")";
default -> name;
};
}
static void print(byte[] bytes) {
ClassModel cm = ClassFile.of().parse(bytes);
System.out.println("class " + cm.thisClass().asInternalName()
+ " extends " + cm.superclass().get().asInternalName());
for (FieldModel f : cm.fields()) {
System.out.println(f.flags().flags() + " field "
+ f.fieldName().stringValue() + " "
+ f.fieldType().stringValue());
}
for (MethodModel m : cm.methods()) {
System.out.println(m.flags().flags() + " method "
+ m.methodName().stringValue()
+ m.methodType().stringValue());
m.code().ifPresent(code -> {
for (CodeElement e : code) {
if (e instanceof Instruction i) {
System.out.println(" " + describe(i));
}
}
});
}
}
public static void main(String[] args) throws Exception {
byte[] bytes = bytesOf(Counter.class);
header(bytes);
print(bytes);
}
}magic CAFEBABE, version 69.0 (Java 25)
class MiniJavap$Counter extends java/lang/Object
[PRIVATE] field count I
[] method <init>()V
aload_0 (slot 0)
invokespecial java/lang/Object.<init>()V
return
[] method next(I)I
aload_0 (slot 0)
dup
getfield count:I
iload_1 (slot 1)
iadd
putfield count:I
aload_0 (slot 0)
getfield count:I
ireturn
[STATIC] method isEven(I)Z
iload_0 (slot 0)
iconst_2 (2)
irem
ifne
iconst_1 (1)
goto
iconst_0 (0)
ireturnHow It Works
bytesOffinds the class's own.classdata through its class loader. A class nameda.b.Cis stored as the resourcea/b/C.class.headerreads the first eight bytes by hand: theintmagic number prints asCAFEBABE, followed by two unsigned 16-bit numbers, the minor and major version. The major version minus 44 is the Java release, here 25.ClassFile.of().parse(bytes)turns the bytes into aClassModel. Names come back in the JVM's internal form with slashes, and types as descriptors:Iisint,(I)Zis "takes anint, returns aboolean".- Fields and methods are listed with their flags. An empty flag set,
[], means package-private with no other modifiers. m.code()is anOptional, because abstract and native methods have no code. Iterating aCodeModelyields instructions and other elements such as labels and line numbers, so the loop keeps onlyInstructions.describeuses a pattern-matching switch on the kind of instruction to print its details: the field forgetfield, the target forinvokespecial, the slot for loads, the value for constants.
Reading the output: the compiler generated the constructor <init>, which only calls Object's constructor. In next, count += step became aload_0, dup, getfield, iload_1, iadd, putfield: dup copies the this reference so one copy is used to read the field and the other to write it. In isEven, n % 2 == 0 became irem followed by ifne, a jump taken when the remainder is not zero.
What Real Tools Add
The real javap also prints byte positions, jump targets, the constant pool, and attributes such as LineNumberTable. The same API can write class files too: ClassFile.of().build(...) generates a class, and transformClass(...) rewrites an existing one. Frameworks use these abilities to create proxies, add logging, and measure code coverage. Before Java 24, tools depended on third-party libraries such as ASM and Byte Buddy, which had to be updated for every new class file version; a standard API inside the JDK always matches the current format.
Common Mistakes
Trying to parse the whole file by hand. The constant pool makes it tedious and error-prone; use the Class-File API.
Expecting every method to have code. Abstract and native methods don't; check the Optional.
Treating every CodeElement as an instruction. Labels, line numbers and local variable entries are code elements too.
Using the API on older Java versions. java.lang.classfile is standard from Java 24; before that, use ASM.
Practice
- Easy: Change
mainso it printsjava.lang.Runnable(useClassLoader.getSystemResourceAsStream("java/lang/Runnable.class")) and observe a method without code. - Harder: Extend
describeso that branch instructions (BranchInstruction) are marked with the word "jump", and print each method's maximum stack size and local count from theCodeAttribute.
Key Takeaways
Was this tutorial helpful?