Everything after the constant pool describes what a class has and what it can do. Both are the same shape — a flag word, a name index, a descriptor index, and a list of attributes — and the descriptor is where the interesting grammar lives.
A descriptor is how the class file spells a type. Not a name, not a reference to the constant pool's idea of a class, but a complete, self-contained type written out in characters. The whole JVM type system fits in about a dozen symbols, and reading them is a skill in its own right — there is nothing else in the format where so much meaning is packed into so few bytes.
It is also where the format stops being a name table and starts describing behaviour, because a method's descriptor says what goes in and what comes out. That is enough for the verifier to type-check a call, to decide whether an override is legal, and to reject a class that claims to implement an interface it does not. All of that from one line of printable ASCII.
The complete type grammar, as verified against three independent readers on a class declaring one field of every kind:
PRIMITIVES one character each
Z boolean B byte C char
S short I int J long
F float D double
V void -- ONLY as a method's return
REFERENCES L, an internal name, and a semicolon
Ljava/lang/String; a class or interface
LDesc$Nested; a nested class, $ separating the parts
ARRAYS one [ per dimension, then the element type
[I int[]
[[I int[][]
[Ljava/lang/String; String[]
[[D double[][]
METHODS an open paren, the parameters, a close paren, the return
()V no parameters, returns void
(I)Z one int, returns boolean
(IJLjava/lang/String;[ILjava/lang/Object;)V
int, long, String, int[], Object; returns void
Five things in that grammar are worth stating outright, because each one is a decision rather than a notation.
unsigned. There is no way to spell “a pointer” because there are no pointers — every object is a reference the runtime manages. And there is no unsigned integer, which is a deliberate language decision that reaches all the way down into the file's type system. Compare WebAssembly's four numeric types, which made the same cut from a different direction.void is a return type only.V can never appear as a parameter, and a constructor's descriptor is ()V because it returns nothing and declares no parameters regardless of the source. A return type of V and a parameter list of zero are two different things that happen to be spelled the same way — which is why a descriptor parser needs to know which position it is in.[ comes before the element type, so int[][] is [[I and not [I[. That is the reverse of C, and the reason is that the descriptor is read left to right into a type with no lookahead: [ is a complete type on its own once you have read what follows it./ and $. A class is Ljava/lang/String; — slashes, not dots — and a nested class is LDesc$Nested;, with a dollar sign. The $ is compiler-generated and not always a nesting, which is the next concept's problem; for now it is a character to expect.List<String> has the descriptor Ljava/util/List; — the type arguments are not there. They live in a separate Signature attribute, and their absence from the descriptor is precisely what makes the descriptor usable by the verifier: the erased type is all the verifier needs.What a class with one field of every type and a handful of methods actually stores. The descriptor column is the entire per-member cost after the name, and it is worth seeing how small it is:
field descriptor z Z b B c C s S i I j J f F d D ref Ljava/lang/String; arr [I jag [[I sarr [Ljava/lang/String; method descriptor m ()V ret ()I args (IJLjava/lang/String;[ILjava/lang/Object;)V manyRet ()J usesNested (LDesc$Nested;)LDesc;
All of those strings came out of the file and all three readers agree on every one of them, name by name and descriptor by descriptor. The longest is 41 characters for five parameters, and the shortest is 3. A field of type boolean costs one byte of type information. There is no per-type overhead anywhere.
The access_flags field is a u2 in all three places it appears — on the class, on each field, on each method — and the three tables are different. That is not a design convenience; it is a genuine trap, and this course's own decoder shipped with a bug in exactly this area.
CLASS bit 0x0001 ACC_PUBLIC
bit 0x0010 ACC_FINAL
bit 0x0020 ACC_SUPER
bit 0x0200 ACC_INTERFACE
bit 0x0400 ACC_ABSTRACT
bit 0x1000 ACC_SYNTHETIC
bit 0x2000 ACC_ANNOTATION
bit 0x4000 ACC_ENUM
bit 0x8000 ACC_MODULE
FIELD bit 0x0001 ACC_PUBLIC
bit 0x0002 ACC_PRIVATE
bit 0x0004 ACC_PROTECTED
bit 0x0008 ACC_STATIC
bit 0x0010 ACC_FINAL
bit 0x0040 ACC_VOLATILE
bit 0x0080 ACC_TRANSIENT
bit 0x1000 ACC_SYNTHETIC
bit 0x4000 ACC_ENUM
METHOD bit 0x0001 ACC_PUBLIC
bit 0x0002 ACC_PRIVATE
bit 0x0004 ACC_PROTECTED
bit 0x0008 ACC_STATIC
bit 0x0010 ACC_FINAL
bit 0x0020 ACC_SYNCHRONIZED
bit 0x0040 ACC_BRIDGE
bit 0x0080 ACC_VARARGS
bit 0x0100 ACC_NATIVE
bit 0x0400 ACC_ABSTRACT
bit 0x0800 ACC_STRICT
bit 0x1000 ACC_SYNTHETIC
Look at what overlaps and what does not. 0x0010 is ACC_FINAL everywhere — consistent. 0x1000 is ACC_SYNTHETIC everywhere. But 0x0008 is ACC_STATIC on a field and a method and means nothing at all on a class, while 0x0002 is ACC_PRIVATE on a field and a method and is not defined for a class. A reader that uses one table for all three will report names that are not wrong so much as meaningless, and the error is invisible because the bits it misinterprets are usually clear.
0x0009 listed as a single entry called ACC_FINAL. That was wrong twice over: 0x0009 is not a bit at all but the combinationACC_PUBLIC | ACC_STATIC, and ACC_FINAL is 0x0010. Every public static method was therefore reported as ACC_PUBLIC ACC_FINAL — two wrong names and one missing. It was caught by running the decoder and javap side by side on the same file and comparing, which is the only kind of check that works: all three readers could have shared the mistake, because a table copied from a wrong source is wrong identically everywhere. The general rule is that a table of names is the one part of a decoder that cannot be validated by agreement, and has to be checked against something outside the set of readers.One attribute deserves separate mention because it is the only place the file stores a value rather than code. A static final field of a primitive or String type gets a ConstantValue attribute whose entire body is a two-byte pool index:
ConstantValue:
u2 constantvalue_index a pool index, and that is the whole attribute
and the pool entry it points at is the value itself:
#20 = Integer 2135567385
#23 = Long 81985529216486895l
#27 = Float 3.5f
#30 = Double -2.718281828459045d
#34 = String #35
#35 = Utf8 hello
No bytes of the value appear in the attribute at all — it is a pointer into the pool like everything else. And note what that buys: the value is shared with every other occurrence of it in the file. Two classes with the same constant do not each store it; they each store an index, and the compiler emits one pool entry. That is the constant pool doing its job one more time, and it is the reason the format needs no separate data section.
The restriction to primitives and String is a language rule rather than a format one, and it is worth knowing why: a compile-time constant must have a value the compiler can write down and the verifier can check. An arbitrary object does not have one, which is why static final List<String> EMPTY = ... is legal Java and gets a <clinit> method that builds it at class-initialisation time instead.
The other two-slot rule. The constant pool gives Long and Double two indices each; the local variable frame does the same thing to the same two types, and for a related reason. Here is every method of a ten-method class, with the slot arithmetic checked against the file:
method descriptor this slots predicted max_locals
a ()V yes 0 1 1
b (I)V yes 1 2 2
c (II)V yes 2 3 3
d (J)V yes 2 3 3 <-- long is TWO
e (JJ)V yes 4 5 5
f (D)V yes 2 3 3 <-- double is TWO
g (IJD)V yes 5 6 6
s (IJD)V no 5 5 5 <-- static: no this
h (Ljava/lang/String;[ILjava/lang/Object;)V
yes 3 4 4 <-- arrays are ONE
Ten methods, ten predictions, ten matches. Three rules produce all of it:
Why 64-bit types take two slots is a register-allocation decision, and it explains the descriptor rule too. The JVM's calling convention passes 32-bit values in one slot and 64-bit values in two, so that a long and two ints both occupy eight bytes and a max_locals can be reasoned about as a frame size. J and D are the only two descriptor characters with this property, and a parser needs to know that before it can walk a parameter list — because (IJ) has three slots, not two, and a tool that assumes one-slot-per-parameter computes a frame size a quarter too small.
And the array case is the one that catches people, including a verification script written for this course: [I is one slot, not two. The [ is part of the type, not a separate thing, and an array of any dimension is a single reference. Getting it wrong inflates the count and makes max_locals look too large, which is a confusing symptom because the real rule is right and only the arithmetic is wrong.
max_locals is not. It is not the number of local variables, and it is not the number the method might use. It is the number of slots the frame needs for the parameters and this, and it is set from the signature before the body is looked at. A method that declares a hundred locals and uses none of them still has max_locals equal to its parameter count plus one. The body's own locals live above that mark and are the verifier's business, not the signature's — which is why adding an unused local to a method does not change the descriptor, and why a tool that rebuilds a frame from the descriptor alone gets the right answer for the parameters and no information about the rest.One more consequence worth flagging for the next concept, because it is where a reader can lose its place: descriptor parsing is recursive and self-delimiting, and the only field that is not one byte per character is the name inside an L...;.LDesc$Nested; is a reference whose name is Desc$Nested, terminated by the first ; — and the name may contain almost anything except that one character. So a descriptor parser cannot be a fixed-length reader; it must scan to the terminator, which is the same walk-to-a-terminator discipline the WebAssembly and PE courses arrived at independently.
$ javap -v -p out/Desc.class
$ python3 courses/jvm/assets/samples/class_decode.py out/Desc.class$ and a digit, which is what a nested or anonymous class gives you.max_locals before you look. Take ten method signatures, compute the slots from the rules, then check against the file. The one you will get wrong is the first long or double parameter, and finding out that you got it wrong by looking is worth more than being told.[ as a separate token and watch max_locals come out too large for any method with an array parameter. Producing a wrong prediction on purpose and seeing exactly how it is wrong is the fastest way to internalise a rule.public abstract class with volatile transient fields and a native synchronized method. Run all three and compare flag by flag — and remember that agreement is not proof, because a wrong table copied three times agrees perfectly.static final int X = 42, and one class with it twice. Compare the pool sizes. The pool is a dictionary, and this is the clearest demonstration of what that buys — the same number appears once however many fields want it.ConstantValue and follow the index. Find the attribute, note the two-byte body, resolve the pool index, and confirm the value. Then change one byte of the value and watch the class still load — which tells you something important about how little a class file promises about its own constants.Without looking back: a non-static method has the descriptor (IJ[D)Ljava/lang/String;. How many local slots does its frame need for parameters and this, and what is the descriptor of the parameter that is a double array?
You are writing a bytecode tool that computes a method's stack frame size for a JNI-style native call. It works on every method except those taking a long or a double, where it hands the native code a frame two slots too small and the process corrupts memory. Everything else in your pipeline is correct, and the affected methods all pass javap -v cleanly. What is the defect, what is the one-line check against the file, and why is it likely that this bug survived testing on a codebase with hundreds of methods?
The general habit: when a bug correlates perfectly with one input type, that type is where the rule you are missing lives. And when a tool is accidentally correct on your test data, enumerate the input space and ask which cases your data happens not to contain.
A descriptor is a type written as a string, and this collection has three other places that does the same thing for the same reasons. A COFF or PE symbol has no type at all — only a storage class — so a linker can check that a symbol exists but not that it is callable. A DWARF type is a graph of DIEs joined by DW_AT_type, so a type is a pointer chase rather than a string. A WebAssembly value type is one byte, with function types in a separate section entirely. The class file descriptor is the only one of the four that puts a complete type in a single self-delimiting string, and that is why it is the only one the verifier can read without a second lookup.
The three-flag-tables decision is the sharpest contrast, because it is a case of a format doing the conventional thing and paying for it. An ELF header has one flags word and a section header has another, and the two are documented separately for the same reason. A PE section's characteristics word is a third table again. Every format with more than one kind of record ends up with more than one flag vocabulary, and the reason is structural: the flags describe the thing, and the things are different. Consolidating them would mean either meaningless bits or an indirection, and both are worse than a second table.
The two-slot rule for 64-bit values is the concept's real finding and it connects to something in the previous one on purpose. A long takes two pool indices and two local slots, for the same underlying reason in both places: 64-bit values are handled as two 32-bit units so that a size can be computed by addition rather than by a special case. The constant pool does it so an entry fetch is uniform; the frame does it so a frame size is uniform. Same decision, applied twice, in a format that has to keep sizes computable everywhere. It is also a good example of a rule that is invisible in the encoding and load-bearing in every consumer — nothing marks a long in the file as occupying two of anything; the rule lives entirely in how a reader counts.
Next: the Code attribute — where max_locals stops being about parameters, and the instructions themselves begin.