Topic 8.6
Type Erasure and Its Limits
In one line
Java checks generic types at compile time and then erases them: T becomes its bound (usually Object), casts are inserted where values come out, and bridge methods keep overriding working. That's why new T(), new T[n], instanceof List<String> and overloading on List<String> vs List<Integer> are impossible.
Think of it like this
A parcel company puts a sticker "FRAGILE: GLASS" on a box while it's being packed and checked. Once the checks pass, the sticker is peeled off before the box goes on the lorry, so the drivers just see "a box". At delivery, the receiver opens it and says "this is glass" because the packing record says so. Type erasure is that: the compiler checks every List<String> using the sticker, then removes it, and the running program only sees List. The compiler adds a cast at the delivery point, trusting its earlier checks.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Type erasure
- The compiler removing generic type information after checking it, so the bytecode uses plain types like
ListandObject. - Erasure of a type
- The plain type something becomes:
List<String>erases toList,Tto its first bound orObject. - Reifiable type
- A type whose full information exists at run time, like
String,int[], rawListorList<?>.List<String>is not reifiable. - Bridge method
- An extra method the compiler generates so that overriding still works after erasure. It casts its argument and calls the real method.
- Heap pollution
- A variable of a generic type pointing at an object of the wrong type, like a
List<String>that contains anInteger. - Class token
- A
Class<T>object, likeString.class, passed into generic code so it can create or checkTvalues at run time. - Super type token
- An anonymous subclass such as
new TypeReference<List<String>>() {}used to capture a full generic type that reflection can read. - @SafeVarargs
- An annotation (Java 7) on a generic varargs method promising it doesn't misuse its hidden array, which silences the heap-pollution warning for callers.
Step by step
01Before and after erasure
The source on the left is what you write. The compiler checks it, then produces bytecode equivalent to the code on the right: T replaced by Object, type arguments dropped, and a cast added where get returns into a String variable.
The Box class file is identical whether you use it as Box<String> or Box<Integer>. Only the casts at call sites differ.
// What you write // What the bytecode means
class Box<T> { // class Box {
private T value; // private Object value;
T get() { return value; } // Object get() { return value; }
void set(T v) { value = v; } // void set(Object v) { value = v; }
} // }
Box<String> b = new Box<>(); // Box b = new Box();
b.set("tea"); // b.set("tea");
String s = b.get(); // String s = (String) b.get();02Bridge methods keep overriding working
Money implements Comparable<Money> declares compareTo(Money). But after erasure, the interface's method is compareTo(Object). Without help, Money wouldn't override it, and Collections.sort (which calls compareTo(Object)) would hit the abstract method.
So javac generates a bridge: compareTo(Object) that casts to Money and calls your compareTo(Money). javap shows both methods, and javap -v marks the bridge with ACC_BRIDGE, ACC_SYNTHETIC. A bridge's cast is also why passing a wrong type through a raw Comparable fails with ClassCastException inside a method you never wrote.
03Limit 1: no new T()
The bytecode for new needs a real class name. After erasure T is Object, and creating an Object is never what you meant, so the compiler refuses.
Workarounds: pass a Supplier<T> (() -> new Order() or Order::new), or a Class<T> token and call getDeclaredConstructor().newInstance(). The supplier is simpler and faster; the class token is used by frameworks that create objects from configuration.
04Limit 2: no generic arrays
Arrays check every store at run time (Topic 8.5's ArrayStoreException), so an array must know its exact element type. new T[10] has no real type to use, and new List<String>[3] could only record List, letting a List<Integer> in without any check. Both are rejected with generic array creation.
ArrayList itself stores an Object[] and casts each element when it's returned. You can do the same with (T[]) new Object[n] behind @SuppressWarnings("unchecked"), as long as that array never escapes your class. If you must hand out a real T[], take a Class<T> and use java.lang.reflect.Array.newInstance.
05Limit 3: no instanceof List<String>
At run time an object is just an ArrayList. The JVM can't tell whether it was created as ArrayList<String>, so obj instanceof List<String> can't be checked. Since Java 16 the compiler allows it only when the static type already guarantees it (for example a Collection<String> variable); from Object it says Object cannot be safely cast to List<String>. (Older compilers said illegal generic type for instanceof.)
Use obj instanceof List<?> list and then check elements one by one if you really need to.
06Limit 4: no overloading on type arguments
print(List<String>) and print(List<Integer>) both erase to print(List). A class file can't have two methods with the same name and descriptor, so the compiler reports a name clash.
Give them different names (printNames, printNumbers), or one generic method print(List<?> items).
07Limit 5: generic exceptions
catch clauses are matched at run time by class. catch (Problem<String> e) and catch (Problem<Integer> e) would be the same check, so Java forbids generic subclasses of Throwable entirely.
Put the extra data in a normal field with a fixed type, or make the exception non-generic and carry a typed payload object.
08What survives: Signature metadata and super type tokens
Declarations keep their generic types in the class file. Main.class.getDeclaredField("names").getGenericType() returns java.util.List<java.lang.String> for a field declared List<String> names. What an object doesn't know is its own type arguments.
The super type token trick exploits this. new TypeRef<Map<String, Integer>>() {} creates an anonymous subclass whose declared superclass is TypeRef<Map<String, Integer>>. getClass().getGenericSuperclass() returns that full type. Jackson's TypeReference and Spring's ParameterizedTypeReference work this way, so they can deserialize JSON into a real List<Order> instead of a list of maps.
Try it yourself
- 1
Prove the objects don't know their type
In the first example, add
List<Double> c = new ArrayList<>();and printa.getClass() == c.getClass(). Predict it first. Then declare a second fieldstatic List<Integer> ids;and print its generic type. - 2
Catch the pollution earlier
In the heap pollution example, wrap the list:
List<String> words = Collections.checkedList(new ArrayList<>(List.of("tea", "chai")), String.class);. Predict which line throws now and what the size line prints. - 3
Fix the money formatting
In the bridge example, change
toStringto useString.format("Rs %d.%02d", paise / 100, paise % 100). Predict the sorted list before running. - 4
Write new T() and read the error
In the workarounds example, add
static <T> T broken() { return new T(); }and compile. Then replace it with aSupplier<T>parameter.
Code & diagrams
The objects a and b don't know String or Integer. The field declaration and the anonymous subclass's superclass do, because they live in class-file metadata.
Expected output
same class: true
class name: java.util.ArrayList
field type: java.util.List<java.lang.String>
type params of ArrayList: [E]
captured: java.util.Map<java.lang.String, java.util.List<java.lang.Integer>>
instanceof List<?>: truePrinting the list works because toString only needs Object. The failure appears only when code relies on the element type, in a method that did nothing wrong.
Expected output
size after legacy call: 3
printing is fine: [tea, chai, 42]
ClassCastException inside totalLength, a line with no castExpected output
2 builders
created: ok
array type: String[], length 3
popped: BRs 120.0 shows a formatting bug in toString (paise % 100 is 0, printed as one digit). Fixing it with String.format("%02d") is a good exercise.
Expected output
compareTo(Money) bridge=false synthetic=false
compareTo(Object) bridge=true synthetic=true
[Rs 9.99, Rs 50.50, Rs 120.0]
bridge cast rejected a StringThis is the classic example from Effective Java. @SafeVarargs is allowed on static, final and (since Java 9) private methods.
Expected output
direct: String[]
pickTwo: ClassCastException, the array was really Object[]
safe listOf: [tea, chai]Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Create a T with new
Inside class Factory<T>, write T make() { return new T(); }.
Break #2
Overload on type arguments
Declare both static void print(List<String> names) and static void print(List<Integer> numbers).
Break #3
Create a generic array
Write List<String>[] arr = new List<String>[3];.
Myth vs fact
Myth
Generic type information is completely gone at run time.
Fact
Declarations keep it in the Signature attribute: fields, method signatures, superclasses and type parameters are visible through reflection. Only individual objects lose their type arguments.
Myth
Erasure makes generics slow because of all the casts.
Fact
The casts are the same ones you'd write by hand, and checkcast on a known type is very cheap; the JIT often removes it. The real cost of Java's generics is boxing primitives, not erasure.
Myth
You can never create an array of a generic type.
Fact
You can't write new T[n] or new List<String>[n], but new List<?>[n], Array.newInstance(type, n) and (T[]) new Object[n] (kept private) all work.
Myth
A ClassCastException always comes from a cast you wrote.
Fact
With generics, the cast may be compiler-generated: in a for-each loop, on get, or in a bridge method. If a line with no visible cast throws, look for heap pollution caused by a raw type or unchecked cast elsewhere.
When it breaks
JSON list deserialized without a type token
What you see
objectMapper.readValue(json, List.class) returns a List of LinkedHashMaps. Assigned to List<Order> through an unchecked conversion, it compiles, then throws ClassCastException: class java.util.LinkedHashMap cannot be cast to class Order in unrelated code that iterates the list.
Fix & prevent
Pass the full type: objectMapper.readValue(json, new TypeReference<List<Order>>() {}) (a super type token). In Spring's RestTemplate/WebClient, use ParameterizedTypeReference. Treat any unchecked warning on such lines as a bug.
Changing a generic bound breaks already-deployed callers
What you see
A library changes static <T> T pick(List<T> l) to static <T extends Comparable<T>> T pick(List<T> l). Source still compiles, but the erased descriptor changes from Object to Comparable. Services compiled against the old version fail at run time with NoSuchMethodError.
Fix & prevent
Treat erased signatures as part of the binary API. Keep the old erasure (<T extends Object & Comparable<? super T>>, like Collections.max) or add a new method. Run a binary-compatibility checker such as japicmp or revapi in CI.
Raw types in legacy code pollute a typed collection
What you see
An old module adds the wrong type through a raw List. Months later a new feature reads the list in a for-each loop and crashes with ClassCastException on a line with no cast, and the stack trace never mentions the legacy module.
Fix & prevent
Compile with -Xlint:unchecked -Werror so new unchecked code fails the build. Wrap shared collections with Collections.checkedList while migrating, so the failure happens at the polluting add.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Erasure rules are in JLS 4.6, and the reifiable types (those safe for
instanceof, arrays andcatch) are listed in JLS 4.7: primitives, non-generic types, raw types, parameterized types where every argument is an unbounded wildcard (List<?>), and arrays of those. - ▸
Bridge methods also handle covariant return types without generics: if
Dog.clone()returnsDogwhileObject.clone()returnsObject, javac emits a bridgeObject clone()that calls theDogone. Reflection-based frameworks must skip bridges (Method.isBridge()), or they see duplicate methods, a common source of bugs in hand-written bean mappers. - ▸
Since Java 16,
instanceofwith a parameterized type is allowed when the conversion is checked:Collection<String> c = ...; if (c instanceof List<String> l)compiles, because the static type already guarantees the element type. Only unchecked tests are rejected. Pattern matching (Phase 5 and Phase 13) follows the same rule. - ▸
C# reifies generics (the runtime creates a specialized type per value-type argument), so
List<int>has no boxing andtypeof(T)works. Java's erasure traded that for zero-cost migration. Project Valhalla's value classes and its research on specialized generics aim to bring primitive-friendly generics to Java in a compatible way; treat any detail as not yet final.
Remember this
- 1
Erasure is three steps. (1) Every type variable is replaced by its erasure: its first bound, or
Objectif unbounded (Topic 8.4). (2) Every parameterized type loses its arguments:List<String>becomesList. (3) The compiler inserts acheckcastwherever an erased value is used as a more specific type, and generates bridge methods where erasure would otherwise break overriding. The result is ordinary pre-Java-5 bytecode. - 2
Why? Migration compatibility. In 2004 there were millions of lines of code using raw
List. With erasure, the new genericArrayListis the same class file shape as the old one, so old and new code link together, and libraries could add generics without forcing all their users to recompile. The cost is that objects don't know their type arguments: anArrayList<String>and anArrayList<Integer>have the samegetClass(). - 3
Because
Tdoesn't exist at run time, anything that needs it at run time is forbidden:new T()(unexpected type ... found: type parameter T),new T[10]andnew List<String>[3](generic array creation),obj instanceof List<String>(Object cannot be safely cast to List<String>;instanceof List<?>is fine), astaticfield of typeT, catchingTor making a genericExceptionsubclass (a generic class may not extend java.lang.Throwable), and overloads that differ only by type arguments (name clash: ... have the same erasure). - 4
Each limit has a standard workaround. Need to create a
T? Pass aSupplier<T>(ArrayList::new) or a class tokenClass<T>and calltype.getDeclaredConstructor().newInstance(). Need aT[]? UseArray.newInstance(type, n), or do whatArrayListdoes: store anObject[]and cast elements on the way out. Need the full typeList<String>at run time? Use a super type token: an anonymous subclass likenew TypeReference<List<String>>() {}keeps its generic superclass in the class file, and Jackson, Gson and Spring read it with reflection. - 5
Heap pollution happens when a variable of a parameterized type points at an object that isn't of that type, for example a
List<String>that really contains anIntegerbecause of a raw type or an unchecked cast. Erasure means nothing notices until a compiler-inserted cast fails, often far away, on a line with no visible cast. Varargs of generic types create a hidden generic array and can pollute the heap too;@SafeVarargs(Java 7) is the author's promise that a method doesn't. - 6
Erasure isn't total: generic declarations are kept in the class file's
Signatureattribute. Reflection can read the declared type of a field (List<String> names), a method's generic return type, or a class's generic superclass. What's lost is the type argument of each object. Project Valhalla is exploring "specialized generics" for primitive and value types in a future Java; nothing has shipped yet, and erasure remains the model for reference types.
Explain it without notes
What is type erasure, step by step, and why did Java choose it?
What is a bridge method? Show when the compiler generates one.
List the things you can't do with a type parameter because of erasure, and a workaround for each.
What is heap pollution, and how can it cause a ClassCastException on a line with no cast?
If types are erased, how do frameworks like Jackson know to deserialize into List<Order>?
Practice
Write a class Registry<T> whose constructor takes a Class<T> and whose add(Object o) method accepts the object only if type.isInstance(o) is true. Add a string and an integer to a Registry<String> and print what's stored.
Write a generic Pool<T> that takes a Supplier<T> and creates objects on demand with borrow(); returned objects go back with giveBack(T) and are reused. Show that the second borrow after a give-back reuses the object.
Write a program that shows List<String> and List<Integer> have the same class, and that reads the generic type of a method's return value static Map<String, Integer> counts() through reflection.
Trade-offs
- ↔
Erasure gave Java painless migration and one class file per generic type (no code bloat), at the cost of runtime type information: no
new T(), no generic arrays, noinstanceof List<String>. - ↔
Workarounds have costs:
Class<T>tokens can't expressList<String>(onlyList.class), super type tokens need an anonymous class, andSupplier<T>adds a parameter to every constructor. Choose the simplest one that carries the information you need. - ↔
@SuppressWarnings("unchecked")is sometimes necessary (generic arrays inside a class), but each one switches off the guarantee that generic casts can't fail. Keep them on the smallest scope, with a comment proving safety.
Done when you can
Done when you can describe the steps of erasure and what
Tbecomes.Done when you can explain and spot a bridge method in
javapoutput.Done when you can list the erasure limits and a workaround for each.
Done when you can explain heap pollution and trace a cast-less
ClassCastExceptionto its cause.Done when you can use a
Class<T>token, aSupplier<T>and a super type token.Done when you know when
@SafeVarargsis appropriate.