Type Erasure in Java Generics and Its Limitations

Introduction Generics are checked by the compiler - but at runtime, a List<String> and a List<Integer> are exactly the same class. The compiler removes the type arguments after checking them, a process called type erasure. It explains several rules of generics which otherwise seem arbitrary. This tutorial explains type erasure in Java generics and its limitations. We will start with what erasure is and why Java uses it. We will then see what the compiler turns generic code into, and prove at runtime that the type arguments are gone. Next we will go through the limitations erasure causes - no primitives, no new T(), no generic arrays, no instanceof with type arguments, and no overloading by type argument - and the usual workarounds. Lastly, we will look at heap pollution and what @SafeVarargs means.

(Note - This tutorial completes the generics module, which starts with Introduction to Java GenericsClick to read tutorial: Introduction to Java Generics: Why and How.) What is type erasure? When generics were added in Java 5, millions of lines of existing code - and the JVM itself - already worked with plain collections such as List. To stay compatible, Java's designers implemented generics entirely in the compiler. The compiler -
  1. checks all the type arguments and reports any misuse as a compile error;
  2. then erases them: each type parameter is replaced by its bound - Object for <T>, or Number for <T extends Number> - and List<String> becomes just List;
  3. and inserts casts where values come out of generic code, such as (String) list.get(0).
The resulting bytecode is essentially what a programmer would have written by hand before Java 5 - which is why old and new code work together. The cost is that the JVM never knows the type arguments. Seeing erasure at runtime
Type arguments do not exist at runtime
//ErasureDemo.java
import java.util.ArrayList;
import java.util.List;

public class ErasureDemo {
  static class Box<T> {
    T value;
  }

  static class NumberBox<T extends Number> {
    T value;
  }

  public static void main(String[] args) throws Exception {
    List<String> strings = new ArrayList<>();
    List<Integer> integers = new ArrayList<>();
    System.out.println("strings class  : " + strings.getClass().getName());
    System.out.println("integers class : " + integers.getClass().getName());
    System.out.println("same class?    : " + (strings.getClass() == integers.getClass()));

    System.out.println("Box.value type       : " + Box.class.getDeclaredField("value").getType().getName());
    System.out.println("NumberBox.value type : " + NumberBox.class.getDeclaredField("value").getType().getName());
  }
}
 OUTPUT of the above code
strings class  : java.util.ArrayList
integers class : java.util.ArrayList
same class?    : true
Box.value type       : java.lang.Object
NumberBox.value type : java.lang.Number
Explanation of the code
  • Both lists are simply java.util.ArrayList at runtime - there is no ArrayList<String> class.
  • The field T value is stored as an Object in Box, and as a Number in NumberBox - each type parameter was replaced by its bound.
The compiler's inserted casts can be seen with javap -c, which shows the bytecode, as described in How Java Code RunsClick to read tutorial: How Java Code Runs: Bytecode, Class Loader and JIT -
A generic call, before erasure
//CastInserted.java
import java.util.List;

public class CastInserted {
  public static void main(String[] args) {
    List<String> names = List.of("Tom");
    String first = names.get(0);
    System.out.println(first);
  }
}
javap -c CastInserted
 OUTPUT of the above command
Compiled from "CastInserted.java"
public class CastInserted {
  public CastInserted();
    Code:
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return

  public static void main(java.lang.String[]);
    Code:
         0: ldc           #7                  // String Tom
         2: invokestatic  #9                  // InterfaceMethod java/util/List.of:(Ljava/lang/Object;)Ljava/util/List;
         5: astore_1
         6: aload_1
         7: iconst_0
         8: invokeinterface #15,  2           // InterfaceMethod java/util/List.get:(I)Ljava/lang/Object;
        13: checkcast     #19                 // class java/lang/String
        16: astore_2
        17: getstatic     #21                 // Field java/lang/System.out:Ljava/io/PrintStream;
        20: aload_2
        21: invokevirtual #27                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        24: return
}
Look at instructions 8 and 13 of main(): the call is to List.get, which returns Object (Ljava/lang/Object;), followed by a checkcast to String that the compiler added - exactly the cast we would have written without generics.
Limitation 1 - no primitive type arguments After erasure, a type parameter becomes Object (or its bound), and primitives such as int are not objects. So List<int> is not allowed; use List<Integer> and autoboxing, as shown in the introduction to genericsClick to read tutorial: Introduction to Java Generics: Why and How. For large amounts of numeric data, primitive arrays or IntStream avoid the boxing cost. Limitation 2 - no new T(), and no new T[] At runtime, generic code does not know what T is, so it cannot create an object or an array of it -
Creating objects and arrays of a type parameter
//CannotCreate.java
public class CannotCreate<T> {
  T create() {
    return new T();
  }

  T[] createArray(int size) {
    return new T[size];
  }
}
 OUTPUT on compiling the above code
CannotCreate.java:4: error: unexpected type
    return new T();
               ^
  required: class
  found:    type parameter T
  where T is a type-variable:
    T extends Object declared in class CannotCreate
CannotCreate.java:8: error: generic array creation
    return new T[size];
           ^
2 errors
The usual workarounds are to let the caller supply what is needed - a factory, such as a Supplier<T> or a constructor reference, or a Class<T> object, sometimes called a type token - or to use a List<T> instead of an array.
Creating objects and arrays with a Supplier and a Class token
//Workarounds.java
import java.lang.reflect.Array;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Supplier;

public class Workarounds {
  static <T> List<T> fill(Supplier<T> factory, int count) {
    List<T> result = new ArrayList<>();
    for (int i = 0; i < count; i++) {
      result.add(factory.get());           // the supplier creates each T
    }
    return result;
  }

  @SuppressWarnings("unchecked")
  static <T> T[] newArray(Class<T> type, int size) {
    return (T[]) Array.newInstance(type, size);   // the Class object knows the real type
  }

  public static void main(String[] args) {
    List<StringBuilder> builders = fill(StringBuilder::new, 3);
    System.out.println("created " + builders.size() + " " + builders.get(0).getClass().getSimpleName() + "s");

    String[] names = newArray(String.class, 2);
    names[0] = "Tom";
    names[1] = "Harry";
    System.out.println(names.getClass().getSimpleName() + " of length " + names.length + ": " + String.join(", ", names));
  }
}
 OUTPUT of the above code
created 3 StringBuilders
String[] of length 2: Tom, Harry
Limitation 3 - no instanceof or casts with type arguments Because List<String> and List<Integer> are the same class at runtime, obj instanceof List<String> cannot be checked and does not compile. You can only test for the erased type - obj instanceof List<?> - and a cast such as (List<String>) obj is an unchecked cast which the JVM cannot verify. If the cast was wrong, the error appears later, where an element is used -
An unchecked cast fails later, not at the cast
//UncheckedCast.java
import java.util.List;

public class UncheckedCast {
  @SuppressWarnings("unchecked")
  public static void main(String[] args) {
    Object data = List.of(1, 2, 3);                 // really a List<Integer>
    System.out.println("data instanceof List<?> : " + (data instanceof List<?>));

    List<String> names = (List<String>) data;       // unchecked - the JVM cannot verify it
    System.out.println("Cast succeeded, size " + names.size());
    String first = names.get(0);                    // the inserted checkcast fails here
  }
}
 OUTPUT of the above code
data instanceof List<?> : true
Cast succeeded, size 3
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')
	at UncheckedCast.main(UncheckedCast.java:12)
The cast "succeeded", and the failure came one line later, at the cast the compiler inserted when get() returned an Integer. Treat every "unchecked" compiler warning as a possible bug. Limitation 4 - no overloading by type argument Two methods which differ only in their type arguments have the same signature after erasure, so they cannot be overloaded -
Overloads that clash after erasure
//ErasureClash.java
import java.util.List;

public class ErasureClash {
  static void print(List<String> names) { }
  static void print(List<Integer> numbers) { }
}
 OUTPUT on compiling the above code
ErasureClash.java:6: error: name clash: print(List<Integer>) and print(List<String>) have the same erasure
  static void print(List<Integer> numbers) { }
              ^
1 error
Give such methods different names - printNames() and printNumbers() - as the JDK does with Collectors.toList() and toSet(). (The rules for overloading are in Method OverloadingClick to read tutorial: Method Overloading in Java with Examples.) Two more consequences: a class cannot have static fields of type T, because there is one static field for all Box<...> types, and generic classes cannot extend Throwable, because a catch block cannot tell MyException<String> from MyException<Integer>. Heap pollution and @SafeVarargs Heap pollution is the state where a variable of a generic type refers to an object of the wrong type - like the List<String> above, which really held integers. It can only happen through unchecked operations: raw types, unchecked casts, and generic varargsClick to read tutorial: Java Varargs: Variable-Length Arguments with Examples. A varargs parameter such as T... items is an array, and since a T[] cannot really be created, the compiler warns about "possible heap pollution". A method which only reads the array, and never stores into it or lets it escape, is safe - and can be marked @SafeVarargs to promise this and silence the warning for callers.
A safe generic varargs method
//SafeVarargsDemo.java
import java.util.ArrayList;
import java.util.List;

public class SafeVarargsDemo {
  @SafeVarargs
  static <T> List<T> listOf(T... items) {          // only reads items, so it is safe
    List<T> result = new ArrayList<>();
    for (T item : items) {
      result.add(item);
    }
    return result;
  }

  public static void main(String[] args) {
    List<String> names = listOf("Tom", "Harry", "Ethan");
    List<Integer> numbers = listOf(1, 2, 3);
    System.out.println(names + " " + numbers);
  }
}
 OUTPUT of the above code
[Tom, Harry, Ethan] [1, 2, 3]
List.of(), Arrays.asList() and Stream.of() are all declared @SafeVarargs for this reason. Use the annotation only when the method really is safe - it switches off a warning that is there to protect you. Summary In this tutorial we first saw that Java implements generics through type erasure: the compiler checks type arguments, replaces type parameters with their bounds, and inserts casts - for compatibility with pre-Java 5 code. We then proved at runtime that List<String> and List<Integer> are the same class, and found the inserted checkcast with javap. Next we went through the limitations erasure causes - no primitive type arguments, no new T() or new T[] (with Supplier and Class workarounds), no instanceof with type arguments, unchecked casts, and no overloading by type argument. Lastly, we looked at heap pollution and @SafeVarargs. This completes the generics module.