Java generics are a compile-time-only feature, meaning that at runtime, the JVM doesn’t know about the type parameters you’ve specified.

Let’s see this in action. Imagine we have a Box class that can hold an Integer or a String:

class Box<T> {
    private T item;

    public void set(T item) {
        this.item = item;
    }

    public T get() {
        return item;
    }
}

public class GenericsDemo {
    public static void main(String[] args) {
        Box<Integer> integerBox = new Box<>();
        integerBox.set(123);
        // integerBox.set("hello"); // Compile-time error!

        Box<String> stringBox = new Box<>();
        stringBox.set("Hello Generics");
        // stringBox.set(456); // Compile-time error!

        // Let's try to see the type at runtime
        System.out.println("integerBox type: " + integerBox.getClass().getName());
        System.out.println("stringBox type: " + stringBox.getClass().getName());
    }
}

If you run this GenericsDemo code, you’ll see:

integerBox type: Box
stringBox type: Box

Notice that both Box<Integer> and Box<String> appear as just Box at runtime. This is because of type erasure. When the Java compiler encounters generic types, it performs a process called type erasure. This means it replaces all type parameters with their upper bounds (or Object if there’s no explicit bound). The generic type information is effectively removed.

The primary problem this solves is backward compatibility. When generics were introduced in Java 5, the language designers wanted to ensure that existing Java code that didn’t use generics could still interoperate with new generic code. If type information were preserved at runtime, older code might not understand it, breaking existing applications. Type erasure allows older code to treat generic types as their raw types (e.g., Box<Integer> is treated as Box), thus maintaining compatibility.

Internally, the compiler does a few things during type erasure:

  1. Replaces type parameters with their bounds: For Box<T>, T is replaced with Object. If you had Box<T extends Number>, T would be replaced with Number.
  2. Inserts type casts: When you retrieve an item from a generic collection or class, the compiler automatically inserts casts to ensure type safety. For example, integerBox.get() returns Object after erasure, but the compiler knows T was Integer and inserts an (Integer) cast before returning it to your code.
  3. Generates bridge methods: In certain inheritance scenarios, the compiler may generate synthetic "bridge" methods to maintain polymorphism.

Consider this scenario: you have a List<String> and a List<Integer>. After erasure, both are just List. The compiler handles the type safety by inserting casts when you retrieve elements. If you try to add a String to a List<Integer> at compile time, you’ll get an error. The compiler prevents this before erasure. At runtime, the List doesn’t "remember" it was supposed to hold only Integers.

The consequence of type erasure is that you cannot perform certain operations that rely on knowing the specific type parameter at runtime. For instance, you cannot check if (obj instanceof T) or create an array of T like new T[10]. These operations require runtime type information that has been erased.

Here’s a practical limitation: if you try to create a generic array, you’ll hit a compile-time error.

// This will NOT compile:
// List<String>[] stringLists = new List<String>[10];
// List<Integer> intList = new ArrayList<Integer>();
// Object obj = intList;
// List<String>[] anotherStringLists = (List<String>[]) obj; // Heap pollution warning and ClassCastException at runtime if not careful

The compiler prevents new T[10] because it doesn’t know what T is at runtime. It would have to be new Object[10], and then casting it to T[] would be unsafe. The compiler issues a warning about "unchecked casts" or "heap pollution" when you try to circumvent this, because at runtime, you could end up with an array that contains elements of different types, despite the compile-time intent.

The most surprising implication of type erasure is how it affects method overloading. If you have two methods that would have the same signature after type erasure, the compiler will reject them as duplicates. For example, you can’t have void process(List<String> list) and void process(List<Integer> list) in the same class because after erasure, both become void process(List list). The compiler enforces uniqueness based on the erased types.

This means that when you write generic code, you’re essentially writing code that the compiler then "simplifies" by removing the generic type information before it’s compiled into bytecode. The safety is enforced at compile time, not at runtime.

The next concept you’ll likely encounter is understanding how to work around type erasure limitations, particularly with collections and when dealing with reflections.

Want structured learning?

Take the full Java course →