Guide contents

Lesson 12 of 71

Type Erasure

Java implements generics by erasure: the compiler checks type arguments, then removes them, so the bytecode and the running program see only the erased types. This lesson shows the erasure rules, the bridge methods the compiler adds, and the limits that follow from erasure, with the standard workarounds. It builds on Generic Types, Bounds, and Wildcards.

Topic Consequence
Erasure List<String> and List<Integer> share one class at run time
Bridge methods Synthetic methods keep overriding working after erasure
Raw types Generic type used without type arguments; checks turned off
Heap pollution A variable of a generic type refers to an object of another type
Reifiable types Types fully known at run time; only these work with instanceof and arrays
Type tokens A Class<T> passed in to bring the type back at run time

Example 1: one class at run time

The erasure of a type parameter is its leftmost bound, or Object when it has none. The erasure of List<String> is List. The compiler inserts casts where a value of type T is used as a concrete type.

Java
import java.util.ArrayList;
import java.util.List;

public class OneClass {
    static <T extends Comparable<T>> T larger(T a, T b) {   // erases to Comparable larger(Comparable, Comparable)
        return a.compareTo(b) >= 0 ? a : b;
    }

    public static void main(String[] args) throws Exception {
        List<String> strings = new ArrayList<>();
        List<Integer> ints = new ArrayList<>();
        System.out.println(strings.getClass() == ints.getClass());
        System.out.println(strings.getClass().getName());

        var m = OneClass.class.getDeclaredMethod("larger", Comparable.class, Comparable.class);
        System.out.println("erased return type: " + m.getReturnType().getSimpleName());
        System.out.println("generic return type: " + m.getGenericReturnType());
        String s = larger("a", "b");    // compiler inserts checkcast String
        System.out.println(s);
    }
}
Output
true
java.util.ArrayList
erased return type: Comparable
generic return type: T
b

getDeclaredMethod finds larger by its erased parameter types. The generic signature is kept as metadata in the class file (the Signature attribute), which is why getGenericReturnType still reports T. Reflection can read declared generic types; it cannot read the type argument of a particular object, because objects do not carry it.

Example 2: bridge methods

When a subclass overrides a generic method with a concrete type, the erased signatures differ: Node.setData(Object) and IntNode.setData(Integer). The compiler adds a bridge method, a synthetic setData(Object) in IntNode that casts and calls the real method, so that calls through a Node reference still dispatch to the override.

Java
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Comparator;

public class Bridges {
    static class Node<T> {
        T data;
        void setData(T d) { data = d; }
    }

    static class IntNode extends Node<Integer> {
        @Override
        void setData(Integer d) { super.setData(d * 10); }
    }

    @SuppressWarnings({"unchecked", "rawtypes"})
    public static void main(String[] args) {
        Method[] methods = IntNode.class.getDeclaredMethods();
        Arrays.sort(methods, Comparator.comparing(Method::toString));
        for (Method m : methods) {
            System.out.println(m.getName() + "(" + m.getParameterTypes()[0].getSimpleName()
                    + ") bridge=" + m.isBridge());
        }

        Node<Integer> n = new IntNode();
        n.setData(4);                         // calls the bridge, which calls setData(Integer)
        System.out.println(n.data);

        Node raw = new IntNode();
        try {
            raw.setData("text");              // bridge casts to Integer
        } catch (ClassCastException e) {
            System.out.println("ClassCastException in bridge");
        }
    }
}
Output
setData(Integer) bridge=false
setData(Object) bridge=true
40
ClassCastException in bridge

The Comparator.comparing(Method::toString) call only sorts the methods by name so the output order is fixed; lambdas and method references are taught in Lambdas and Functional Interfaces. The same structure in javap -c IntNode output (representative, JDK 25):

Text
  void setData(java.lang.Integer);
    Code:
         0: aload_0
         1: aload_1
         2: invokespecial #7    // Method Node.setData:(Ljava/lang/Object;)V
         5: return

  void setData(java.lang.Object);
    Code:
         0: aload_0
         1: aload_1
         2: checkcast     #11   // class java/lang/Integer
         5: invokevirtual #13   // Method setData:(Ljava/lang/Integer;)V
         8: return

The second method is the bridge (flags ACC_BRIDGE and ACC_SYNTHETIC, shown by javap -v). Its checkcast is why the raw call with a String fails at the bridge and not inside IntNode.setData. Covariant return types also use bridges.

Example 3: raw types and unchecked warnings

A raw type is a generic type used without type arguments, such as List. Raw types exist for compatibility with pre-Java 5 code. The compiler accepts any element in a raw list and reports an unchecked warning (javac -Xlint:unchecked shows details) when raw values flow into generic variables. The failure shows up later, at the cast the compiler inserted.

Java
import java.util.ArrayList;
import java.util.List;

public class RawTypes {
    @SuppressWarnings({"rawtypes", "unchecked"})
    static void legacyAdd(List list) {
        list.add(42);                          // raw: no check
    }

    public static void main(String[] args) {
        List<String> names = new ArrayList<>();
        names.add("ann");
        legacyAdd(names);                      // an Integer is now in a List<String>
        System.out.println("size = " + names.size());
        Object o = names.get(1);               // no cast to String here, so no error
        System.out.println("element type: " + o.getClass().getSimpleName());
        try {
            String s = names.get(1);           // inserted checkcast String fails
            System.out.println(s);
        } catch (ClassCastException e) {
            System.out.println("ClassCastException at read, not at add");
        }
    }
}
Output
size = 2
element type: Integer
ClassCastException at read, not at add

This state, where a List<String> holds an Integer, is heap pollution. The error appears far from the code that caused it. Collections.checkedList(list, String.class) wraps a list so a wrong add fails at once, which helps when passing generic collections to legacy code.

Example 4: reifiable types, instanceof, and new T[]

A reifiable type is one whose full type is available at run time: primitives, non-generic classes, raw types, List<?>, and arrays of these. List<String> is not reifiable. instanceof and array creation need a reifiable type, so the compiler rejects instanceof List<String> (unless the static type already guarantees it) and new T[n].

Java✗ does not compile
import java.util.List;

public class NoGenericArray<T> {
    T[] make(int n) {
        return new T[n];
    }

    public static void main(String[] args) {
        System.out.println(new NoGenericArray<String>().make(2).length);
    }
}
Output
error: generic array creation

The runnable version shows what does work: instanceof List<?>, pattern checks that the compiler can prove, and Array.newInstance with a Class object.

Java
import java.lang.reflect.Array;
import java.util.ArrayList;
import java.util.List;

public class Reifiable {
    @SuppressWarnings("unchecked")
    static <T> T[] newArray(Class<T> type, int n) {
        return (T[]) Array.newInstance(type, n);   // real String[] at run time
    }

    public static void main(String[] args) {
        Object o = new ArrayList<>(List.of("x"));
        System.out.println(o instanceof List<?>);
        if (o instanceof List<?> list) System.out.println("size " + list.size());

        List<String> strs = List.of("a");
        System.out.println(strs instanceof ArrayList<String>);   // allowed: provable from List<String>

        String[] arr = newArray(String.class, 3);
        System.out.println(arr.getClass().getSimpleName() + " length " + arr.length);
    }
}
Output
true
size 1
false
String[] length 3

strs instanceof ArrayList<String> compiles because a List<String> that is an ArrayList must be an ArrayList<String>; the runtime check is only against ArrayList. List.of returns an internal immutable class, so the result is false.

Example 5: generic arrays inside a class, and @SafeVarargs

A generic container that stores elements in an array declares the field as Object[] and casts on read, as ArrayList does. Casting to T[] and returning the array to callers is unsafe: the real array is Object[], and a caller who stores it in a String[] variable gets a ClassCastException.

A varargs parameter T... creates an array of the erased type, so generic varargs can cause heap pollution and the compiler warns at the declaration. @SafeVarargs (on static, final, or private methods) states that the method does not store into the array or let it escape.

Java
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public class GenericArrays {
    static final class Stack<T> {
        private Object[] items = new Object[2];
        private int size;

        void push(T item) {
            if (size == items.length) items = Arrays.copyOf(items, size * 2);
            items[size++] = item;
        }

        @SuppressWarnings("unchecked")
        T pop() { return (T) items[--size]; }    // unchecked cast, safe: only T is pushed
    }

    @SafeVarargs
    static <T> List<T> listOf(T... items) {      // reads the array only
        return new ArrayList<>(Arrays.asList(items));
    }

    @SuppressWarnings("unchecked")
    static <T> T[] unsafeToArray(T... items) {
        return items;                            // leaks the varargs array
    }

    static <T> T[] pair(T a, T b) {
        return unsafeToArray(a, b);              // creates an Object[] here
    }

    public static void main(String[] args) {
        Stack<String> s = new Stack<>();
        s.push("a"); s.push("b"); s.push("c");
        System.out.println(s.pop() + s.pop() + s.pop());
        System.out.println(listOf(1, 2, 3));

        try {
            String[] p = pair("x", "y");
            System.out.println(p.length);
        } catch (ClassCastException e) {
            System.out.println("ClassCastException: Object[] is not String[]");
        }
    }
}
Output
cba
[1, 2, 3]
ClassCastException: Object[] is not String[]

Inside pair, T is erased to Object, so the varargs call builds an Object[]. The caller's inserted cast to String[] fails. List.of and Arrays.asList are annotated with @SafeVarargs because they only read the array.

Example 6: type tokens and super type tokens

A type token is a Class<T> object passed to a method so it can check or create values of type T at run time. Class.cast gives a checked cast. A Class cannot describe List<String>, so libraries use a super type token: an anonymous subclass of a generic class, whose superclass type argument is kept in the class file and read with getGenericSuperclass.

Java
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class TypeTokens {
    static final class Registry {
        private final Map<Class<?>, Object> values = new HashMap<>();
        <T> void put(Class<T> type, T value) { values.put(type, type.cast(value)); }
        <T> T get(Class<T> type) { return type.cast(values.get(type)); }
    }

    abstract static class TypeRef<T> {
        final Type type;
        protected TypeRef() {
            ParameterizedType sup = (ParameterizedType) getClass().getGenericSuperclass();
            this.type = sup.getActualTypeArguments()[0];
        }
    }

    public static void main(String[] args) {
        Registry r = new Registry();
        r.put(String.class, "config");
        r.put(Integer.class, 8080);
        String s = r.get(String.class);
        int port = r.get(Integer.class);
        System.out.println(s + " " + port);

        TypeRef<List<String>> ref = new TypeRef<List<String>>() {};   // anonymous subclass
        System.out.println(ref.type);
        TypeRef<Map<String, List<Integer>>> ref2 = new TypeRef<>() {};
        System.out.println(ref2.type);
    }
}
Output
config 8080
java.util.List<java.lang.String>
java.util.Map<java.lang.String, java.util.List<java.lang.Integer>>

The Registry is a typesafe heterogeneous container: keys are Class objects, and each value is checked against its key. Jackson's TypeReference and Guava's TypeToken use the super type token pattern.

Example 7: overloading clashes after erasure

Two methods whose parameter types differ only in type arguments erase to the same signature. The compiler rejects them with a "name clash" error, even though the source signatures look different.

Java✗ does not compile
import java.util.List;

public class Clash {
    static int total(List<Integer> xs) { return xs.size(); }
    static int total(List<String> xs) { return xs.size(); }

    public static void main(String[] args) {
        System.out.println(total(List.of(1)));
    }
}
Output
error: name clash: total(List<String>) and total(List<Integer>) have the same erasure

The fix is distinct names (totalInts, totalStrings) or one generic method. The same rule forbids a class implementing both Comparable<A> and Comparable<B>, and catching a generic exception type: a generic class cannot extend Throwable, because catch needs a reifiable type.

Interview points

  • Erasure replaces each type parameter with its leftmost bound (or Object) and inserts casts at use sites; one Class object serves all parameterizations.
  • Declared generic signatures survive in the class file and are readable by reflection; the type argument of a single object is not.
  • Bridge methods are synthetic methods the compiler adds so overriding still works after erasure; their cast is where a raw call with a wrong type fails.
  • Raw types disable generic checks; the resulting heap pollution fails later at an inserted cast, far from the cause.
  • new T[], instanceof List<String>, T.class, and generic exception classes are illegal because these need reifiable types.
  • @SafeVarargs is a promise that the varargs array is not written or exposed.
  • Class<T> type tokens give checked casts and array creation; super type tokens capture full generic types through an anonymous subclass.
  • <T extends Object & Comparable<? super T>> in Collections.max keeps the erased return type Object for binary compatibility.

Summary

  • Generics are checked by javac and erased in bytecode, which kept compatibility with pre-generic code and libraries.
  • Bridge methods, inserted casts, and Signature attributes are the compiler's tools for erasure.
  • Reifiable types: primitives, non-generic types, raw types, unbounded wildcard types, and arrays of these.
  • Generic containers store Object[] and cast on read; returning that array as T[] is unsafe.
  • Methods that differ only in type arguments clash after erasure.