Type Erasure
Type erasure is how the JVM implements generics: most generic type information is removed at compile time, leaving ordinary, backward-compatible bytecode at runtime.
Java generics are implemented by a compile-time technique called type erasure: the compiler checks all your generic code for type safety, then erases most of the specific type-parameter information, replacing it with its bound (or Object if unbounded) in the generated bytecode. This is why List<String> and List<Integer> share a single List class at runtime for ordinary purposes, and it explains many of generics' quirks and restrictions.
Why Erasure Exists
Generics were added in Java 5, well after millions of lines of pre-generics code and compiled .class files already existed. Erasure was chosen specifically so that generic code compiles down to bytecode compatible with the pre-generics JVM and interoperable with legacy, non-generic code — a design goal usually called migration compatibility.
This is a deliberate compatibility tradeoff, not an oversight: full "reified" generics (where the JVM itself tracks parameterized types at runtime, as in some other languages) would have required a fundamentally different, incompatible bytecode/runtime model.
Runtime Representation: One Class Per Generic Type
At runtime, all instantiations of a generic class share the same single .class file and the same Class object — the type argument is not part of the runtime class.
Both variables refer to the exact same runtime class, ArrayList — the fact that one is 'of String' and the other 'of Integer' exists only in the source code and is checked only at compile time.
List<String> ls = new ArrayList<>();
List<Integer> li = new ArrayList<>();
System.out.println(ls.getClass() == li.getClass()); // true
System.out.println(ls.getClass().getName()); // java.util.ArrayListHow Erasure Transforms Generic Code
Conceptually, the compiler replaces each type parameter with its bound (Object for an unbounded parameter, or the bound type itself for a bounded one), and inserts casts where needed so the erased code behaves correctly.
You never see the inserted cast in your source, but it's really there in the compiled bytecode at the call site, which is why a ClassCastException can still theoretically occur if generics are bypassed (e.g. via raw types).
// Source code you write:
public class Box<T> {
private T value;
public T get() { return value; }
public void set(T value) { this.value = value; }
}
// Conceptually, after erasure (unbounded T -> Object):
public class Box {
private Object value;
public Object get() { return value; }
public void set(Object value) { this.value = value; }
}
// At a call site, the compiler inserts the cast for you:
Box<String> box = new Box<>();
box.set("hello");
String s = box.get(); // compiler-inserted cast: (String) box.get()Erasure with Bounded Type Parameters
A bounded type parameter erases to its bound rather than to Object, since the bound is the most specific type the compiler can guarantee for every possible instantiation.
public class NumberBox<T extends Number> {
private T value;
public double asDouble() { return value.doubleValue(); }
}
// Conceptually erases to:
public class NumberBox {
private Number value;
public double asDouble() { return value.doubleValue(); } // legal on Number directly
}Bridge Methods (Conceptually)
Erasure can make an overriding method's erased signature not literally match its generic interface/superclass method, breaking normal polymorphism. To fix this, the compiler silently generates a synthetic "bridge method" with the erased signature that simply forwards to your actual method with the correct cast.
Without the bridge method, calling set() through a raw Container reference or Container<String> reference wouldn't correctly dispatch to StringContainer's set(String) at the bytecode level, because interface Container<T>'s erased signature is set(Object).
You never write bridge methods yourself — the compiler generates them automatically whenever erasure would otherwise break method overriding across a generic type boundary.
interface Container<T> {
void set(T value);
}
class StringContainer implements Container<String> {
public void set(String value) { /* ... */ }
// The compiler ALSO generates, invisibly, a synthetic bridge method:
// public void set(Object value) { set((String) value); }
}Restriction: Cannot Instantiate a Type Parameter
Because the runtime class has no record of what T actually was for a given instance, new T() is illegal — there is no class to instantiate.
public class Factory<T> {
// T make() { return new T(); } // compile-time error
// Workaround: caller supplies a Supplier or Class token
T make(java.util.function.Supplier<T> supplier) {
return supplier.get();
}
}Restriction: Cannot Create Generic Arrays
Arrays in Java are reified (they remember their element type at runtime for the array-store check), which directly conflicts with erasure — creating new T[] would produce an array whose real runtime element type is unknown, undermining array type safety guarantees.
public class Container<T> {
// T[] array = new T[10]; // compile-time error
@SuppressWarnings("unchecked")
T[] createArray(int size) {
return (T[]) new Object[size]; // legal, but unchecked — caller must know the real type
}
}
// The JDK's own workaround for this exact problem:
// List<String> list = new ArrayList<>();
// String[] arr = list.toArray(new String[0]); // caller supplies the concrete array typeRestriction: Cannot Use Primitives as Type Arguments
Since a type parameter erases to a reference type (Object or its bound), it must always be substitutable by a reference; primitives don't fit that model, so wrapper types (autoboxed) are required.
// List<int> nums; // does not compile
List<Integer> nums = new ArrayList<>(); // OK, relies on autoboxingRuntime Type Information Limitations
Because the specific type argument is erased, you cannot use instanceof against a parameterized type, and getClass() cannot reveal what a collection was declared to hold.
List<String> list = new ArrayList<>();
// if (list instanceof List<String>) { } // compile-time error
if (list instanceof List<?>) { } // OK: only the unbounded wildcard form is legal
list.getClass(); // ArrayList.class -- no mention of String anywhere at runtimeWhat Erasure Does NOT Remove: Reflective/Signature Metadata
It's a common oversimplification to say generic type information "completely disappears" at runtime. Ordinary generic-type checks on a specific object instance are indeed unavailable (as shown above), but the compiler also emits additional metadata — a class-file Signature attribute — that preserves the declared generic type of fields, method parameters, method return types, and superclass/interface information. This is accessible reflectively.
field.getType() reflects the erased type (List). field.getGenericType() reads the class file's Signature attribute and reveals that this particular field was declared as List<String> — proving generic information is not entirely gone from a compiled class, only unavailable for a runtime instance's actual object identity.
The key distinction: an individual object instance (like a specific ArrayList at runtime) has no memory of what type argument it was created with. But the class file that DECLARED a generic field, method, or supertype relationship retains that declared generic signature, which reflection APIs like getGenericType(), getGenericSuperclass(), and getGenericParameterTypes() can read back.
import java.lang.reflect.Field;
import java.lang.reflect.ParameterizedType;
class Holder {
List<String> names; // generic field
}
Field field = Holder.class.getDeclaredField("names");
System.out.println(field.getType()); // interface java.util.List (erased)
System.out.println(field.getGenericType()); // java.util.List<java.lang.String> (from Signature metadata!)Practical Usage
Understanding erasure explains why certain patterns are needed in real code: passing a Class<T> token or Supplier<T> to work around "cannot instantiate T", using toArray(new String[0]) instead of a generic array, and why overload resolution can't distinguish two methods that only differ by type argument (e.g. void process(List<String>) and void process(List<Integer>) cannot coexist — they'd have the same erased signature).
// Does not compile: both erase to process(List)
// void process(List<String> list) { }
// void process(List<Integer> list) { }Mental Model
Think of type erasure as the compiler doing all the type-safety homework up front, then handing the JVM a plain, un-parameterized version of your code with the necessary casts already inserted. The JVM itself never "sees" List<String>" as a distinct runtime concept from List<Integer> — it only ever runs the erased List` bytecode, trusting that the compiler already verified everything was consistent.
Quick Reference
Erasure summary:
<T> -> erases to Object
<T extends Foo> -> erases to Foo
Restrictions caused by erasure:
- cannot: new T()
- cannot: new T[]
- cannot: primitives as type arguments (use wrapper types)
- cannot: instanceof against a parameterized type (only List<?> form is legal)
- cannot: overload two methods differing only by type argument
What erasure does NOT remove:
- class-file Signature metadata (readable via getGenericType(), etc.)
- the compiler's compile-time type checking itselfKey Takeaways
Java implements generics via type erasure: the compiler enforces type safety at compile time, then erases type parameters to their bound (or Object) in the generated bytecode, for backward compatibility with pre-generics code.
All instantiations of a generic class share one runtime class and Class object — List<String> and List<Integer> are the same class, ArrayList, at runtime.
Erasure is why you cannot write new T(), new T[], use primitives as type arguments, or use instanceof against a specific parameterized type.
Bridge methods are synthetic, compiler-generated methods that preserve correct polymorphic dispatch when erasure would otherwise mismatch an overriding method's signature.
Erasure removes ordinary runtime type information for individual object instances, but does not remove all generic metadata: class-file Signature attributes preserve declared generic types for fields, methods, and supertypes, and reflection APIs like getGenericType() can read them back.
List<String> ls = new ArrayList<>(); List<Integer> li = new ArrayList<>(); if (ls.getClass() != li.getClass()) { System.out.println("different types"); // never printed — this assumption is wrong }
List<String> ls = new ArrayList<>(); List<Integer> li = new ArrayList<>(); System.out.println(ls.getClass() == li.getClass()); // true — same runtime class
Assuming getClass() reveals a generic type argument at runtime is a common mistake caused by not understanding erasure — both are simply ArrayList.class at runtime, regardless of declared type argument.
public class Factory<T> { T make() { return new T(); } // does not compile: cannot instantiate a type parameter } public class Factory<T> { T make(java.util.function.Supplier<T> supplier) { return supplier.get(); // caller provides how to create a T } }
Because of erasure, the runtime has no record of what class T was bound to for a given instance, so new T() cannot be compiled; pass a Supplier<T> or a Class<T> token as a workaround.
// Claiming generic information is completely gone at runtime, always "Once compiled, there is no way to ever discover a field's declared generic type." import java.lang.reflect.Field; Field field = Holder.class.getDeclaredField("names"); System.out.println(field.getGenericType()); // e.g. List<String> — recoverable via reflection
It's inaccurate to claim generic type information vanishes in every scenario: the class file's Signature attribute preserves declared generic types for fields, methods, and supertypes, and reflection can read it back, even though a live object instance itself carries no such record.
What is type erasure, and why was it chosen as Java's generics implementation strategy?
Type erasure is the compile-time process of checking generic code for type safety and then replacing type parameters with their bound (or Object) in the compiled bytecode. It was chosen for migration compatibility — so generic code produces bytecode that remains compatible with the pre-generics JVM and interoperates with legacy non-generic code.
Why does list1.getClass() == list2.getClass() return true for a List<String> and a List<Integer>?
Because all instantiations of a generic class share a single runtime class — the type argument is erased and is not part of the runtime class identity; both are simply ArrayList.class.
Why can't you write new T() inside a generic class?
Because of erasure, the runtime representation of a generic class instance carries no record of what concrete type T was bound to, so there is no class available to instantiate at that point; a factory method, Supplier<T>, or Class<T> token is used as a workaround instead.
What is a bridge method, and why does the compiler generate one?
A bridge method is a synthetic method the compiler generates when erasure would otherwise cause an overriding method's erased signature to not match its generic interface/superclass method, breaking normal polymorphic dispatch. The bridge method has the erased signature and simply forwards (with a cast) to the real overriding method.
Is it accurate to say generic type information completely disappears at runtime? Why or why not?
No, not entirely. An individual object instance carries no memory of its type argument (ordinary instanceof/getClass() checks can't recover it), but the class file retains a Signature attribute describing the declared generic types of fields, methods, and supertype relationships, which reflection APIs like getGenericType() can read back.
Interview: Why can't you overload void process(List<String> list) and void process(List<Integer> list) in the same class?
Both signatures erase to the identical process(List) at the bytecode level, since the type argument is erased; the JVM would have no way to distinguish the two overloads, so the compiler rejects this as a duplicate method declaration.
Interview: Why is new T[10] illegal inside a generic class, when new T() for a non-generic instantiation problem has at least a Supplier<T> workaround?
Java arrays are reified — they track their actual element type at runtime for array-store checking — which directly conflicts with an erased T whose real type is unknown at that point; creating such an array would silently undermine the JVM's array type-safety guarantee, so it's disallowed at compile time (with (T[]) casts on Object[] as an unchecked, programmer-responsibility workaround).
Write code that demonstrates, at runtime, that a List<String> and a List<Double> share the same Class object.
List<String> a = new ArrayList<>(); List<Double> b = new ArrayList<>(); System.out.println(a.getClass() == b.getClass()); // true System.out.println(a.getClass().getName()); // java.util.ArrayList
Both print true and java.util.ArrayList, demonstrating that the specific type argument (String vs. Double) is erased and is not part of the runtime class identity.
What does Box's value field erase to, and why is value.doubleValue() legal after erasure?
Because T is bounded with extends Number, the field erases to type Number (not Object), so calling doubleValue() — a method declared on Number — remains legal in the erased bytecode.
Write code using reflection that reads back the declared generic type of a field List<String> names in a class Holder, demonstrating that erasure doesn't remove all generic metadata.
import java.lang.reflect.Field; class Holder { List<String> names; } public class Demo { public static void main(String[] args) throws NoSuchFieldException { Field field = Holder.class.getDeclaredField("names"); System.out.println(field.getType()); // interface java.util.List System.out.println(field.getGenericType()); // java.util.List<java.lang.String> } } getType() shows the erased type (List), but getGenericType() reads the class file's Signature attribute and correctly reports List<String>, proving the declared generic type is preserved in reflective metadata even though it's erased from ordinary runtime instance behavior.
What does this print, and why must the wildcard form List<?> be used instead of List<String> here?
Prints true. Only the unbounded wildcard form of instanceof against a parameterized type is legal, because type erasure means the JVM has no runtime representation of List<String> specifically to check against — List<?> only requires checking that the object is some List, which is checkable regardless of erasure.
public class Box<T extends Number> {
private T value;
public void set(T value) { this.value = value; }
public double asDouble() { return value.doubleValue(); }
}List<String> list = new ArrayList<>();
list.add("a");
boolean isListOfString = list instanceof List<?>;
System.out.println(isListOfString);