the class file
CAFEBABE, the constant pool, and every byte of a tiny .class.
- chapter
- chapter 13.02
- reading time
- 8 min read
a .class file is a flat run of bytes with a fixed shape: the magic CAFEBABE, a version, a constant pool (every name, type and literal the class uses, numbered), then the class's flags, fields, methods and attributes. almost everything after the pool is a two-byte index into the pool, not the thing itself.
it's small enough to read by hand. so let's.
the picture
you know how .class files feel like opaque blobs that only the JVM is allowed to look at? they're not. they're one of the most readable binary formats around, and javac's output for a tiny class fits on one screen.
think of it like a recipe book with a glossary in the front. the glossary (the constant pool) lists every ingredient and every technique once, numbered. the recipes themselves just say "use #7" instead of spelling it out. that's why the pool is most of the file: the rest is mostly numbers pointing back into it.
here's the class:
public class Point {
int x;
int getX() { return x; }
}compiled with javac -g:source Point.java on jdk 25 (that flag keeps the file name but skips line numbers, so the file stays tiny). the result is 224 bytes. here's every one of them.
every class file starts with these four bytes. the JVM refuses anything else. a joke from the early 90s that is now load-bearing.
0x00ae to find the bytecode of the constructor.see the strip on top? 122 of the 224 bytes are the constant pool. the actual code (both methods' instructions) is 10 bytes.
the shape
the JVM spec defines the whole file as one struct. u2 and u4 are unsigned 2 and 4 byte numbers, big-endian:
ClassFile {
u4 magic;
u2 minor_version;
u2 major_version;
u2 constant_pool_count;
cp_info constant_pool[constant_pool_count-1];
u2 access_flags;
u2 this_class;
u2 super_class;
u2 interfaces_count;
u2 interfaces[interfaces_count];
u2 fields_count;
field_info fields[fields_count];
u2 methods_count;
method_info methods[methods_count];
u2 attributes_count;
attribute_info attributes[attributes_count];
}no padding, no alignment, no offsets table. the only way to find the methods is to walk the pool entry by entry (each tag tells you how long that entry is). that's what the JVM's class file parser does, and it's what javap -v prints for you:
public class Point
minor version: 0
major version: 69
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #8 // Point
super_class: #2 // java/lang/Object
interfaces: 0, fields: 1, methods: 2, attributes: 1
Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#4 = Utf8 java/lang/Object
...
#7 = Fieldref #8.#9 // Point.x:Ithe constant pool is a graph
the pool isn't a flat list of strings. entries point at other entries. getfield #7 in getX names a field, and resolving what that means takes four hops:
- invokespecial in the constructor
- ├──
- │ └── #4 Utf8 java/lang/Object
- └──
- ├── #5 Utf8 <init>
- └── #6 Utf8 ()V
- getfield in getX
- ├── also this_class
- │ └── #10 Utf8 Point
- └──
- ├── #11 Utf8 x
- └── #12 Utf8 I
this indirection is why linking is lazy. the bytes say "the field x of type I in class Point", by name. the first time getfield #7 runs, the JVM looks that up, finds the field's real byte offset inside a Point object, and caches the answer in the runtime constant pool. after that it's a plain memory read. more in class loading.
descriptors
types in a class file are written as compact strings called descriptors. you just saw I and ()V. the full alphabet:
B C D F I J S Z- byte, char, double, float, int, long, short, boolean. (J and Z because L and B were taken.)
Ljava/lang/String;- a class type: L, the internal name with slashes, a semicolon.
[I·[[Ljava/lang/Object;- arrays: one
[per dimension. V- void. only allowed as a return type.
(ILjava/lang/String;)Z- a method: parameters in parentheses, then the return type. this one is
boolean m(int, String).
notice what's not in a descriptor: generic type arguments (List<String> is just Ljava/util/List;, see type erasure) and parameter names. generics survive only in an optional Signature attribute, for reflection and the compiler.
versions
bytes 6 and 7 are the major version. each java release bumps it by one, and a JVM refuses anything newer than itself.
| java | major version |
|---|---|
| 1.1 | 45 |
| 8 | 52 |
| 11 | 55 |
| 17 | 61 |
| 21 | 65 |
| 25 | 69 |
| 27 | 71 |
# Point.class from above, with byte 7 changed from 0x45 (69) to 0x46 (70)
$ java -cp . Runwriting one without javac
since java 24, the JDK can read and write class files itself: the Class-File API (java.lang.classfile, JEP 484) java 24+. here's a class built from nothing, loaded and called, no source code involved:
void main() throws Throwable {
byte[] bytes = ClassFile.of().build(ClassDesc.of("Answer"), cls -> cls
.withMethodBody("get", MethodTypeDesc.of(ConstantDescs.CD_int),
ClassFile.ACC_PUBLIC | ClassFile.ACC_STATIC,
code -> code.bipush(42).ireturn()));
IO.println(HexFormat.of().formatHex(bytes, 0, 4));
Class<?> answer = MethodHandles.lookup().defineClass(bytes);
IO.println(answer.getMethod("get").invoke(null));
}cafebabe 42
this one needs a real JVM (a library class the browser runtime doesn't have), so it doesn't run in the browser. copy it into java Main.java to try it.
on jdk 25 that's a 106-byte class: magic, version 69, a constant pool, one method whose code is bipush 42, ireturn. frameworks that generate classes at runtime (mocking libraries, proxies, ORMs) have always needed a library like ASM or ByteBuddy for this. the JDK itself has been moving its own class generation (lambdas, proxies, records) off an internal copy of ASM and onto this API.
attributes: the extension mechanism+
everything optional in a class file is an attribute: a name (a pool index to a Utf8 like Code), a length, and bytes. Code holds a method's bytecode. LineNumberTable maps bytecode offsets to source lines (stack traces need it). LocalVariableTable keeps local names (javac -g). StackMapTable gives the verifier the type of every stack slot at every jump target, so verification is one linear pass. Signature keeps generics, Record lists record components, NestMembers lists inner classes allowed to touch each other's privates, BootstrapMethods holds the recipes for invokedynamic.
the rule that makes this work: a JVM must silently skip attributes it doesn't recognize. that's how a 1996 format keeps absorbing lambdas, records, sealed types and modules without a redesign.
- magic, version, constant pool, flags, this/super, interfaces, fields, methods, attributes. in that order, no padding.
- the constant pool is most of the file. everything else mostly points into it.
- names stay symbolic until first use. that's why linking can be lazy.
- major 69 is java 25. a JVM refuses a newer one.
javap -vreads it for you. the Class-File API writes it for you.