Generics and Type Erasure
Interview Question: "What fundamental problems did Generics solve when introduced in Java 5? How does the JVM execute generics under the hood via Type Erasure, and what practical limitations does this architectural choice impose?"
The short version
Before Java 5, every collection just stored plain Object references. You could put anything in, and you only found out you'd put the wrong thing in when the program crashed at runtime with a ClassCastException. Generics let you label a collection with the type it's supposed to hold, so the compiler catches the mistake before the code ever runs.
Here's the twist: at runtime, the JVM doesn't actually know about any of that labeling. Java erases the generic type information at compile time - it checks everything while compiling, inserts the casts you'd have written by hand, and then throws the type labels away. This is called type erasure, and it's a deliberate trade-off: it kept Generics 100% compatible with the millions of lines of pre-Java-5 code already running in production.
The problem, before and after
// BEFORE: raw collections - anything goes, compiler won't stop you
List rawList = new ArrayList();
rawList.add("Alice");
rawList.add(100); // compiles fine - Object accepts anything
String name = (String) rawList.get(0); // works
String number = (String) rawList.get(1); // compiles, then crashes at RUNTIME
// AFTER: generics - the compiler enforces the type for you
List<String> safeList = new ArrayList<>();
safeList.add("Alice");
// safeList.add(100);
// COMPILE ERROR - caught immediately, never reaches production
String name = safeList.get(0); // no manual cast needed
Two real wins: mistakes become compile errors instead of 3 AM production crashes, and you stop writing manual casts everywhere.
The analogy: a labeled box with a bouncer
A raw type is an unlabeled cardboard box. Anyone can toss in an apple, a shoe, whatever - and when you reach in blind expecting an apple, you might get a shoe instead (ClassCastException).
A generic type is the same box with a label on it (<Apple>), and the compiler acts as a bouncer standing at the opening: anything that isn't an apple gets rejected before it goes in.
Type erasure is what happens once everyone's safely inside: the bouncer tears up the tickets at the door. Inside, the JVM just sees a box of Objects - it doesn't need to keep checking, because the bouncer already verified everything on the way in.
What erasure actually does to your code
List<String> listList listjavac does three things when it compiles generic code:
- Replaces the type parameter with its bound - an unbounded
<T>becomesObject; a bounded<T extends Number>becomesNumber. - Inserts casts at the call sites that read values back out, so the bytecode still behaves correctly.
- Generates bridge methods where needed, so overriding still works correctly across the erasure.
// You write:
public class DataHolder<T> {
private T value;
public T getValue() { return value; }
}
// The bytecode is equivalent to:
public class DataHolder {
private Object value;
public Object getValue() { return value; }
}
// Bounded version, you write:
public class NumericStats<T extends Number> {
private T number;
public double toDouble() { return number.doubleValue(); }
}
// Erased to its bound:
public class NumericStats {
private Number number;
public double toDouble() { return number.doubleValue(); }
}
Bridge methods, explained: say Node implements Comparable<Node> with compareTo(Node o). After erasure, Comparable<T>'s method becomes compareTo(Object) - so Node's method no longer technically overrides it at the bytecode level. The compiler quietly adds a bridge to fix this:
// generated silently by the compiler
public synthetic bridge int compareTo(Object o) {
return this.compareTo((Node) o);
}
Generics vs. raw types vs. C++ templates
| Dimension | Raw types (pre-Java 5) | Java generics (erasure) | C++ templates (reified) |
|---|---|---|---|
| Type safety | None - caught only at runtime | Strong, at compile time | Strong, at compile time |
| Known at runtime | Only Object | Erased - type argument is gone | Reified - full type info kept |
| Binary size | One class file | One class file, shared by every type used | New code generated per type (vector<int>, vector<double>, ...) |
| Compatibility | Legacy baseline | 100% compatible with pre-2004 bytecode | Needs recompilation from source |
| Primitives | N/A | Not allowed directly - List<Integer>, not List<int> | Supported natively, no boxing |
| Runtime cost | Manual cast overhead | Same as raw types, plus an inserted cast | Fastest - the compiler can inline aggressively |
What erasure makes illegal
Because the type argument is gone by runtime, a few things you'd expect to work, don't:
1. You can't do new T()
public class Factory<T> {
public T create() {
// return new T(); // COMPILE ERROR
return null;
}
}
The JVM doesn't know what T actually is at runtime, so it has no idea which constructor to call. Workaround: pass in a Class<T> token and call clazz.getDeclaredConstructor().newInstance().
2. You can't instanceof a parameterized type
List<String> list = new ArrayList<>();
// if (list instanceof List<String>) { ... } // COMPILE ERROR
if (list instanceof List<?>) { ... } // fine - unbounded wildcard
At runtime, List<String> and List<Integer> are both just ArrayList - there's nothing left to check against.
3. You can't make a generic array
// T[] items = new T[10]; // COMPILE ERROR
@SuppressWarnings("unchecked")
T[] items = (T[]) new Object[10]; // the standard JDK workaround
Arrays remember their exact element type at runtime (so they can throw ArrayStoreException); generics don't. The two systems don't mix.
4. You can't use a primitive as a type argument
// List<int> numbers = new ArrayList<>(); // SYNTAX ERROR
List<Integer> numbers = new ArrayList<>(); // use the boxed type instead
Erasure replaces an unbounded type with Object, and primitives aren't objects.
The overload trap
A classic trick question:
public class Printer {
public void print(List<String> stringList) {
System.out.println("Printing strings");
}
public void print(List<Integer> intList) {
System.out.println("Printing integers");
}
}
This doesn't compile. Both methods erase to the same signature, print(List list), so the compiler rejects it with "has the same erasure as another method." You'd need different method names (printStrings, printIntegers) instead.
Say this in the interview
"Generics gave us compile-time type safety and got rid of manual casts and runtime ClassCastExceptions. Under the hood, Java implements this through type erasure: the compiler checks everything, inserts the casts you'd otherwise write by hand, and then strips the generic type information out of the bytecode entirely - an unbounded <T> becomes Object, a bounded <T extends Number> becomes Number. This keeps full backward compatibility with pre-Java-5 bytecode and avoids the code bloat that C++ templates have, but it's why you can't do new T(), can't instanceof a parameterized type, can't create a generic array, and can't use primitives as type arguments."