The header concept made a point that looked like a limitation: the class file has no section table, no id byte, no way to add a new top-level block. Everything is at a fixed position, and a reader that meets something unfamiliar has nowhere to put it.
And yet the format has absorbed generics, lambdas, enums, annotations, inner-class access, records, sealed types, modules, and varargs — features nobody foresaw, several of which changed the language's semantics. All of that went in through one 8-byte header that is repeated everywhere: a name index and a length.
This concept is about that header, because it is the most consequential design decision in the format and it is almost invisible. An attribute is the only part of a class file a reader is allowed to skip without understanding. That single permission is what made the format extensible, and it is why the class file version could be pinned to the Java release number rather than moving on its own.
Every attribute in the format, without exception, has this shape:
Six bytes of overhead and a length-delimited body whose structure the attribute itself defines. That is the whole mechanism, and the last line is the part that catches people: there is no common body format. There is not even a common first field. Verified on four attributes in this course's own samples:
SourceFile 4 + 2 bytes the body is ONE u2: a Utf8 index
NestHost 4 + 2 bytes also ONE u2 -- no count, no list
PermittedSubclasses 4 + 4 bytes u2 count, then that many u2 indices
00 02 00 0a 00 08 count 2, then #10 and #8
NestMembers 4 + 12 bytes u2 count, then that many u2 indices
00 06 00 1b 00 1d 00 1f
00 21 00 12 00 07 count 6, then six indices
Record 4 + 20 bytes u2 count, then per component:
u2 name, u2 descriptor, u2 attrs_count
00 03 00 0e 00 0f 00 00 count 3
00 12 00 13 00 00 "x" "I"
00 16 00 17 00 00 "name" "Ljava/lang/String;"
Every one of those bodies is a different shape, and a reader that guessed would be wrong on at least one.SourceFile and NestHost are a bare index with no count — treating them as a counted list reads the following attribute's name index as a count. PermittedSubclasses and NestMembers look identical and mean different things, one naming classes a type may be extended by and the other naming classes inside a nest. And Record nests a third level, because a component can itself carry attributes.
That is the cost of the design, and it is worth being honest about: the format gives you a container and no contents. A tool that wants to read RuntimeVisibleAnnotations must implement the annotation grammar, and there are roughly forty attribute bodies to implement before it understands the format. Compare a PE data directory, where adding a table means adding a directory entry and the loader finds it by index — and compare a WebAssembly custom section, which is the same escape hatch with the same property.
Where the attributes appear, and that is not a single place. There are five distinct places an attribute can appear, and they are not interchangeable:
on the CLASS SourceFile, InnerClasses, NestHost,
NestMembers, Signature, Record,
PermittedSubclasses, BootstrapMethods,
RuntimeVisibleAnnotations, Deprecated,
Module, EnclosingMethod
on a FIELD ConstantValue, Signature, RuntimeVisibleAnnotations
on a METHOD Signature, Exceptions, Deprecated,
RuntimeVisibleAnnotations, AnnotationDefault,
MethodParameters
INSIDE a Code attribute LineNumberTable, LocalVariableTable, LocalVariableTypeTable,
StackMapTable, RuntimeVisibleTypeAnnotations
-- and these nest one level deeper
than everything else
on a Record COMPONENT Signature, RuntimeVisibleAnnotations
That fourth row is the one that costs a reader work, and the previous concept already hit it. A method's attributes are siblings of its Code attribute, but a method's code attributes are nested inside Code — because a line number or a stack map is meaningless without the code it describes. So "read a method" means two attribute lists at different depths, and a reader that treats them as one will read the code's nested attributes as the method's and desynchronise.
And the fifth row is newer than the format's original design. A record component carries attributes, which means the attribute mechanism is recursive: a record component's attribute can be a Signature, which is a Utf8 index, but the structure around it is the same container. The recursion terminates because the body formats do not themselves contain attribute lists at arbitrary depth — only Record and Code nest, and both are one level.
The six bytes of overhead are a pool index and a length, and the choice of a name rather than a number is what makes the mechanism work. An id would be smaller — two bytes either way, so actually no saving — but it would need the specification to allocate it, and the specification cannot allocate an id for a feature that does not exist yet. A name needs no permission.
That last line is measurable, and it is worth measuring because it is the format's only recurring overhead. Across the twenty-four class files in this course's samples, the constant pool contains SourceFile twenty-four times, InnerClasses fourteen, NestHost eight, BootstrapMethods four, Signature three, Record three, and one or two of most others. The name is a Utf8 entry and the pool deduplicates identical strings, so twenty-four classes cost one SourceFile string, not twenty-four. The dictionary earns its keep here exactly as it does for class and method names.
attribute_length and steps over that many bytes. There is no way for a new attribute to make an old reader misparse the file, because the old reader never interprets the body. Compare that with a new instruction opcode, which the verifier must understand or reject the whole class, and with a new class file version, which the loader refuses outright. The format is closed where it executes and open where it describes, and that split is the single most important thing to understand about how it grew.Skipping is easy; noticing is not, and a real tool has to do three things a skipping reader does not. It has to collect the distinct attribute names across a whole corpus to know which bodies to implement. It has to reject a malformed body of a name it does recognise, because the skip rule means an unrecognised body is invisible but a recognised one with a bad length is a real error. And it has to know which of the five positions an attribute is legal in, because the JVM does check that: a ConstantValue attribute on a method is rejected, and so is a LineNumberTable outside a Code.
That last check is the one a hand-rolled reader discovers the hard way, and it is a good example of the difference between "can skip" and "may be present". The format has a table of which attribute names are valid in which of the five positions, and the specification is the only place that table exists — it cannot be derived from the bytes, because a class file with a ConstantValue on a method is not malformed, it is illegal, and only a reader that knows the table can tell the difference.
What a tool that wants to be useful has to implement, and the ordering that makes it possible. The lists are measured, not guessed:
Two things fall out of those numbers, and both are about what is optional.
LineNumberTable appears on all 75 Code attributes without exception, because javac emits debug information by default. That means a class file is systematically larger for being debuggable, and the flag that turns it off (-g:none) removes a nested attribute from every method at once. It also means the attribute is present by default and its absence is the unusual case — which inverts the intuition from a native format, where line tables are in a separate section and are usually stripped from a release build.
And SourceFile is on all 24 classes, which sounds like padding until you remember what it costs: a two-byte body, and the SourceFile string is a pool entry that only exists because of it. Remove SourceFile and the pool shrinks, because the source filename was never referenced by anything else in the file — the class file does not need to know its own name. A stack trace can therefore name a class it cannot locate on disk, which is exactly what happens with obfuscation, and it is a small illustration that a format's contents are driven by what something needs rather than by what a human might expect to find.
That gives the shape of a real implementation. Collect the names first, then implement bodies in order of how often they occur — which for a general tool means Code and SourceFile and LineNumberTable first, because those cover essentially every class, and the exotic ones last or never. A tool that skips unknown attributes will still load every class ever compiled; a tool that only understands known ones and errors otherwise will fail on the first class from a newer compiler. Skipping is the behaviour that makes a reader future-proof, and it is available for free.
$ javap -v -p out/Rec.class
$ python3 courses/jvm/assets/samples/class_decode.py out/Rec.classlib directory and confirm it never desynchronises. That is the whole extensibility mechanism, and it is a dozen lines — write it before you write anything else and every attribute you do not understand costs you nothing.SourceFile, NestHost, PermittedSubclasses and Record and write a reader that assumes they are all counted lists. Watch SourceFile read the next attribute's name index as a count, and work out what number it produces and why it looks plausible.ConstantValue attribute from a field to a method by hand, fixing every length. It will be structurally perfect and the JVM will still reject it, and the rejection is the only thing that reveals the position table exists.-g:none difference. Compile something with and without it and compare file sizes and attribute counts. All 75 Code attributes here carry a LineNumberTable by default, and seeing what that is worth in bytes makes the nesting feel less like free.Without looking back: a class file has a SourceFile attribute whose body is 2 bytes, followed immediately by a Deprecated attribute. A reader that assumes every attribute body is a counted list of two-byte entries will do what, and why will nothing tell it is wrong?
You are writing a class file inspector for a build tool. It must handle every class a large project compiles, including classes from dependencies built years apart with different compiler versions. It currently parses SourceFile, Code and LineNumberTable, and errors on any attribute it does not recognise. On a dependency built by a newer JDK it fails with unknown attribute: RuntimeTypeAnnotations, having loaded every other dependency in the project. What is the defect, and what is the one change that makes the tool correct for inputs you have not seen yet?
The general habit: at any length-delimited extension point, make the unknown case the default path and the recognised case the special one — because the recognised set is what changes and the unknown case is what must not. Still validate the length; just do not validate the name.
The escape hatch is not unique to the class file, and the collection has the whole spectrum. A WebAssembly custom section is the identical mechanism at file scope: a name, a length, and contents the core specification does not define. DWARF has a versioned form of the same idea, where tag ranges are reserved per version so a new record cannot collide with an old one. The class file's contribution to the family is using a name instead of a number, which removes the need for the specification to know in advance. That is a strictly weaker guarantee bought for a strictly larger cost, and it is the right trade when the set of writers is open-ended and the set of readers is not.
The contrast with the instruction side is the sharpest thing in this concept, and it is worth restating because it explains the format's whole shape. The instruction stream is closed and the attribute list is open. An unknown opcode makes a class unloadable; an unknown attribute makes it merely unreadable in part. That is not an accident of history — it is a deliberate asymmetry, and it is the same asymmetry as a PE's optional header being versioned while its sections are not, or ELF's section table accepting an unknown type while its e_type does not accept an unknown machine. Formats are open where a reader can safely step over something and closed where a reader must understand it to be correct.
The five attribute positions connect to the Code attribute's nesting, and the rule that produced them is worth naming: an attribute lives where the thing it describes lives. A line number describes a code offset, so it goes inside the code. A source filename describes the class, so it goes on the class. A record component's type arguments describe the component, so they go on the component — which is a position that did not exist until Java 16 and was added without changing the mechanism. The container did not need extending; only the table of valid names and positions did, and that table lives in a specification rather than in the bytes.
Which brings up the honest limit, and it is the reason a class file writer is harder than a class file reader. Reading the format is a few hundred lines; writing one requires implementing the bodies, honouring the position table, and computing things a reader is allowed to skip — stack maps above all, as the next-but-one concept showed. The escape hatch made the format extensible without making it easy, and the two are related more than they look: a mechanism that is trivial to skip and hard to fill is exactly a mechanism that lets old readers survive and forces new writers to work. Next: the InnerClasses attribute, which is the first one where the position table and the flag table both surprise you.