Every other field in a class file is a number that means “look at this row of the constant pool.” The class's own name is a pool index. A method's name is a pool index. Its descriptor is a pool index. A Code attribute's name is a pool index. There is no field anywhere in the format that holds a name directly.
So the constant pool is not one section among several. It is the table the whole file is made of, and a reader that gets it wrong cannot recover: every subsequent interpretation is a lookup into a structure it has already misread. This is the opposite of, say, an ELF section header table, which indexes sections and whose entries are mostly self-describing. Here the entries are mostly references to each other, so the pool has to be walked before any of it can be read.
And it has one rule that catches everybody exactly once: CONSTANT_Long and CONSTANT_Double occupy two indices, and the second one is permanently unusable. The reason is a good one — it lets a 64-bit constant be loaded with one uniformly-sized index fetch — and the consequence is that the pool's indices are not contiguous, which every assumption about “the next entry” breaks on.
Each entry is a one-byte tag followed by a payload whose size the tag determines. Nothing is length-prefixed — the tag is the length, which is why the pool must be walked entry by entry rather than skipped in one go.
Twenty tags, and the shape of the table is not uniform in an important way. Eleven of the twenty are pure indirections — a Class, a String, a MethodType, a Module, a Package, and the three *ref tags plus NameAndType — and they carry nothing but one or two indices. The other nine carry data: five are the constant types, one is the string encoding, and three are the MethodHandle / Dynamic / InvokeDynamic family that Java 7 and 8 added for lambdas.
So a pool is mostly a graph. this_class points at a Class, which points at a Utf8. A Methodref points at a Class and a NameAndType, and the NameAndType points at two more Utf8 entries. Reading a name means following a chain two or three links long, and a reader that tries to print names while walking the pool — before the later entries have been read — will follow a pointer into a table slot it has not filled in yet.
The two-slot rule, on a real file. Consts.class declares four 64-bit constants, and here is what that does to the pool's numbering. The class is 661 bytes and the pool has a stored count of 52:
#20 = Integer 2135567385 #21 = Utf8 I64 #22 = Utf8 J #23 = Long 81985529216486895l #25 = Utf8 F32 <-- #24 does not exist #26 = Utf8 F #27 = Float 3.5f #28 = Utf8 F64 #29 = Utf8 D #30 = Double -2.718281828459045d #32 = Utf8 S <-- #31 does not exist #33 = Utf8 Ljava/lang/String; #34 = String #35 #35 = Utf8 hello ... #39 = Long -9223372036854775808l #41 = Utf8 NAN2 <-- #40 does not exist #42 = Double NaNd #44 = Utf8 self <-- #43 does not exist
That output is javap's, and look closely at what it does: it simply skips the missing indices. There is no line for #24, no comment, nothing. A reader scanning that listing and assuming consecutive numbering will be wrong by one from #23 onward, and by four by the end. The gaps are visible only as an absence, which is the hardest kind of thing to notice.
So the arithmetic, stated plainly. Four 64-bit constants, four extra indices consumed, and:
stored constant_pool_count 52
index 0 reserved, never valid
usable entries 47 (52 - 1 for the reserved zero,
- 4 for the second halves)
highest index actually used 51
permanently unusable indices 24, 31, 40, 43
All of that was confirmed by three independent implementations, and the third is the interesting one. This course's decoder reports the count, the usable total and the hole list. javap shows the same gaps in its numbering. And the JDK's own standard parsing API — java.lang.classfile, the supported interface the JVM's own parser is reached through — reports cpSize 52, cpUsable 47, cpHoles 4, cpHoleIdx 24 31 40 43.
entryByIndex(24) does not return null, does not return the neighbouring entry, and does not return a plausible wrong answer. It raises ConstantPoolException. That is a stronger position than “there is a gap you had better notice” — the JDK treats an unusable index as a hard error rather than a silent one. Two more details from the same API, both worth having: its ConstantPool.size() is the raw stored count of 52, while iterating it yields only the 47 usable entries, so the iteration length and size() disagree and that disagreement is the two-slot rule seen from outside the format. And a reader is expected to use the raw index space, because that is the index space the file's own fields refer to. The API is not being tidy; it is preserving the file's numbering so that a field read from the file can be used to look up a pool entry unchanged.Worth understanding, because the design looks like a wart and is actually a trade. The alternative would be to give CONSTANT_Long a two-byte tag, or a tag plus an explicit length, and keep one index per entry. The rule instead works like this: a pool entry is fetched with a single index operation of a size the runtime can pick from the tag alone, and a 64-bit value is fetched as one 8-byte unit rather than as two 4-byte halves the runtime has to reassemble. Constant pool entries are the hottest data in the whole format — every field access, every method call, every type check goes through them — so the format spends indices to make the fetch trivial.
The cost is paid by readers, once, in one place: you cannot iterate the pool by incrementing an index. Every loop has to ask how many indices the current entry consumed. Get that wrong and you are off by one from the first Long onward, reading a Utf8's length byte as a tag, and from there producing plausible nonsense — because a Utf8 tag is 0x01 and a length byte is very often a small number, so the desync does not announce itself. It surfaces as a pool entry with a nonsensical tag, at which point a careful reader stops; an unlucky one keeps going.
And there is a second, quieter cost. The pool's size is not derivable from its content, so nothing can be built by streaming: the walk must know, at every entry, whether the next index exists. A reader that wants random access — which is what an index-based design invites — has to carry a bitmap of holes or check a flag on every lookup. For a design whose whole premise is that the pool is a random-access table, that is an awkward cost to pay in the table's primary use case.
Index 0 is never a valid entry, which is what makes the stored count one more than the number of entries. The reason is not arbitrary: zero is what an uninitialised field reads as, and every index in the file is a 16-bit unsigned number where 0x0000 is a perfectly ordinary-looking value. Reserving it means a field that was never set, or was set by a buggy producer, is a detectable error rather than a silent reference to whatever happens to be at the front of the table. The same argument applies to super_class, where 0 is legal and means java.lang.Object for the one class that has no superclass — java.lang.Object itself.
Walking the pool correctly, which is the whole skill, and the one place the two-slot rule has to be honoured:
Two details in that are where implementations actually go wrong, and both are consequences of the same design.
The loop condition is i < cp_count, not <=. The stored count is one past the highest usable index, which is the direct consequence of one-based indexing with a reserved zero. A reader that loops inclusively reads one entry past the end of the pool. Because the pool is immediately followed by access_flags, that "extra entry" is read out of the class header, and since access_flags for a public class is 0x0021, its low byte 0x21 = 33 is not a valid tag — so a strict reader stops, and a lenient one tries to parse 33 as a tag and fails more confusingly further on.
The index advance and the byte advance are different numbers, and keeping them in step is the entire skill. CONSTANT_Utf8 advances the byte cursor by a variable amount and the index by exactly one. Long advances eight bytes and the index by two. There is no single "entry size" to compute, which is why the format makes the tag do the work and why a decoder that assumes fixed-size entries cannot work at all.
Now the failure that this course verified, because it is the most instructive thing in the concept. A reader that advances the index by one always, ignoring the two-slot rule, gets a plausible-looking answer for a while and then stops:
correct: #23 Long, then #25 Utf8 (24 is a hole)
ignoring the rule: #23 Long, then #24
but #24 is the SECOND HALF of the Long's 8-byte
payload -- its high 4 bytes, which for the value
0x0123456789ABCDEF are 01 23 45 67
so the "tag" at #24 is 0x01, which is Utf8
-> the reader believes it has found a Utf8
-> it reads the next two bytes as a length: 23 45 = 0x2345 = 9029
-> it skips 9029 bytes and is now 9000 bytes past the end of a
661-byte file
and the error message, when it finally surfaces, names a tag and an
offset rather than the two-slot rule that actually caused it.
The high half of a Long is usually a small, plausible byte, and small bytes are valid-looking tags. That is why this bug is so much harder to catch than an off-by-one in a fixed-size table: the first symptom is nine kilobytes downstream, at an offset that has nothing to do with the mistake. The general habit is the one this course has now established twice, in two unrelated formats: when a decoder reports a wild offset, the bug is usually a few records earlier, and the arithmetic that produced the wild number is the place to look rather than the position it was found at.
$ javap -v -p out/Consts.class
$ python3 courses/jvm/assets/samples/class_decode.py out/Consts.class
$ python3 courses/jvm/assets/samples/crosscheck.py classes/Consts.classConsts.class, list every Long and Double index, and write down what you think the next index is after each. Then check against the real listing. Four constants, four holes, and the arithmetic is one subtraction — do the arithmetic before being told it, and the rule sticks permanently.static final long and nothing else numeric. The pool should have one unusable index. Minimal cases are where a rule is easiest to see and easiest to get wrong, because there is nothing to hide behind.Consts.class. Two of them report the holes; javap only implies them by skipping. Then write the three-line program that asks the JDK API for index 24 and catches the exception — that is the strongest evidence in the course, and it is four lines long.Methodref in Hello.class and follow it: Methodref to a Class to a Utf8, and a NameAndType to two more Utf8 entries. Count the hops. That chain is why a naive disassembler cannot print a method name until the entire pool has been read — and the reason a pool walker that prints as it goes produces nonsense.Without looking back: a class file's constant_pool_count is 52, and the pool contains four CONSTANT_Long or CONSTANT_Double entries. How many usable entries are there, which indices are unusable, and what happens to a reader that starts its loop at index 0?
Your class file reader handles nine of your project's hundred classes. The other ninety-one produce an error like unknown constant pool tag 1 at index 0x1D4, and the offset is far past the end of the file. The nine that work have no 64-bit constants anywhere in their constant pools. The ninety-one that fail each have at least one static final long. What is the defect, why does the error message point at the wrong thing, and what is the general habit that would have found it before ninety-one classes failed?
The general habit: when a bug correlates perfectly with one syntactic feature of the input, that feature is the cause until proven otherwise — and the smallest sample containing one instance of it is the fastest test available, by a wide margin.
The two-slot rule is a run-length encoding in a place where nothing is repeated, and that is worth seeing as a category rather than a curiosity. A COFF auxiliary record lets one 18-byte symbol stand for several by carrying a count; a WebAssembly local declaration group does the same for a run of same-typed locals. Both save space by declaring a multiplicity. This rule saves no space at all — a Long occupies eight bytes either way — and spends a pool index to buy a cheaper fetch. Same shape, opposite purpose, and the difference is instructive: the multiplicity there compresses data, while here it aligns an access pattern. A format is full of encodings that exist for a machine rather than for a file size, and this is the cleanest example in the collection.
The index-as-everything design is the class file's answer to a question a PE file answers differently. In a PE, a call target is a relative displacement patched into the instruction, and the symbol it referred to lived in a separate directory. In a class file there is no patching at all: an invokevirtual carries a pool index and the name is simply looked up. Nothing is relative, nothing is patched, and there is no linker — which is why a class file is self-contained and why the only renumbering that happens is at compile time, as the previous concept showed.
The one-based indexing with a reserved zero has a close cousin in DWARF, where index 0 in a .debug_str or a section table is conventionally a sentinel meaning "absent" rather than "the first thing". Both formats use a zero that means nothing in a space where zero would otherwise be a perfectly good value, and both do it so that an unset field is detectable. The WebAssembly mutability byte makes the same move more explicitly — 0x00 and 0x01 for no and yes, with nothing else legal — and pays for the explicitness with a reader that accepts only two values.
And the "pool is a random-access table, which is awkward" tension is the same one that an ELF section header table creates and solves differently. ELF's table entries are fixed-size and self-locating, so a reader can jump to any one; the class file's entries are variable-size and mostly references to each other, so a reader must walk the whole thing before it can resolve anything. ELF optimises for random access and the class file for compactness, and the two-slot rule is a small, concrete instance of the compactness choice costing something at the other end. Nothing in the format is arbitrary; it is a series of trades, and this one is unusually easy to see.
Next: modified UTF-8 — the encoding the Utf8 entries use, which is not the one it is named after, and which is the only place in the format where a text encoding rather than a number is the thing to get right.