Type Erasure: How Generics Work at Runtime
Java generics exist only at compile time. The compiler enforces type constraints and inserts casts, then erases all generic type information before generating bytecode. At runtime, List
Contents
What Type Erasure Means
Java generics are purely a compile-time construct. The compiler enforces type constraints and inserts casts, then removes all generic type information before generating bytecode: a process called type erasure. At runtime, List<String>List<Integer>ListClass
// TYPE ERASURE: generic type parameters are removed at compile time.
// The bytecode contains no information about T, E, K, V, etc.
// Source code (what you write):
List<String> names = new ArrayList<>();
names.add("Alice");
String first = names.get(0); // no cast needed — compiler handles it
// After erasure (what the bytecode looks like):
List names2 = new ArrayList(); // raw type
names2.add("Alice");
String first2 = (String) names2.get(0); // compiler inserts cast for you
// Proof — these two method signatures are IDENTICAL after erasure:
// void process(List<String> list) { ... }
// void process(List<Integer> list) { ... }
// → compile error: erasure of both is void process(List list) — duplicate!
// Proof — List<String> and List<Integer> are the same class at runtime:
List<String> strings = new ArrayList<>();
List<Integer> ints = new ArrayList<>();
System.out.println(strings.getClass() == ints.getClass()); // true
System.out.println(strings.getClass().getName()); // java.util.ArrayList
// Proof — you cannot get the type argument from a List at runtime:
List<String> myList = new ArrayList<>();
// myList.getClass().getTypeParameters()[0] gives "E" (the name), NOT "String"
java.lang.reflect.TypeVariable<?>[] params = myList.getClass().getTypeParameters();
System.out.println(params[0].getName()); // "E" — the parameter name, not the argument
// What IS retained at runtime (not erased):
// - Class declarations: class Pair<A, B> — you can get A and B names via reflection
// - Field generic types (if declared with concrete types)
// - Method return/parameter types with concrete generic bounds
// These are stored in the class file as "Signature" attributes for reflection use.
Generic Type → Raw Type at Bytecode Level
The compiler replaces every type parameter with its erasure: an unbounded TObjectT extends ComparableComparableCHECKCASTjavap -verbose
// Erasure rules:
// T (unbounded) → Object
// T extends Comparable<T> → Comparable (leftmost bound)
// T extends A & B → A (leftmost bound)
// Generic class source:
class Box<T> {
private T value;
Box(T value) { this.value = value; }
T getValue() { return value; }
static <T extends Comparable<T>> T max(T a, T b) {
return a.compareTo(b) >= 0 ? a : b;
}
}
// After erasure — equivalent bytecode:
// class Box {
// private Object value; // T → Object
// Box(Object value) { ... }
// Object getValue() { return value; }
// static Comparable max(Comparable a, Comparable b) { // T extends Comparable → Comparable
// return a.compareTo(b) >= 0 ? a : b;
// }
// }
// The compiler inserts CHECKCAST instructions at every use site:
Box<String> box = new Box<>("hello");
String s = box.getValue(); // bytecode: CHECKCAST java/lang/String
// Raw type usage (avoid in new code — pre-Java 5 compatibility only):
Box rawBox = new Box("hello"); // raw — no type safety
String val = (String) rawBox.getValue(); // you must cast manually
rawBox.setValue(42); // compiler warning but ALLOWED — can corrupt later
// Why raw types are dangerous:
List raw = new ArrayList<String>(); // raw reference to parameterized list
raw.add(42); // int sneaks in — no compile error with raw type
List<String> typed = (List<String>) raw; // unchecked cast warning
String item = typed.get(0); // ClassCastException at runtime — not at add() time!
ClassCastExceptionBridge Methods
When a generic class or interface is subclassed with a concrete type, type erasure creates a signature mismatch between the overriding method and the erased supertype method. To preserve polymorphism, the compiler generates a synthetic bridge method in the subclass that matches the erased signature and delegates to the concrete method. These show up in reflection with Method.isBridge() == true
// Bridge methods are synthetic methods generated by the compiler
// to preserve polymorphism after type erasure.
// Source:
interface Comparable<T> {
int compareTo(T other); // after erasure → int compareTo(Object other)
}
class Length implements Comparable<Length> {
final int value;
Length(int value) { this.value = value; }
@Override
public int compareTo(Length other) { // concrete method
return Integer.compare(this.value, other.value);
}
}
// Problem after erasure:
// - Comparable requires: int compareTo(Object)
// - Length provides: int compareTo(Length) ← different signature!
// Without a bridge, polymorphism would break (casting through interface would fail)
// Compiler generates a synthetic bridge method in Length:
// public synthetic bridge int compareTo(Object other) {
// return this.compareTo((Length) other); // delegates to real method + cast
// }
// You can see bridge methods via reflection:
for (java.lang.reflect.Method m : Length.class.getMethods()) {
if (m.isBridge()) {
System.out.println("Bridge: " + m);
}
}
// Output: Bridge: public int Length.compareTo(java.lang.Object)
// Bridge methods and covariant return types:
class Base {
Object get() { return "base"; }
}
class Sub extends Base {
@Override
String get() { return "sub"; } // covariant — String is a subtype of Object
}
// Compiler generates bridge in Sub:
// public bridge Object get() { return this.get(); } // bridges Base.get() contract
// Bridge methods are invisible in normal usage; you only encounter them when
// iterating getMethods() and need to filter them out:
java.lang.reflect.Method[] methods = Length.class.getDeclaredMethods();
java.util.Arrays.stream(methods)
.filter(m -> !m.isBridge() && !m.isSynthetic())
.forEach(System.out::println);
Reifiable vs Non-Reifiable Types
A reifiable type has its complete type information available at runtime: raw types, non-generic concrete classes, primitive arrays, and unbounded wildcards are all reifiable. Non-reifiable types lose their generic argument information through erasure: List<String>TList<? extends Number>instanceof
// REIFIABLE type — full type information available at runtime
// NON-REIFIABLE type — type info partially or fully erased at runtime
// Reifiable types (safe to use with instanceof, arrays, reflection):
Object obj = "hello";
System.out.println(obj instanceof String); // OK — String is reifiable
System.out.println(obj instanceof int[]); // OK — primitive arrays are reifiable
System.out.println(obj instanceof Object[]); // OK
// Non-reifiable types (erased — cannot use with instanceof):
List<String> ls = new ArrayList<>();
// obj instanceof List<String> ← COMPILE ERROR — cannot check erased type
// obj instanceof List<?> ← OK — unbounded wildcard is reifiable (kinda)
System.out.println(obj instanceof List<?>); // allowed — checks raw List
// Type | Reifiable?
// ----------|--------------------------------------------
// String | Yes — concrete class
// int[] | Yes — primitive array
// String[] | Yes — array of concrete class
// List | Yes — raw type
// List<?> | Yes — unbounded wildcard
// List<String> | No — erased to List
// T (parameter)| No — erased to its bound (Object or bound type)
// List<? extends Number> | No — bounded wildcard is erased
// Varargs + non-reifiable = heap pollution warning:
@SafeVarargs // suppresses warning when method doesn't expose the array
static <T> List<T> listOf(T... elements) {
return java.util.Arrays.asList(elements);
}
// Without @SafeVarargs, calling listOf("a","b") gives an unchecked warning
// because compiler creates a T[] (Object[] at runtime) — type info is gone
// Generic array creation is forbidden:
// T[] arr = new T[10]; // COMPILE ERROR
// List<String>[] arr2 = new List<String>[10]; // COMPILE ERROR
// Workaround — create Object[] and cast (generates unchecked warning):
@SuppressWarnings("unchecked")
T[] createArray(int size) {
return (T[]) new Object[size];
}
Why instanceof with Generics Fails
instanceof List<String>ListStringinstanceof List<?>instanceof ListClass<T>
// instanceof checks type at runtime, but generic type info is erased.
// The JVM has NO idea whether a List was parameterised as String or Integer.
void checkTypes(Object obj) {
// These work — reifiable types:
if (obj instanceof String s) {
System.out.println("String: " + s);
}
if (obj instanceof List<?> list) { // unbounded wildcard OK
System.out.println("Some List of size " + list.size());
}
// These do NOT compile:
// if (obj instanceof List<String>) { } // ERROR — illegal generic type for instanceof
// if (obj instanceof Map<String,?>) { } // ERROR — partially parameterized
}
// Runtime check with explicit element inspection (workaround):
boolean isStringList(Object obj) {
if (!(obj instanceof List<?> list)) return false;
for (Object element : list) {
if (!(element instanceof String)) return false;
}
return true;
}
// Class<T> as a runtime type token (the proper solution — see next section):
class TypeSafeContainer<T> {
private final Class<T> type;
private T value;
TypeSafeContainer(Class<T> type) { this.type = type; }
void set(Object v) {
value = type.cast(v); // runtime check via Class — throws ClassCastException on failure
}
T get() { return value; }
boolean isInstance(Object obj) {
return type.isInstance(obj); // equivalent of obj instanceof T — works at runtime!
}
}
TypeSafeContainer<String> c = new TypeSafeContainer<>(String.class);
c.set("hello"); // OK
// c.set(42); // ClassCastException at runtime — caught here, not silently later
System.out.println(c.isInstance("world")); // true
System.out.println(c.isInstance(123)); // false
Heap Pollution and Unchecked Warnings
Heap pollution occurs when a variable of a parameterized type holds a reference to an object of the wrong type: usually caused by mixing raw types with generic ones. The compiler emits unchecked warnings at the source of potential pollution, but the ClassCastException@SuppressWarnings("unchecked")
// HEAP POLLUTION: a variable of parameterized type refers to an object
// of the wrong type. Caused by mixing raw types and generics.
// Example 1 — raw type assignment:
@SuppressWarnings("unchecked")
void heapPollution() {
List<String> strings = new ArrayList<>();
List raw = strings; // raw reference — no warning here
raw.add(42); // Integer added to List<String> — warning
String s = strings.get(0); // ClassCastException! (not at add() above)
}
// Unchecked warnings tell you WHERE heap pollution may originate:
// "unchecked cast" — (T) someObject where T is erased
// "unchecked call" — calling a method on raw type with generic args
// "unchecked conversion"— assigning raw type to parameterized type
// Varargs and heap pollution:
static void unsafe(List<String>... lists) { // compiler warns here
Object[] array = lists; // List<String>[] → Object[] (valid upcast)
array[0] = List.of(42); // store List<Integer> in slot 0 — heap pollution!
String s = lists[0].get(0); // ClassCastException
}
// Safe varargs — don't expose the array:
@SafeVarargs // tells compiler (and callers) this method is safe
static <T> void safeLog(T... items) {
for (T item : items) System.out.println(item); // only reads — safe
}
// Suppressing warnings responsibly:
// Only use @SuppressWarnings("unchecked") when you have MANUALLY verified safety:
@SuppressWarnings("unchecked")
static <T> T coerce(Object o) {
// Safe because caller controls both T and o:
return (T) o; // unchecked cast — we know what we're doing
}
// Document WHY it is safe. Never suppress warnings just to silence the compiler.
ClassCastExceptionUsing Class Token to Work Around Erasure
Because TClass<T>type.isInstance(obj)type.cast(obj)User.classreadValue()
// Pass Class<T> explicitly so the type is available at runtime.
// This is called the "type token" or "class token" pattern.
// Type-safe heterogeneous container (Bloch, Effective Java Item 33):
class TypesafeMap {
private final Map<Class<?>, Object> map = new HashMap<>();
<T> void put(Class<T> type, T value) {
map.put(Objects.requireNonNull(type), type.cast(value));
}
<T> T get(Class<T> type) {
return type.cast(map.get(type)); // runtime-safe cast via Class
}
}
TypesafeMap tsm = new TypesafeMap();
tsm.put(String.class, "Hello");
tsm.put(Integer.class, 42);
tsm.put(Double.class, 3.14);
System.out.println(tsm.get(String.class)); // "Hello"
System.out.println(tsm.get(Integer.class)); // 42
// tsm.put(String.class, 42); ← ClassCastException — type token prevents this
// Generic factory using class token:
class Registry<T> {
private final Class<T> type;
Registry(Class<T> type) { this.type = type; }
T create() throws Exception {
return type.getDeclaredConstructor().newInstance();
}
boolean isCompatible(Object obj) {
return type.isInstance(obj);
}
Class<T> getType() { return type; }
}
Registry<StringBuilder> reg = new Registry<>(StringBuilder.class);
StringBuilder sb = reg.create(); // creates new instance without knowing T at compile time
// Limitation — class token doesn't work for parameterized types:
// Class<List<String>> c = List<String>.class; // COMPILE ERROR — no class literal
// for parameterized type
// Only raw class literals exist: List.class, Map.class — → see TypeToken pattern below
// Casting with Class (safer than unchecked cast):
Object obj = "world";
String s = String.class.cast(obj); // throws ClassCastException with clear message
// vs (String) obj // throws ClassCastException with less context
Supertype Token Pattern (TypeToken)
A plain Class<T>List<String>getGenericSuperclass()TypeTokenTypeReferenceList<Map<String, Integer>>
// Problem: Class<T> token cannot represent parameterized types like List<String>.
// Solution: Supertype token — capture the generic type via an anonymous subclass.
// The trick: an anonymous subclass of a generic class records the type argument
// in its "Signature" attribute (not erased!) and reflection can read it.
import java.lang.reflect.*;
// Minimal TypeToken implementation:
abstract class TypeToken<T> {
private final Type type;
protected TypeToken() {
// getGenericSuperclass() returns ParameterizedType for "TypeToken<List<String>>"
Type superclass = getClass().getGenericSuperclass();
if (!(superclass instanceof ParameterizedType pt)) {
throw new IllegalArgumentException("TypeToken must be parameterized");
}
this.type = pt.getActualTypeArguments()[0]; // captures the T argument
}
public Type getType() { return type; }
@Override public String toString() { return type.toString(); }
}
// Usage — capture List<String> at runtime:
TypeToken<List<String>> token = new TypeToken<List<String>>() {};
// Note: {} creates an anonymous subclass — this is the key trick!
System.out.println(token.getType());
// Output: java.util.List<java.lang.String>
// Full type information is now available:
ParameterizedType pType = (ParameterizedType) token.getType();
System.out.println(pType.getRawType()); // interface java.util.List
System.out.println(pType.getActualTypeArguments()[0]); // class java.lang.String
// Gson uses this pattern to deserialize generic types:
// Gson gson = new Gson();
// Type listType = new TypeToken<List<String>>() {}.getType();
// List<String> result = gson.fromJson(json, listType);
// Jackson uses TypeReference similarly:
// TypeReference<List<User>> ref = new TypeReference<>() {};
// List<User> users = mapper.readValue(json, ref);
// Guava's TypeToken — much richer API (type resolution, subtype checks):
// TypeToken<List<String>> tt = new TypeToken<List<String>>() {};
// tt.isSubtypeOf(Iterable.class); // true
// tt.getRawType(); // List.class
| Problem | Workaround |
|---|---|
| T instanceof check | Class<T>.isInstance(obj) |
| Create T[] | (T[]) new Object[n] + @SuppressWarn |
| List<T> type at runtime | TypeToken supertype token pattern |
| Deserialize generic JSON | Gson TypeToken / Jackson TypeRef |
| Type-safe heterogeneous map | Class<T> keys with type.cast() |
Note: The supertype token trick relies on the fact that Java stores generic type signatures of superclasses in the class file. When you write new TypeToken<List<String>>() {}List<String>