The class file has no section table. There is no list of blocks, no id byte telling you what follows, no index to build first. Every field is at a fixed position given the ones before it, and a reader walks the structure from the top knowing exactly what comes next at every step, all the way to the last byte.
That is a genuinely unusual design and it has one dominant consequence: the file is parsed, never indexed. There is no random access. You cannot jump to the methods without walking the pool, the fields, and everything between, because the only way to find the methods is to count your way there. A format with a section table lets a reader do that in two steps; this one makes it one long step.
The second consequence is less obvious and more annoying in practice. Adding a name renumbers the file. The constant pool is built in the order the compiler encounters things, so insert one string literal in the middle of a method and every entry after it moves. Nothing in the file refers to a name by position in a stable way, so any tool that caches a byte offset into a class file — a build system, an incremental compiler, a coverage tool — has to throw that cache away when the class changes at all.
The whole structure, with the sizes that are fixed and the ones that are not:
Only the first eight bytes are at a known offset. Everything after the pool's count is a walk, and the walk works because every variable-length record states its own length:
That attribute_length is the single most important field in the format after the magic, and it is worth understanding what it is for. It is the format's only extension mechanism. A reader that meets an attribute name it does not know skips attribute_length bytes and carries on — so the JVM can read a class file containing an attribute invented after the JVM shipped, and ignore it. A new attribute is therefore free and needs no version bump.
What the length does not buy is a new top-level block. There is nowhere to put one, because the order is fixed and the reader has no way to skip a block it does not recognise. That is the bargain: attributes are open, the file is not. Java has shipped sealed classes, records and modules by adding attributes and, where the change was too big for that, by adding new attributes and bumping the major version — which is why the major version is the Java release number rather than a format counter that could have moved independently.
The 409-byte Hello.class from this course's samples, walked from the top. Every number below is from the file on disk, and the offsets are given so you can check them against a hex dump:
0x0000 4 magic ca fe ba be 0x0004 2 minor_version 00 00 0x0006 2 major_version 00 46 = 70, Java 26 0x0008 2 constant_pool_count 00 1d = 29 0x000a the pool, 28 usable entries, ending at 0x0124 0x0125 2 access_flags 00 21 ACC_PUBLIC ACC_SUPER 0x0127 2 this_class 00 15 #21, which is "Hello" 0x0129 2 super_class 00 02 #2, "java/lang/Object" 0x012b 2 interfaces_count 00 00 0x012d 2 fields_count 00 00 0x012f 2 methods_count 00 02 0x0131 ... two method records 0x018f 2 attributes_count 00 01 0x0191 ... SourceFile 0x0199 end of file: exactly 409 bytes
Three things in that walk are worth stopping on.
The pool's count is one more than the number of entries. The file says 29 and there are 28 usable entries, because the pool is one-indexed: index 0 is reserved and never used, so a stored count of n means entries 1 through n-1. A reader that allocates n entries and loops to n will read one past the end, and a reader that treats index 0 as a valid entry — the way a C array naturally invites — will resolve a name to whatever happens to be at offset zero of the table.
Indices in the header are pool indices, and they are the pool's own numbering.this_class is 0x15 = 21, and entry 21 is a CONSTANT_Class pointing at entry 22, which is the Utf8"Hello". So the class's own name is two indirections from the field that names it. There is no shortcut to a name anywhere in this format; everything goes through the pool, always.
And the pool is most of the file. 28 entries occupy bytes 0x0a to 0x0124 — 283 bytes of 409, which is 69% of a hello-world. The code that actually does the work is 14 bytes across two methods. The ratio is the argument for the whole design: a format that names everything by index into one shared table pays for the table once and gets compression everywhere else, and on real code the table is where the bytes are.
Worth being precise, because the intuition is usually wrong in both directions. The major version is not incremented when the file format changes — it is the Java feature release that produced the file, and the format's own history is welded to it. Major 52 is Java 8 and introduced the invokedynamic-based lambda implementation; every major number since has been a Java release. So a class file's major version tells you two things at once: which features the class might use, and which JVM can run it.
And the compatibility rule is absolute, not graded. A JVM reads the major version, checks it against what it implements, and if it is too new the class is refused before anything else is read. There is no partial support, no "I can run the methods but not the constant pool", and no attempt to read what it recognises. Compare an ELF file with an unfamiliar machine type, which a loader will usually attempt anyway, or a PE file with an unfamiliar Magic, where the same is true.
0xFFFF, which is a promise that this file will only load in the exact JVM build that compiled it.Here is the practical consequence, and it is worth seeing concretely rather than being told about. Take Hello.class and add one string literal to the middle of main. The compiler encounters the new string while walking the method, so it lands in the middle of the pool, and every entry after it moves up by one. The Utf8 that was #24 is now #25, and so is everything else.
Nothing in the file is broken by this — every reference is updated consistently, because the compiler writes the file after it has finished building the pool. But it means the byte offset of any given name is not a property of the name. It is a property of this compilation of this source. Any tool that recorded "the string hello is at offset 0x1D4 in this class" has recorded something true only until the class is recompiled from anything but identical source. That is why incremental build systems keyed on class files are fragile, and why the tools that do it key on a hash of the content instead.
Reading the file to the last byte, which is the discipline that catches everything: after the walk, the cursor must be exactly at the end. Not past it, not short of it.
That final assertion is the one that earns its keep, and it is the same habit the WebAssembly course arrived at independently. A structural reader that does not check where it ended cannot distinguish a correct parse from a parse that lost sync two hundred bytes ago. The two produce the same output — plausible names, plausible indices — and the only difference is that one of them is wrong. An end-of-file assertion turns that class of bug from silent into loud, and it costs one comparison.
It is worth being concrete about what desynchronisation looks like here, because this format makes it unusually easy. Every count in the header is a u2, so a reader that misjudges a length will read a number that is very often a plausible small integer. A method's attributes_count is typically 1 or 2. A misread that turns into 1 or 2 is not going to be noticed — the walk continues, reads two more six-byte attribute headers, and arrives at a position that is wrong by twelve bytes. The names it reports afterwards will still be pool indices, and most pool indices resolve, because a pool is large and most of its entries are strings.
$ javap -v -p out/Hello.class
$ python3 courses/jvm/assets/samples/class_decode.py out/Hello.classpayload_size for one tag so it is one byte short, and run it on a real class. Then fix it. Watch how much of the output still looks correct — that is the whole argument for the assertion, and it is much more convincing when you have seen it than when you have read it.Without looking back: a class file's constant_pool_count is 29, and its methods_count is 2. How many usable constant pool entries are there, why is that number not 29, and what does a reader that allocates 29 slots and loops i = 0; i < 29 actually do?
Your class file reader reports a plausible method count and a plausible list of method names for a 40 KB class, and the names are real method names. The Code attributes it then decodes contain instruction bytes that disassemble to nothing sensible. The end-of-file assertion passes. Nothing else is wrong: the header, the pool and the member counts are all correct. What is the most likely defect, and why does the fact that the end-of-file assertion passing narrow it sharply?
The general habit: an assertion that passes is evidence, not just absence of evidence. "The walk ended in the right place" narrows the search space more than any amount of staring at the values, because it eliminates every bug whose mechanism is moving the cursor.
The fixed, self-delimiting layout is the same idea as a PE section table reached from the opposite direction, and the comparison is worth making carefully because the two formats have made opposite bets on the same problem. A PE file has a header, then a table of section headers, each carrying a name, a virtual size and a raw-data pointer. Nothing after the table is at a fixed offset, and a loader that wants the import directory must find it in the table first. The class file has no table and no directory; everything is where counting says it is. PE buys flexibility at the cost of two passes; the class file buys one pass at the cost of inflexibility. The second cost is the one that hurts in practice, and it is why a format with a table can grow a new section without a new version while this one cannot.
The escape hatch is the interesting part, because it is a third option neither of those formats uses. An attribute is a name plus a length, and a reader that does not recognise the name skips the length. That is COFF's auxiliary record idea with the variability pushed into a length field, and it is why a class file can carry an attribute the JVM has never heard of. The parallel to DWARF's versioned tag ranges is exact in the other direction: DWARF reserved whole numeric ranges per version so a new tag could not collide, while the class file uses a name and gets collision-freedom for nothing. DWARF pays in tag space, the class file pays in a version number, and both avoid the problem of a new record colliding with an old one.
The renumbering behaviour has no direct counterpart in the other formats here, and that is because of what a constant pool is for. An ELF symbol table is an array too, and adding a symbol renumbers it — but nothing inside an ELF file refers to a symbol by index, so the renumbering is invisible to the rest of the file. In a class file, essentially every field is a pool index, so renumbering rewrites the whole file. The class file's pervasive indirection is what buys it the small code size, and it is also what makes the file unstable under recompilation — the same decision producing both the compression and the cache-hostility, and neither being separable from the other.
Next: the constant pool — twenty tags, and the two-slot rule that makes some indices permanently unusable.