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.
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);
}
}true
java.util.ArrayList
erased return type: Comparable
generic return type: T
bgetDeclaredMethod 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.
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");
}
}
}setData(Integer) bridge=false
setData(Object) bridge=true
40
ClassCastException in bridgeThe 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):
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: returnThe 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.
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");
}
}
}size = 2
element type: Integer
ClassCastException at read, not at addThis 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].
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);
}
}error: generic array creationThe runnable version shows what does work: instanceof List<?>, pattern checks that the compiler can prove, and Array.newInstance with a Class object.
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);
}
}true
size 1
false
String[] length 3strs 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.
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[]");
}
}
}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.
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);
}
}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.
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)));
}
}error: name clash: total(List<String>) and total(List<Integer>) have the same erasureThe 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; oneClassobject 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.@SafeVarargsis 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>>inCollections.maxkeeps the erased return typeObjectfor binary compatibility.
Summary
- Generics are checked by
javacand erased in bytecode, which kept compatibility with pre-generic code and libraries. - Bridge methods, inserted casts, and
Signatureattributes 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 asT[]is unsafe. - Methods that differ only in type arguments clash after erasure.
