type erasure
what the compiler deletes, what it adds (bridge methods), and why.
- chapter
- chapter 05.05
- reading time
- 8 min read
generics live only in the compiler. javac checks every type argument, then erases them: T becomes its bound (Object if there isn't one), List<String> becomes plain List. to keep things working it inserts casts where values come back out, and writes bridge methods where erasure would break overriding.
the JVM never sees a type argument. that one decision explains almost every weird rule in this part.
the picture
you know how ArrayList<String> and ArrayList<Integer> feel like two different classes? at run time they're the same class, the same object layout, the same bytecode. the difference was only ever a promise the compiler checked for you.
think of an editor proofreading a manuscript with a red pen (javac). they check every sentence against the style guide, fix what's wrong, and then the red notes are thrown away before printing. the printed book (the class file) contains the result of the checking, never the checks themselves. a reader (the JVM) can't tell which sentences were reviewed.
hover a stage.
what javac actually writes
talk is cheap. here's real javap output from javac 25, next to the source it came from:
- 1class Main {
- 2 public static void main(String[] args) {
- 3 Box<String> box = new Box<>("hi");
- 4 String s = box.get();
- 5 System.out.println(s.length());
- 6 }
- 7}
public static void main(java.lang.String[]);Code:0: new #7 // class Box3: dup4: ldc #9 // String hi6: invokespecial #11 // Method Box."<init>":(Ljava/lang/Object;)V9: astore_110: aload_111: invokevirtual #14 // Method Box.get:()Ljava/lang/Object;14: checkcast #18 // class java/lang/String17: astore_218: getstatic #20 // Field java/lang/System.out:Ljava/io/PrintStream;21: aload_222: invokevirtual #26 // Method java/lang/String.length:()I25: invokevirtual #30 // Method java/io/PrintStream.println:(I)V28: return
get hands back an Object (that's its descriptor), and the very next instruction is a checkcast to String. that's the cast you didn't write. javac proved it can't fail, so it's free to insert it.
Object where you wrote T, and the checkcast nobody typed.the rules, all of them:
| in the source | after erasure |
|---|---|
T | Object |
T extends Number | Number |
T extends Comparable<T> & Serializable | Comparable (the first bound) |
List<String>, Map<K, List<V>> | List, Map |
T[] | Object[] |
String s = list.get(0) | String s = (String) list.get(0) |
notice that javap still prints class Box<T> and T get(). that's not a contradiction: javac also stores the generic signatures in the class file as metadata (a Signature attribute), for the compiler and for reflection. the descriptor, the thing the JVM links and runs against, is the erased one. what that metadata is good for is in generics at runtime.
var a = new ArrayList<String>();
var b = new ArrayList<Integer>();
IO.println(a.getClass() == b.getClass());bridge methods
erasure has one sneaky side effect. Comparable<T> declares compareTo(T), which erases to compareTo(Object). your Name implements Comparable<Name> declares compareTo(Name). in the bytecode those are different signatures, so yours doesn't override the interface method at all, and code that only knows it has a Comparable (like Collections.sort) would call a method that doesn't exist.
javac's fix is the bridge method: a synthetic compareTo(Object) that casts its argument to Name and calls yours. it's in the "a bridge method" tab above. reflection can see it too:
import java.lang.reflect.Method;
import java.util.*;
record Name(String value) implements Comparable<Name> {
public int compareTo(Name other) {
return value.compareTo(other.value);
}
}
public class Main {
public static void main(String[] args) {
Method[] ms = Name.class.getDeclaredMethods();
Arrays.sort(ms, Comparator.comparing(Method::toString));
for (Method m : ms)
if (m.getName().equals("compareTo"))
System.out.println(m + (m.isBridge() ? " <- bridge, synthetic: " + m.isSynthetic() : ""));
System.out.println(new ArrayList<String>().getClass() == new ArrayList<Integer>().getClass());
}
}public int Name.compareTo(Name) public int Name.compareTo(java.lang.Object) <- bridge, synthetic: true true
this one needs a real JVM (reflection), so it doesn't run in the browser. copy it into java Main.java to try it.
bridges also show up for covariant return types, generic or not (override Object clone() with Point clone() and javac adds an Object clone() bridge). you almost never notice them, except in stack traces: a frame for a method on a line with no code, or a ClassCastException thrown from a line that's just the class declaration. that's the bridge's cast failing, because something called it through a raw type with the wrong argument.
the rules erasure forces on you
if the JVM doesn't know type arguments, anything that would need them at run time is off the table. javac tells you up front:
if (o instanceof List<String>) { }
// error: Object cannot be safely
// cast to List<String>if (o instanceof List<?> list) {
// a List of something. check
// the elements one by one if
// you need to
}instanceof List<String> would need the object to remember String. it doesn't, so the check is impossible and javac refuses (since java 16 it's allowed when the compiler can prove it's safe anyway, like a Collection<String> tested for List<String>). the same logic kills overloads that differ only in type arguments:
void print(List<String> xs) { }
void print(List<Integer> xs) { }error: name clash: print(List<Integer>) and print(List<String>) have the same erasureafter erasure both are print(List), and a class can't have two methods with the same signature. the rest of the list, new T(), new T[], List<int>, generic exceptions, is in arrays, varargs and other limits.
why do it this way?
because in 2004 there were millions of lines of java using raw collections, compiled against libraries that were about to become generic. erasure meant:
- the JVM didn't change. no new bytecodes, no new object layout, no type arguments to track at run time.
- migration could happen in any order. a library could add type parameters without breaking a single compiled client, because
List<String>and the oldListcompile to the same descriptor. clients could generify before or after their libraries did. - one copy of the code.
ArrayListis one class however many type arguments you use, unlike templates that stamp out a copy per type.
C# made the opposite choice a year later: generics in .NET 2.0 are reified (the runtime knows List<string> from List<int>), which needed a new runtime and a parallel set of generic collection classes. java kept compatibility and paid with everything in the next chapter.
will java ever reify generics?+
not wholesale: it would break the migration story that erasure was chosen for. but project valhalla is working on the biggest limit, primitives. value classes (JEP 401, targeted as a preview for jdk 28) are the first step, and the longer-term plan is to let generic code work over value types without boxing, so a List<int>-like thing stops being a fantasy. what's coming is in valhalla.
- javac checks generics, then erases them. the JVM never sees a type argument.
Tbecomes its first bound,List<String>becomesList.- javac inserts a cast wherever an erased value is read as something specific.
- bridge methods keep overriding working after erasure.
- no
instanceof List<String>, no overloads that differ only in type arguments. - the reason: old code and new code had to run side by side, unchanged.