A field declared Map<String, List<? extends Number>> has the descriptor Ljava/util/Map;. The type arguments are gone. Not encoded elsewhere in the member — gone from the descriptor, which is the only type the verifier ever sees.
That is erasure, and it is a language decision rather than a format one: the runtime does not know about type arguments, so the file the runtime checks does not either. A consequence follows immediately. Two fields with the same descriptor are indistinguishable to the verifier — a Map<String,?> and a Map<Integer,?> are the same type as far as any check the JVM performs. Type safety at the container's level is enforced by the compiler inserting casts at the call sites, not by the class file recording what was there.
So where did the type arguments go? Into a second attribute, with a second grammar, sitting beside the descriptor. The class file contains two type languages, and the descriptor grammar from Module 2 is the one the machine uses while the signature grammar is the one humans and reflection use. Confusing the two is the most common mistake in reading class files, because they look similar and mean different things.
The signature grammar, and the additions to the descriptor grammar it makes. Everything from Module 2 still works — the eight primitives, L...;, [ — plus six new productions:
UNCHANGED from the descriptor grammar:
Z B C S I J F D V the primitives
Lname; a class
[X an array of X
ADDED by the signature grammar:
Tname; a TYPE VARIABLE, e.g. T
<X;...> type arguments, e.g. <String;List;>
.name an INNER class, e.g. .Entry
^X a wildcard: exactly X
+X a wildcard: ? extends X
-X a wildcard: ? super X
The wildcards are the part with no descriptor equivalent at all, and they are worth understanding as a group. There is no way to write "a list of some unknown type" in a descriptor, because erasure turns List<?> and List<String> into the same Ljava/util/List;. In a signature they are different, and the difference is exactly those three prefixes:
Note what a wildcard costs: one byte, and it changes the meaning rather than adding a type. There is no wildcard object, no extra indirection, no runtime representation. The whole concept is a compile-time constraint and it costs a prefix character in a string. That is close to the cheapest a language feature can be implemented, and it is a good measure of how much a format can be stretched by adding productions to a grammar rather than adding structures.
One field, and both of its type descriptions side by side. The source is Map<String, List<? extends Number>> m; and the file records both:
descriptor: Ljava/util/Map;
Signature: #18
Ljava/util/Map<Ljava/lang/String;Ljava/util/List<+Ljava/lang/Number;>;>;
read the signature as a type:
Ljava/util/Map a Map
< type arguments follow
Ljava/lang/String; the first: String
Ljava/util/List the second: a List
< whose type arguments follow
+Ljava/lang/Number; the first: ? extends Number
>
>
and the SAME nesting written in Java source, for comparison:
Map<String, List<? extends Number>>
Two things in that are worth pausing on, and one of them is a difference nobody expects.
Type arguments are inline, not named.Ljava/util/List<+Ljava/lang/Number;>; is one Utf8 entry containing the whole nested structure. There is no pool index for the inner List, no shared sub-signature, and therefore no deduplication of a repeated type argument — two fields of Map<String,Integer> get two identical signatures, each its own entry. The descriptor grammar has no such nesting so the question does not arise there; here it is a real cost, and it is the price of a grammar that can express an arbitrary type in one string.
And the + is doing exactly one thing: ? extends Number became +Ljava/lang/Number;. Drop the plus and it would mean Number exactly, which is a different program. One byte, and a wildcard becomes an invariant type.
Now the difference nobody expects, and it is in the class declaration rather than a member. A class declared class Gen<T extends Comparable<T> & Cloneable, V> records its own bound in its signature:
T t; field of type variable T
descriptor: Ljava/lang/Comparable; <- the ERASED bound
Signature: #21 // TT;
^ the signature is just T. The bound is NOT here.
the BOUND lives on the class, in the class's own Signature:
class Gen<T extends Comparable<T> & Cloneable, V>
class signature: <T:Ljava/lang/Comparable<TT;>;:Ljava/lang/Cloneable;TV;>Ljava/lang/Object;
| | | | | | |
| | | | | | +-- superclass, erased
| | | | | +------------------- V's bound is Object
| | | | +---------------------- V, unbounded
| | | +------------------------- T's SECOND bound
| | +---------------------------- Ljava/lang/Cloneable
| +--------------------------------------------------- T's first bound
+---------------------------------------------------------- the type parameter
The class signature declares the type parameters and their bounds; a member's signature only uses the names. The field T t has the signature TT; — a single T, referring back to the class's declaration. And the bound Comparable<T> is erased to Comparable in the class's own signature even though it appears in full there, because the bound's own type argument is T and a bound cannot itself be parameterised by the thing it bounds.
That is a subtle and real constraint: <T extends Comparable<T>> records as <T:Ljava/lang/Comparable;> — the self-reference is dropped. It is a consequence of erasure reaching into the bounds rather than stopping at the type arguments, and it is the kind of detail that makes the grammar worth reading properly rather than pattern-matching.
ampersand in the source became a second colon.Comparable<T> & Cloneable is :Ljava/lang/Comparable<TT;>:Ljava/lang/Cloneable; in the file. A class header opens with <, each type parameter is a name, then a colon and its bound, and additional bounds are simply more colons — the intersection is implicit in the repetition. No & appears, and there is no count of bounds. That is a genuinely terse encoding of a concept that needs a fair amount of syntax in source, and it is the clearest example in the format of a grammar being more compact than the language it describes.One list, and the absences are as informative as the presences:
on a FIELD with type arguments Signature
on a METHOD with type arguments Signature
on a CLASS with type parameters Signature
on a METHOD with type PARAMETERS Signature
and ABSENT, always, when there is nothing to add:
a plain int x descriptor I, no Signature
a plain String s descriptor Ljava/lang/String;, no Signature
a field whose type arguments are all concrete and unchanged
A signature is present only when the erased type is a loss. That is the rule, and it is why most fields in most classes have no signature attribute at all. It is also why the attribute is not derivable: a reader cannot tell from a descriptor whether a signature was omitted because none was needed or because the producer was careless, and only the specification's rule for each descriptor tells you which.
One exception worth knowing: generic methods get a signature for their type parameters even when no parameter is generic.public <R> R conv(T in, Function<T,R> f) has a Function parameter, so it would need one anyway — but a method that only declares <R> and uses it nowhere generable still gets a signature, because the type parameter itself is information the descriptor cannot carry. The class's version of the same fact: Gen has a signature because it declaresT and V, regardless of what its members do with them.
Why the file needs two type languages, stated as the reflection query that breaks if you use the wrong one:
That last line is the part that makes the whole design coherent, and it is a deliberate division of labour. The signature is documentation that happens to be machine-readable; the descriptor is the contract. When a generic method's body does String s = list.get(0); on a List<Integer>, the compiler knows from its own analysis that this is wrong, and it rejects the program. If you defeated the compiler — a bytecode library, or a language that compiles to the JVM without generics — and emitted the same get followed by an assignment to a String local, the class file would contain no signature saying so, and the JVM would not care, because the erased types are compatible. Generic type safety in the JVM is a compile-time property enforced by casts, and the file's signature is not part of it.
Which is worth stating carefully, because it is the opposite of what the attribute's existence suggests. It is entirely possible to write a class file that is wrong about its generics and the JVM will load and run it, inserting a checkcast that throws where a correctly compiled program would not. Compare that with the stack map, where a wrong claim is a load-time rejection. One attribute is checked and one is not, and the difference is that the stack map is load-bearing for safety while the signature is not.
The practical consequences for anyone building tools are three, and they are the reason this concept is worth a lesson rather than a footnote. A tool that renames a class must rewrite descriptors and signatures, or reflection will report the old name. A tool that reads a field's type has to choose which of the two it wants, and picking the descriptor by accident is the common bug. And a tool that checks whether two fields have the same type must compare descriptors, not signatures — because two fields with different signatures and the same descriptor are the same type to the machine, and treating them as different is how a verifier-like tool ends up rejecting valid code.
$ javap -v -p out/Gen.class
$ python3 courses/jvm/assets/samples/class_decode.py out/Gen.classList<String> field, and note the descriptor is Ljava/util/List;. That gap is erasure, and seeing it in a file you made is more convincing than any description of it.List<?>, List<? extends Number>, List<? super Integer> and List<String>. All four have the same descriptor; the signatures differ by one byte each. Four programs, one type to the machine, four types to a human — and the byte that separates them is the whole concept.<T extends Comparable<T>> and read the class signature. The bound appears as bare Comparable, with the T dropped, and working out why is the single most interesting thing in this concept.int field next to a generic one and count the signatures. One has it, one does not, and the rule is that a signature exists only when the erased type loses something.Map<String, List<? extends Number>> above is the test: type arguments nest, so a parser that reads one level will mis-handle it. And note that there is no pool index per nesting level — the whole thing is one string, which is what makes a recursive parser necessary.List<String> while the code puts an Integer in and reads a String out, using a bytecode library to emit it. Load it and watch where it fails — at the checkcast, at run time, not at verification. That is the difference between a checked attribute and an unchecked one, made visible.Without looking back: a field is declared java.util.List<? super Integer>. What is its descriptor, what is its signature, and what does the JVM actually check when the field is read and assigned to a Number?
You are writing a tool that finds every field of a given erased type, across a large codebase, to plan a refactoring. It reads each field's descriptor and matches against Ljava/util/Map;. It works. On one module it reports a field of type Map<String, Integer> as not being a Map. The field's descriptor in the file is unambiguously Ljava/util/Map;, the class is valid, and javap lists it as a java.util.Map. What is the defect, and what is the one-line check that distinguishes it from the alternative explanations?
The general habit: in a format that records one thing for humans and another for the machine, know which is which before you debug anything — and when a field of an obviously-correct type is reported as the wrong type, compare both fields side by side before reading any parser.
Two type languages in one file is unusual, and the closest comparison in this collection is DWARF's split between a type's structure and its name. A DWARF DW_TAG_pointer_type has a DW_AT_type naming what it points at, and that name is a string resolved through a lookup rather than written inline. The difference is who resolves it: DWARF's reference is an index to be followed, and the JVM signature's type arguments are inline text to be parsed. One deduplicates a repeated type name, the other makes a type expressible in a single self-delimiting string, and the second is what lets a signature be a flat Utf8 with no graph to walk.
Erasure itself is the more interesting connection, because the JVM is not the only format to record less than the source said. An ELF .debug_info section is a separate artefact precisely because the machine does not need it, and the DWARF that describes a C++ program's types lives outside the sections the program runs from. The JVM takes the opposite approach — the generics are in the executable artifact, in an attribute — which is what makes them available to reflection at run time. Compare C, where sizeof and the mangled name both erase, and where a debugger cannot recover a template argument without a separate debug section. The JVM's choice is more informative and it costs an attribute per generic member.
The wildcard's one-byte encoding connects to something structural rather than grammatical. A WebAssembly reference type carries no lifetime, no variance and no type argument — a funcref is a funcref — and that is the same trade at a smaller scale: the type system is small enough that the machine can check it quickly, and the richness lives outside. Java's answer is to keep the rich type in the file and check the small one, which is why it needs both a descriptor and a signature rather than one richer type. A format with a single type grammar would be simpler to read and would lose either the machine's tractability or the human's information, and the two-grammar design is the price of refusing that trade.
And the class-signature erasure of a self-referential bound is the point where a grammar stops being a convenience. <T extends Comparable<T>> cannot record the T, because the bound is elaborated before T exists as a type. That is a real limit of encoding bounds as types, and it is the same shape as a DWARF type whose definition refers to itself — in both cases the recursion has to be broken, and each format breaks it in the way its data model allows. Java breaks it by erasure; DWARF breaks it with a declaration and a separate definition. The &-to-colon intersection encoding is the visible consequence of choosing the first.
Next: annotations, where a second grammar appears for a third reason — because an attribute has to be able to hold a value of a type that is itself a name, and the encoding has to survive that.