Command Palette

Search for a command to run...

PHASE 8Intermediate Java 5+ ~36 min· topic 6 of 6

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 List and Object.
Erasure of a type
The plain type something becomes: List<String> erases to List, T to its first bound or Object.
Reifiable type
A type whose full information exists at run time, like String, int[], raw List or List<?>. 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 an Integer.
Class token
A Class<T> object, like String.class, passed into generic code so it can create or check T values 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.

Main.javawhole filejava
// 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();
Before and after erasurediagram
Rendering diagram…

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.

terminal
$ javac Money.java
javap -c Money # (bridge method only)
── expected output ──
public int compareTo(java.lang.Object);
Code:
0: aload_0
1: aload_1
2: checkcast #8 // class Money
5: invokevirtual #19 // Method compareTo:(LMoney;)I
8: ireturn

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.

terminal
$ javac Main.java
── expected output ──
Main.java:3: error: unexpected type
return new T();
^
required: class
found: type parameter T
where T is a type-variable:
T extends Object declared in class Factory
1 error

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.

terminal
$ javac Main.java
── expected output ──
Main.java:2: error: generic array creation
private T[] items = new T[10];
^
1 error

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.

terminal
$ javac --release 17 Main.java
── expected output ──
Main.java:5: error: Object cannot be safely cast to List<String>
return obj instanceof List<String>;
^
1 error

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).

terminal
$ javac Main.java
── expected output ──
Main.java:5: error: name clash: print(List<Integer>) and print(List<String>) have the same erasure
static void print(List<Integer> numbers) { }
^
1 error

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.

terminal
$ javac Main.java
── expected output ──
Main.java:1: error: a generic class may not extend java.lang.Throwable
class Problem<T> extends Exception {
^
1 error

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. 1

    Prove the objects don't know their type

    In the first example, add List<Double> c = new ArrayList<>(); and print a.getClass() == c.getClass(). Predict it first. Then declare a second field static List<Integer> ids; and print its generic type.

  2. 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. 3

    Fix the money formatting

    In the bridge example, change toString to use String.format("Rs %d.%02d", paise / 100, paise % 100). Predict the sorted list before running.

  4. 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 a Supplier<T> parameter.

Code & diagrams

Same class at run time, but declarations keep their generics New tab

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.

Sign in to run this example in your browser.

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<?>: true
Heap pollution: the cast fails far from the bug New tab

Printing 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.

Sign in to run this example in your browser.

Expected output

size after legacy call: 3
printing is fine: [tea, chai, 42]
ClassCastException inside totalLength, a line with no cast
Workarounds: Supplier, Class token, and arrays New tab
Sign in to run this example in your browser.

Expected output

2 builders
created: ok
array type: String[], length 3
popped: B
Bridge methods, seen through reflection New tab

Rs 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.

Sign in to run this example in your browser.

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 String
Generic varargs: the hidden array and @SafeVarargs New tab

This is the classic example from Effective Java. @SafeVarargs is allowed on static, final and (since Java 9) private methods.

Sign in to run this example in your browser.

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(); }.

terminal
$ javac Main.java
── what you'll see ──
Main.java:3: error: unexpected type
return new T();
^
required: class
found: type parameter T
where T is a type-variable:
T extends Object declared in class Factory
1 error

Break #2

Overload on type arguments

Declare both static void print(List<String> names) and static void print(List<Integer> numbers).

terminal
$ javac Main.java
── what you'll see ──
Main.java:5: error: name clash: print(List<Integer>) and print(List<String>) have the same erasure
static void print(List<Integer> numbers) { }
^
1 error

Break #3

Create a generic array

Write List<String>[] arr = new List<String>[3];.

terminal
$ javac Main.java
── what you'll see ──
Main.java:4: error: generic array creation
List<String>[] arr = new List<String>[3];
^
1 error

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 and catch) 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() returns Dog while Object.clone() returns Object, javac emits a bridge Object clone() that calls the Dog one. 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, instanceof with 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 and typeof(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. 1

    Erasure is three steps. (1) Every type variable is replaced by its erasure: its first bound, or Object if unbounded (Topic 8.4). (2) Every parameterized type loses its arguments: List<String> becomes List. (3) The compiler inserts a checkcast wherever 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. 2

    Why? Migration compatibility. In 2004 there were millions of lines of code using raw List. With erasure, the new generic ArrayList is 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: an ArrayList<String> and an ArrayList<Integer> have the same getClass().

  3. 3

    Because T doesn'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] and new List<String>[3] (generic array creation), obj instanceof List<String> (Object cannot be safely cast to List<String>; instanceof List<?> is fine), a static field of type T, catching T or making a generic Exception subclass (a generic class may not extend java.lang.Throwable), and overloads that differ only by type arguments (name clash: ... have the same erasure).

  4. 4

    Each limit has a standard workaround. Need to create a T? Pass a Supplier<T> (ArrayList::new) or a class token Class<T> and call type.getDeclaredConstructor().newInstance(). Need a T[]? Use Array.newInstance(type, n), or do what ArrayList does: store an Object[] and cast elements on the way out. Need the full type List<String> at run time? Use a super type token: an anonymous subclass like new TypeReference<List<String>>() {} keeps its generic superclass in the class file, and Jackson, Gson and Spring read it with reflection.

  5. 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 an Integer because 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. 6

    Erasure isn't total: generic declarations are kept in the class file's Signature attribute. 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

01

What is type erasure, step by step, and why did Java choose it?

02

What is a bridge method? Show when the compiler generates one.

03

List the things you can't do with a type parameter because of erasure, and a workaround for each.

04

What is heap pollution, and how can it cause a ClassCastException on a line with no cast?

05

If types are erased, how do frameworks like Jackson know to deserialize into List<Order>?

Practice

01

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.

02

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.

03

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, no instanceof List<String>.

  • ↔

    Workarounds have costs: Class<T> tokens can't express List<String> (only List.class), super type tokens need an anonymous class, and Supplier<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 T becomes.

  • Done when you can explain and spot a bridge method in javap output.

  • 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 ClassCastException to its cause.

  • Done when you can use a Class<T> token, a Supplier<T> and a super type token.

  • Done when you know when @SafeVarargs is appropriate.