Why generics disappear at runtime
Almost every confusing rule in Java generics — why you can't write new T(), why
List<String>.class won't compile, why two overloads "clash" — traces back to a single
design decision: type erasure. Generics in Java are a compile-time fiction. The
compiler uses type arguments to check your code, then throws them away, emitting
bytecode that looks essentially like pre-generics Java. Understanding erasure turns a pile
of arbitrary-seeming restrictions into one coherent story, and that's exactly what a strong
interview answer demonstrates.
What erasure is and why Java chose it
Type erasure removes all generic type information during compilation. At runtime there
is no T, and a List<String> and a List<Integer> are the same class. The driving
reason was backward compatibility: generics arrived in Java 5, long after millions of
lines of raw-type code existed. Erasure let new generic code and old legacy code share the
same JVM and bytecode format without breaking anything.
List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println(a.getClass() == b.getClass()); // true — both are just ArrayList
List raw = a; // legacy raw type still interoperates
raw.add("ok"); // unchecked, but compiles and runs
The alternative — reified generics (as in C#/.NET), where List<int> and
List<string> are distinct runtime types — would have forced a new bytecode format and
broken the existing ecosystem. Java traded runtime type knowledge for compatibility.
How the compiler erases
Erasure is three concrete transformations. The compiler replaces each type parameter
with its leftmost bound (or Object if unbounded), inserts casts wherever erased
values are read out, and generates bridge methods to keep polymorphism intact. Because
the code was already type-checked, the inserted casts are guaranteed safe.
// 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 for you
A bounded <T extends Number> erases to Number, not Object, which is why you can call
Number methods on T. With multiple bounds (<T extends Comparable<T> & Serializable>)
only the first bound drives erasure, so put the most useful type first.
Reifiable vs non-reifiable types
A reifiable type is one whose 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 reconstruct it. This single distinction governs where generics are allowed at runtime-sensitive operations.
// Reifiable (survive erasure): String, Integer, int[], List (raw), List<?>
// Non-reifiable (erased): List<String>, List<? extends Number>, T, List<String>[]
Object o = new ArrayList<String>();
boolean p = o instanceof List<?>; // OK — unbounded wildcard is reifiable
// boolean q = o instanceof List<String>; // ERROR — non-reifiable
The 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 a class literal.
Why new T(), new T, T.class and instanceof List are banned
Each of these needs runtime type information that erasure removed. There is only one
Class object for List, shared by all parameterizations, so List<String>.class would
point at nothing distinct. new T() has no concrete class for the new bytecode to
instantiate, and the compiler can't guarantee T even has a no-arg constructor.
instanceof List<String> is a runtime check against a type argument that no longer exists.
class Factory<T> {
// T make() { return new T(); } // ERROR — cannot instantiate T
Class<?> c = List.class; // OK — the one List class object
// Class<?> d = List<String>.class; // ERROR — no parameterized literal
T make(Class<T> type) throws Exception { // workaround: a type token
return type.getDeclaredConstructor().newInstance();
}
}
The escape hatch is always the same shape: hand the code something reifiable (a
Class<T> token or a Supplier<T>) so the missing type comes from somewhere.
Generic arrays and heap pollution
Arrays and generics have opposite runtime models. Arrays are reified — they
remember their element type and throw ArrayStoreException if you store the wrong thing.
Generics are erased. A generic array couldn't perform its store-check because its
element type was wiped, so new T[] and new List<String>[] are forbidden. Allowing them
would make heap pollution — a parameterized variable pointing at an object of the wrong
type — trivially easy and silent.
List<String>[] arr = (List<String>[]) new List[1]; // unchecked cast — the only way in
Object[] objs = arr; // arrays are covariant
objs[0] = List.of(42); // stores a List<Integer> — no runtime error!
String s = arr[0].get(0); // ClassCastException far from the real cause
The corruption stays hidden until a later read trips a compiler-inserted cast. Prefer a
List<List<String>> over an array of a non-reifiable type.
Bridge methods
When a generic supertype's method erases to a different signature than the subtype's override, the compiler emits a synthetic bridge method so dynamic dispatch still lands on your real override. Without it, calling through the supertype reference would invoke the inherited erased method and silently skip your code.
class Node<T> { void set(T t) { } } // erases to set(Object)
class StringNode extends Node<String> {
@Override void set(String s) { } // set(String)
// compiler-generated bridge in StringNode:
// void set(Object o) { set((String) o); } // forwards to the real override
}
Bridge methods carry the ACC_BRIDGE flag and report true from Method.isBridge() and
Method.isSynthetic(). They're why reflective scans sometimes report a "duplicate" method
differing only by parameter type — filter them out when introspecting generic classes.
Name clashes from shared erasure
Because type arguments vanish, two methods that differ only by their generic parameter end up with the same erased signature, and a class can't declare two methods with identical signatures. The compiler reports a "name clash" even though the source looks unambiguous.
class Printer {
void print(List<String> s) { } // erases to print(List)
// void print(List<Integer> i) { } // ERROR — same erasure: name clash
}
The fix is to give the methods genuinely different erased signatures — different raw types or arity — or simply rename one. Distinct type arguments alone are never enough.
The Class type-token pattern
When code genuinely needs the runtime type of T, the standard solution is a type
token: pass in a Class<T> to recover what erasure removed. This is how
fromJson(json, User.class) works, and frameworks like Jackson and Spring lean on it
everywhere.
<T> T fromJson(String json, Class<T> type) {
Object parsed = parse(json);
return type.cast(parsed); // typed cast via the token, no unchecked warning
}
User u = fromJson(s, User.class); // T inferred from the token
A plain Class<T> can't represent a parameterized type like List<String> (no such
literal exists). The super type token trick — Jackson's TypeReference, Guava's
TypeToken — subclasses an abstract generic type so the argument is pinned in the class
signature, which does survive erasure as metadata, then reads it back via reflection:
TypeReference<List<String>> ref = new TypeReference<List<String>>() {};
Type t = ref.getClass().getGenericSuperclass(); // TypeReference<List<String>>
Type arg = ((ParameterizedType) t).getActualTypeArguments()[0]; // List<String>
Declaration-site generics (fields, method signatures, generic superclasses) live in
metadata and are readable via java.lang.reflect.Type; it's the instance argument of a
runtime object that's truly gone.
@SafeVarargs and unchecked warnings
A generic varargs parameter T... is compiled as a T[] — a non-reifiable generic array —
so it triggers an unavoidable "possible heap pollution" warning. @SafeVarargs is your
signed assertion that the method only reads the varargs and never pollutes the array; it
suppresses the warning at the declaration and every call site. It applies only to methods
that can't be overridden (static, final, or private).
@SafeVarargs
static <T> List<T> listOf(T... items) { // T[] is a non-reifiable array
return new ArrayList<>(Arrays.asList(items)); // safe — read only, never stores into items
}
More broadly, an unchecked warning is erasure admitting it can't verify a cast to a
parameterized type. Treat each one as a potential ClassCastException to justify, and only
@SuppressWarnings("unchecked") at the narrowest scope, with a comment, once you've proven
safety by hand.
Recap
Type erasure is the single fact behind Java generics: the compiler uses type arguments
to check your code, then replaces type parameters with their bound, inserts casts, and
adds bridge methods, leaving no T at runtime. Java chose it for backward
compatibility with pre-generics code, accepting the loss of runtime type identity. The
reifiable vs non-reifiable distinction explains every restriction — no new T(),
new T[], T.class, or instanceof List<String>, the generic-array ban that guards
against heap pollution, and the name clash when overloads share an erasure. When
you truly need the runtime type, reach for a Class<T> type token (or a super type
token for parameterized types), and use @SafeVarargs to vouch for read-only generic
varargs. Every generics quirk you hit is erasure's price for a compatible platform.