Fundamentals 25 min read

Java Generics Erasure Deep Dive: What Gets Erased, Bridge Methods, and Runtime Type Recovery

This article explains Java generics erasure rules, why erasure exists for backward compatibility, how the compiler inserts checkcast instructions, the purpose and mechanics of bridge methods, how generic signatures survive in class files via the Signature attribute, and how reflection and TypeToken patterns recover type information at runtime.

Java Tech Workshop
Java Tech Workshop
Java Tech Workshop
Java Generics Erasure Deep Dive: What Gets Erased, Bridge Methods, and Runtime Type Recovery

What Is Generics Erasure?

Java generics are "pseudo-generics" — type parameters exist only at compile time. After compilation, all generic types become their raw types. The classic proof:

List<String> stringList = new ArrayList<>();
List<Integer> integerList = new ArrayList<>();
System.out.println(stringList.getClass() == integerList.getClass()); // true
System.out.println(stringList.getClass()); // class java.util.ArrayList
List<String>

and List<Integer> share the same ArrayList.class at runtime; <String> and <Integer> are erased. This is why List<String>.class is illegal syntax — only List.class exists.

Erasure was chosen for backward compatibility: pre-JDK 5 raw-type code must run on JDK 5+, no JVM changes required, and generics incur zero runtime overhead. Contrast with C#'s reified generics, which retain type information at runtime but lacked Java's compatibility burden.

Erasure Rules

Rule 1: Unbounded Type Parameter → Object

// Source
public class Box<T> {
    private T value;
    public T getValue() { return value; }
    public void setValue(T value) { this.value = value; }
}
// After erasure
public class Box {
    private Object value;
    public Object getValue() { return value; }
    public void setValue(Object value) { this.value = value; }
}

Rule 2: Bounded Type Parameter → Upper Bound

// Source
public class NumberBox<T extends Number> {
    private T value;
    public T getValue() { return value; }
    public void setValue(T value) { this.value = value; }
}
// After erasure
public class NumberBox {
    private Number value;
    public Number getValue() { return value; }
    public void setValue(Number value) { this.value = value; }
}

Rule 3: Multiple Bounds → First Bound

// Source
public class MultiBox<T extends Number & Comparable<T>> {
    private T value;
    public T getValue() { return value; }
}
// After erasure
public class MultiBox {
    private Number value; // T → first bound Number
    public Number getValue() { return value; }
}

The first bound is the primary type; additional bounds are "extra capabilities." The compiler inserts casts when calling methods from secondary bounds.

Rule 4: Wildcards → Upper Bound (or Object)

<?>

(unbounded) →

Object
<? extends Number>

Number
<? super Integer>

Object (lower bound doesn't affect erasure)

// Source
public void process(List<? extends Number> list) {
    Number n = list.get(0);
}
// After erasure
public void process(List list) {
    Number n = (Number) list.get(0); // compiler inserts cast
}

Rule 5: Generic Method Type Parameters Also Erased

// Source
public <T extends Comparable<T>> T max(T a, T b) {
    return a.compareTo(b) > 0 ? a : b;
}
// After erasure
public Comparable max(Comparable a, Comparable b) {
    return a.compareTo(b) > 0 ? a : b;
}

Compile-Time Work: Type Checking + Cast Insertion

Compile-Time Type Checking

List<String> list = new ArrayList<>();
list.add("hello"); // ✅ compiles
list.add(123);     // ❌ compile error: int cannot convert to String

This check is compile-time only. Reflection can bypass it:

List<String> list = new ArrayList<>();
list.add("hello");
Method addMethod = List.class.getMethod("add", Object.class);
addMethod.invoke(list, 123); // runtime succeeds
System.out.println(list); // [hello, 123]
String s = list.get(1);   // ClassCastException at runtime!

Compiler-Inserted checkcast

When assigning a generic method's return value, the compiler emits a checkcast bytecode instruction:

// Source
List<String> list = new ArrayList<>();
list.add("hello");
String s = list.get(0); // no explicit cast
// Bytecode
invokevirtual java/util/List.get (I)Ljava/lang/Object;
checkcast java/lang/String  // ← compiler-inserted cast
astore_2

Use javap -c to see the checkcast. If reflection inserted an Integer, the checkcast throws ClassCastException. Type safety is compile-time; runtime relies on these inserted casts.

Bridge Methods

Why Bridge Methods Are Needed

Consider a generic interface and its implementation:

public interface Comparator<T> {
    int compare(T o1, T o2);
}
public class StringComparator implements Comparator<String> {
    @Override
    public int compare(String o1, String o2) {
        return o1.compareTo(o2);
    }
}

After erasure, the interface method becomes compare(Object, Object), but the implementation has compare(String, String). Signatures differ, so this is not a valid override — polymorphism would break.

The compiler auto-generates a bridge method in StringComparator:

// Compiler-generated bridge method (not in source)
public int compare(Object o1, Object o2) {
    return compare((String) o1, (String) o2); // cast then delegate
}

Now the interface's compare(Object, Object) has an implementation (the bridge), which casts and calls the real compare(String, String). Polymorphism works.

Viewing Bridge Methods with javap

javap -c StringComparator.class

Output shows two compare methods:

public int compare(String, String);
  Code:
    0: aload_1
    1: aload_2
    2: invokevirtual #2 // Method java/lang/String.compareTo:(Ljava/lang/String;)I
    5: ireturn

public int compare(java.lang.Object, java.lang.Object);
  Code:
    0: aload_0
    1: aload_1
    2: checkcast     #3 // class java/lang/String
    5: aload_2
    6: checkcast     #3 // class java/lang/String
    9: invokevirtual #4 // Method compare:(Ljava/lang/String;Ljava/lang/String;)I
    12: ireturn

Bridge methods carry two access flags: ACC_BRIDGE (0x0040) and ACC_SYNTHETIC (0x1000). Detect them via Method.isBridge() and Method.isSynthetic().

Other Bridge Method Scenarios

Scenario 1: Subclass overriding generic parent method

public class Parent<T> {
    public T getValue() { return null; }
}
public class Child extends Parent<String> {
    @Override
    public String getValue() { return "hello"; }
}

Erased parent method: Object getValue(). Child method: String getValue(). Compiler generates bridge Object getValue() calling String getValue(). This also implements covariant return types.

Scenario 2: Generic method override

public interface Converter<T, R> {
    R convert(T source);
}
public class StringToIntConverter implements Converter<String, Integer> {
    @Override
    public Integer convert(String source) {
        return Integer.parseInt(source);
    }
}

Bridge method: Object convert(Object source) casting to String and calling Integer convert(String).

Bridge Method Pitfalls

Reflection may return bridge methods — filter with isBridge().

AOP/dynamic proxies may intercept bridge methods — Spring's AopUtils handles this; custom proxies must filter.

Method references via raw types invoke bridge methods :

Comparator<String> c = new StringComparator();
c.compare("a", "b"); // calls real method
Comparator rawC = c;
rawC.compare(new Object(), new Object()); // calls bridge → ClassCastException

Generic Information Isn't Fully Erased: The Signature Attribute

Use-site generic information (e.g., List<String> list) is erased, but declaration-site signatures (class, method, field generic signatures) are stored in the Signature attribute of the .class file for reflection and compiler use.

Signature Attribute Contents

Class type parameters (e.g., class Box<T>)

Method type parameters and return type (e.g., <T> T getValue())

Field generic types (e.g., List<String> list)

Superclass/interface generic arguments (e.g., extends Parent<String>)

Reflection APIs for Generic Types

Class.getGenericSuperclass()

— generic superclass Class.getGenericInterfaces() — generic interfaces Method.getGenericReturnType() — generic return type Method.getGenericParameterTypes() — generic parameter types Field.getGenericType() — generic field type ParameterizedType.getActualTypeArguments() — actual type arguments

Example: retrieving String from ArrayList<String>:

public class StringList extends ArrayList<String> {
    public static void main(String[] args) {
        Type superClass = StringList.class.getGenericSuperclass();
        if (superClass instanceof ParameterizedType) {
            ParameterizedType pt = (ParameterizedType) superClass;
            Type[] typeArgs = pt.getActualTypeArguments();
            System.out.println(typeArgs[0]); // class java.lang.String
        }
    }
}

TypeToken / Super Type Token Pattern

Anonymous inner subclass of a generic class retains its generic signature in the Signature attribute. This enables runtime type capture:

public abstract class TypeToken<T> {
    private final Type type;
    protected TypeToken() {
        Type superClass = getClass().getGenericSuperclass();
        this.type = ((ParameterizedType) superClass).getActualTypeArguments()[0];
    }
    public Type getType() { return type; }
}
// Usage
TypeToken<List<String>> token = new TypeToken<List<String>>() {};
System.out.println(token.getType()); // java.util.List<java.lang.String>

Gson, Jackson, and Spring use this for deserialization and dependency injection. Must use anonymous inner class ( new TypeToken<...>(){}); plain new TypeToken() loses the generic info.

Limitations Imposed by Erasure

Cannot new T()

Erased to new Object(). Workaround: pass Class<T> or Supplier<T>.

public T create(Class<T> clazz) throws Exception {
    return clazz.getDeclaredConstructor().newInstance();
}
public T create(Supplier<T> supplier) {
    return supplier.get();
}

Cannot instanceof T

Erased to instanceof Object (always true). Workaround: pass Class<T> and use clazz.isInstance(obj).

Cannot new T[] (Generic Arrays)

Arrays are covariant ( String[] is Object[] subtype), generics are invariant. Allowing new T[] would erasure to new Object[], breaking type safety. Declaration T[] array is allowed; creation via reflection (T[]) Array.newInstance(clazz, length) works with unchecked warning.

Primitives Cannot Be Type Parameters

List<int>

illegal; must use List<Integer>. Erasure targets Object (or bound), and primitives aren't Object subtypes. Causes boxing/unboxing overhead.

Static Members Cannot Reference Type Parameters

Type parameter T belongs to instances ( Box<String> and Box<Integer> have different T), but static members are shared across all instances — contradiction.

Exceptions Cannot Be Generic

class MyException<T> extends Exception

illegal; catch (T e) illegal. JVM cannot distinguish MyException<String> from MyException<Integer> at runtime.

Overload Conflict After Erasure

public void print(List<String> list) { }
public void print(List<Integer> list) { } // ❌ compile error: both erase to print(List)

Summary

Erasure Rules : unbounded → Object; bounded → bound; multiple bounds → first bound; wildcards → upper bound/ Object; generic methods similarly erased.

Compile-Time Actions : strict type checking; auto-insert checkcast; type safety is compile-time only, bypassable via reflection.

Bridge Methods : compiler-generated to reconcile erased signatures; carry ACC_BRIDGE / ACC_SYNTHETIC; appear in interface implementation, subclass override, generic method override; watch for reflection/proxy pitfalls.

Signature Attribute : declaration-site generic info retained in .class; accessible via reflection APIs; enables TypeToken pattern (anonymous subclass required).

Erasure Limitations : no new T(), instanceof T, new T[]; no primitives as type args; no static reference to type params; no generic exceptions; no overloads erasing to same signature.

Design Trade-off : erasure traded runtime type info for backward compatibility and zero overhead; C# chose reified generics but lacked Java's compatibility constraints.

Understanding erasure moves you beyond basic List<Map<String, Object>> usage to diagnosing ClassCastException, bridge method confusion, and runtime generic recovery — essential for advanced Java, reflection, and framework internals.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JVMBytecodeReflectionType ErasureBridge MethodsTypeTokenJava GenericsSignature Attribute
Java Tech Workshop
Written by

Java Tech Workshop

Focused on Java backend technologies, sharing fundamentals, multithreading, JVM, the Spring ecosystem, microservices, distributed systems, high concurrency, source‑code analysis, and practical experience. Continuously delivers high‑quality original content, interview guides, and learning roadmaps to help Java developers progress from beginner to advanced, enhancing technical skills and core competitiveness.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.