Type erasure is the process by which the compiler removes all generic
type information during compilation. Generics exist only at compile time
for type checking; at runtime the type parameters are gone, so a
List<String> and a List<Integer> are both just plain List.
List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println(a.getClass() == b.getClass()); // true — both ArrayList
The compiler replaces type parameters with their bound (or Object if
unbounded) and inserts casts where needed. The result is bytecode that looks
essentially like pre-generics Java. Rule of thumb: generics are a
compile-time fiction — nothing about the type argument survives to runtime.
The driving reason was backward compatibility. Generics arrived in Java 5, long after millions of lines of pre-generics code existed. Erasure let generic code and legacy raw-type code interoperate on the same JVM without changing the bytecode format or breaking existing libraries.
List<String> generic = new ArrayList<>();
List raw = generic; // legacy raw type — still works
raw.add("ok"); // unchecked, but compiles and runs
A pre-generics .class file and a generics-aware one produce compatible
bytecode, and a List from old code can flow into new generic code.
Rule of thumb: erasure was the price of adding generics without forking
the platform — runtime reification (like C#) would have broken the existing
ecosystem.
The compiler applies three transformations: it replaces type parameters
with their leftmost bound (or Object if unbounded), inserts casts at
every site where erased values are read out, and generates bridge methods
to preserve polymorphism. The runtime never sees T.
// You write:
class Box<T> { T value; T get() { return value; } }
String s = new Box<String>().get();
// After erasure (conceptually):
class Box { Object value; Object get() { return value; } }
String s = (String) new Box().get(); // compiler inserts the cast
A bounded <T extends Number> erases to Number, not Object.
Rule of thumb: type parameter -> bound, plus compiler-inserted casts — the
casts are guaranteed safe because the compiler already type-checked the code.
A type parameter erases to its leftmost (first) bound, not always Object.
An unbounded <T> erases to Object, but <T extends Number> erases to
Number, which lets the compiler call Number methods directly on T.
<T extends Number> double sum(T a, T b) {
return a.doubleValue() + b.doubleValue(); // legal — T erases to Number
}
// erased signature: double sum(Number a, Number b)
<T extends Comparable<T> & Serializable> T pick(T x) { return x; }
// erases to Comparable (the FIRST bound)
With multiple bounds (A & B), only the first drives erasure; that's why
you put the class or most-used interface first. Rule of thumb: the erased
type is the first bound — choose bound order so the useful methods survive.
A reifiable type is one whose type information is fully available at runtime — its representation is complete after erasure. A non-reifiable type loses information to erasure, so the JVM can't fully reconstruct it.
| Reifiable (survives) | Non-reifiable (erased) |
|---|---|
String, Integer |
List<String> |
List, Map (raw) |
List<? extends Number> |
List<?> (unbounded wildcard) |
T (type parameter) |
int[], String[] |
List<String>[] |
o instanceof List<?> // legal — unbounded wildcard is reifiable
o instanceof List<String> // ERROR — non-reifiable, info was erased
Rule of thumb: if a type still carries a generic argument other than ?,
it's non-reifiable and you can't use it with instanceof, array creation, or
.class.
Because there is only one Class object for List, shared by every
parameterization. After erasure List<String>, List<Integer>, and raw
List are the same class, so a List<String>.class literal would be
meaningless — there's nothing distinct to point to.
Class<?> c = List.class; // legal — the one List class
Class<?> d = List<String>.class; // compile error
List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println(a.getClass() == b.getClass()); // true
getClass() on any generic instance returns the raw class object for the
same reason. Rule of thumb: there's one Class per erased type, so a
parameterized class literal can't exist.
Because at runtime T has been erased — the JVM has no idea which class to
instantiate, and it can't guarantee T even has a no-arg constructor. The
new bytecode needs a concrete class, which erasure has removed.
class Factory<T> {
T make() { return new T(); } // compile error — cannot instantiate T
}
// Workaround: pass a Class token or a supplier
class Factory<T> {
T make(Class<T> type) throws Exception {
return type.getDeclaredConstructor().newInstance(); // reflection
}
T make(Supplier<T> s) { return s.get(); } // cleaner
}
Rule of thumb: to create instances of a type parameter, pass in a
Class<T> token or a Supplier<T> — the type must come from somewhere
reifiable.
Arrays are reified — they remember and enforce their element type at
runtime (storing a wrong type throws ArrayStoreException). Generics are
erased. The two models are incompatible: a generic array couldn't perform
its runtime store-check because the element type was erased.
T[] a = new T[10]; // compile error — generic array creation
List<String>[] b = new List<String>[10]; // compile error
// Workarounds:
List<String>[] c = (List<String>[]) new List[10]; // unchecked cast
T[] d = (T[]) Array.newInstance(componentType, 10); // reflection + Class token
Rule of thumb: you can't directly create arrays of a non-reifiable type;
use a List<List<String>> or an unchecked cast from a raw array.
Heap pollution occurs when a variable of a parameterized type points to an object that is not of that type — usually because erasure let an incompatible value slip in. Generic arrays are banned precisely because they'd make this trivially easy and undetectable.
List<String>[] arr = (List<String>[]) new List[1]; // unchecked
Object[] objs = arr; // arrays are covariant
objs[0] = List.of(42); // stores List<Integer> — no error!
String s = arr[0].get(0); // ClassCastException at READ time
The corruption is silent until a later read fails far from the cause.
Rule of thumb: heap pollution = a generic variable holding the wrong
element type; it surfaces as a surprise ClassCastException from
compiler-inserted casts.
instanceof is a runtime check, but the generic type argument was
erased, so there's nothing to test against. The JVM could only tell you
it's a List, never a List<String>. Only reifiable types (raw types or
the unbounded wildcard) are allowed.
if (obj instanceof List<String>) { } // compile error
if (obj instanceof List<?>) { } // OK — unbounded wildcard is reifiable
if (obj instanceof List) { } // OK — raw type
@SuppressWarnings("unchecked")
List<String> l = (List<String>) obj; // cast compiles but is unchecked
Rule of thumb: check against List<?> or the raw List; you can never
instanceof a specific type argument because it doesn't exist at runtime.
A bridge method is a synthetic method the compiler generates to preserve polymorphism after erasure. When a generic supertype's method erases to a different signature than the subtype's override, the compiler adds a bridge so dynamic dispatch still finds the real method.
class Node<T> { void set(T t) { } } // erases to set(Object)
class StringNode extends Node<String> {
@Override void set(String s) { } // set(String)
}
// Compiler adds a synthetic bridge in StringNode:
// void set(Object o) { set((String) o); } // forwards to the real method
Without the bridge, Node<String> n = new StringNode(); n.set("x") would call
the inherited set(Object) and skip the override. Rule of thumb: bridge
methods are invisible glue that keep overriding working across erased generic
signatures.
Bridge methods are marked synthetic and carry the ACC_BRIDGE flag in
the bytecode. You can detect them at runtime via reflection with
Method.isBridge() and Method.isSynthetic(). They never appear in your
source and you shouldn't call them directly.
for (Method m : StringNode.class.getDeclaredMethods()) {
System.out.println(m + " bridge=" + m.isBridge());
}
// set(String) bridge=false
// set(Object) bridge=true <- the synthetic bridge
They're why reflective scans sometimes find "duplicate" methods that differ
only by parameter type. Rule of thumb: filter out isBridge()/
isSynthetic() methods when introspecting generic classes.
Because after erasure they have the same signature, and a class can't have two methods with identical signatures. The compiler reports a "name clash" even though the source-level parameter types look different.
class Printer {
void print(List<String> s) { } // erases to print(List)
void print(List<Integer> i) { } // also erases to print(List) — CLASH!
}
// error: name clash: both methods have the same erasure
The fix is to give them genuinely different erased signatures (different raw types or arity) or rename one. Rule of thumb: overloads must differ after erasure — distinct type arguments alone are not enough.
A type token is a Class<T> object passed in to recover the type
information that erasure removed. Since the type argument is gone at runtime,
handing the method a Class<T> lets it instantiate, cast, or check types
safely.
<T> T fromJson(String json, Class<T> type) {
Object parsed = parse(json);
return type.cast(parsed); // typed cast via the token
}
User u = fromJson(s, User.class); // T inferred from the token
<T> T create(Class<T> type) throws Exception {
return type.getDeclaredConstructor().newInstance();
}
Frameworks like Jackson and Spring rely on this everywhere.
Rule of thumb: when you need the runtime type of T, pass a Class<T>
token — it's the standard escape hatch from erasure.
A plain Class<T> token can't represent a parameterized type like
List<String> (no such class literal). The super type token trick (used by
Jackson's TypeReference, Guava's TypeToken) captures it by subclassing an
abstract generic class and reading the type argument via reflection from the
generic superclass, which the compiler does record.
// The anonymous subclass embeds List<String> in its signature:
TypeReference<List<String>> ref = new TypeReference<List<String>>() {};
// Internally:
Type t = getClass().getGenericSuperclass(); // TypeReference<List<String>>
Type arg = ((ParameterizedType) t).getActualTypeArguments()[0]; // List<String>
Generic type info on class/method signatures survives erasure (in metadata), even though instance-level arguments don't. Rule of thumb: subclassing a generic type pins its arguments in the class signature, which reflection can read back.
A type parameter belongs to a specific instance's parameterization, but a
static member is shared across all parameterizations — and after
erasure there's only one class anyway. There's no single T for the static
context to refer to, so it's forbidden.
class Box<T> {
static T shared; // compile error — T in static context
static void set(T t) { } // compile error
static <U> U pick(U u) { return u; } // OK — its OWN type parameter
}
A static method may declare its own type parameter, which is fine.
Rule of thumb: the class's T is per-instance; statics are class-wide, so
they can't use it — give a static method its own type parameter instead.
A generic class cannot extend Throwable, and you cannot catch a type
parameter, because catch is a runtime dispatch on the exception's actual
class — which erasure has removed. You can declare a method that throws
a type parameter bounded by Throwable.
class MyException<T> extends Exception { } // compile error — generic Throwable
<T extends Throwable> void run() {
try { /* ... */ }
catch (T e) { } // compile error — cannot catch type parameter
}
<T extends Throwable> void rethrow(T t) throws T { throw t; } // OK
If catch were allowed, two catch (T) clauses could erase to the same
catch (Exception). Rule of thumb: you can throw but never catch a type
parameter, and exception classes can't be generic.
Instance type arguments vanish, but generic info embedded in declarations is
kept in the class file's signature metadata and is readable via reflection
(java.lang.reflect.Type). This includes field types, method
parameter/return types, type variable bounds, and the generic superclass /
interfaces.
class Repo { List<String> names; }
Field f = Repo.class.getDeclaredField("names");
Type t = f.getGenericType(); // List<String> — preserved!
ParameterizedType pt = (ParameterizedType) t;
System.out.println(pt.getActualTypeArguments()[0]); // class java.lang.String
What's lost is the argument of a runtime object (new ArrayList<String>()
knows nothing about String). Rule of thumb: declaration-site generics
live in metadata; use-site instance generics do not.
A varargs parameter of a generic type creates a generic array under the
hood (T... becomes T[]), which triggers an unavoidable unchecked /
"possible heap pollution" warning. @SafeVarargs is your assertion that the
method only reads from the varargs and never pollutes the array, which
suppresses the warning at the declaration and all call sites.
@SafeVarargs
static <T> List<T> listOf(T... items) { // T[] is a non-reifiable array
return new ArrayList<>(Arrays.asList(items)); // safe — read only
}
It's applicable only to methods that can't be overridden (static,
final, or private). Rule of thumb: use @SafeVarargs when your
generic varargs method is read-only and provably safe from heap pollution.
An unchecked warning means the compiler can't verify the type safety of an operation because the needed type information was erased — typically a cast to a parameterized type or use of a raw type. The code compiles, but the compiler is telling you it can't guarantee the runtime cast won't fail.
Object o = new ArrayList<String>();
List<String> list = (List<String>) o; // unchecked — only List is verifiable
List raw = new ArrayList();
raw.add("x"); // unchecked call to add(E)
Suppress only when you've manually proven safety, with the narrowest scope
and a comment, via @SuppressWarnings("unchecked"). Rule of thumb: an
unchecked warning is erasure admitting it can't check you — treat each one as a
potential ClassCastException to justify.
With reified generics (C#/.NET), the type argument is preserved at
runtime: List<int> and List<string> are distinct types, you can call
typeof(T), do new T(), and use is List<string>. Java's erased
generics throw all of that away after compilation.
// Java — none of these work:
T.class; new T(); new T[10]; obj instanceof List<String>;
// C# equivalents all work because T is reified at runtime.
Reification costs a specialized type per parameterization and breaks legacy interop; erasure is cheaper and compatible but loses runtime type identity. Rule of thumb: Java traded runtime type knowledge for backward compatibility — every generics restriction you hit traces back to that choice.
More Generics interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.