Back to course

The Signature Attribute

20 minadvancedModule 3: The Attribute MechanismContinues from jvm-inner-classes
20 min · Module 3: The Attribute Mechanism · Algorithms

Why This Matters

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.

A Simple Model

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:

^X ? exactly X. Unbounded. The type argument is irrelevant to this use, and that is the point. +X ? extends X. This use needs no MORE than X. -X ? super X. This use needs no LESS than X. and a bare X, with no prefix, means the argument is EXACTLY X -- which in a parameter position means an invariant type, and in a field means the declared type.

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.

The Actual Detail

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.

The 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.

Where signatures appear, and where they do not

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.

A Real Example

Why the file needs two type languages, stated as the reflection query that breaks if you use the wrong one:

the query: "what is the declared type of this field?" getType() on a Field returns the ERASED type. a field declared Map<String, List<? extends Number>> returns java.util.Map getGenericType() returns the SIGNATURE type. the same field returns java.util.Map<java.lang.String, java.util.List<? extends java.lang.Number>> and the reason both exist: erased type -> what the JVM checks, and what a cast uses signature -> what a compiler or a tool wants to know the compiler does NOT use the signature to type-check anything. It generates the signature and then type-checks against the ERASED types, inserting checkcast instructions at the boundaries.

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.

Try It Yourself

$ javap -v -p out/Gen.class
$ python3 courses/jvm/assets/samples/class_decode.py out/Gen.class

Check Your Understanding

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?

Apply It

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.

Connect

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.