Skip to content

12.3 (Part 2): Type Erasure and Runtime Boundaries

After completing Immutability, Wildcards, and PECS, spend about 40 minutes examining what happens when generics cross compilation boundaries. You'll observe type erasure, bridge methods, parameterized types, array conflicts, and Class<T> type tokens in practice.

The Toolbox at Runtime: Does It Still Recognize String?

Master Chen looked at Box<String> and Box<Integer> and asked, "Since the compiler can prevent putting the wrong things in, are these two boxes still two different classes when the program runs?"

Although these two boxes carry different type relationships in the source code, they typically share the same Box.class at runtime. This does not mean that generics are just comments: the compiler has already performed type checks, and the necessary conversions and bridge methods have been written into the class file. The key is to distinguish which information is used during compilation and which information remains available for querying at runtime.

1. Erasure Happens After Checking

A type T without an explicit upper bound is typically erased to Object:

java
final class Box<T> {
    T get() { /* ... */ }
}

The byte code descriptor of get returns Object. When calling Box<String>.get(), the compiler inserts a check cast to String at the call site. If declared as <T extends Number>, T is erased to Number; when multiple upper bounds are present, the leftmost one is used.

Box<String> and Box<Integer> thus share the same runtime class:

java
boolean sameClass =
        new Box<>("Hammer").getClass().equals(new Box<>(42).getClass());

The class file remains available with properties like Signature to save partial generic information about the declaration. Reflection can read the type parameters from the fields, methods, or parent class declarations. However, the ordinary Box object usually does not know whether the caller originally wrote String or Integer.

2. Bridge Methods Maintain Polymorphism After Erasure

Once the tooling leaves the source code workbench and enters the .class files, the interface's T has been erased into a broader type. Subclasses still return String. The compiler quietly adds an adapter plate, allowing the old interface and more specific implementation to still fit together.

java
interface Source<T> {
    T get();
}

final class StringSource implements Source<String> {
    public String get() {
        return "Hammer";
    }
}

The source's T is erased to Object, but the implementation method returns String. The compiler generates a synthetic bridge method for StringSource, allowing the erased Object get() to still dispatch to the source's String get().

You can observe this from the command line:

bash
javac --release 17 StringSource.java
javap -v StringSource

You can also check Method.isBridge() in reflection. Occasionally, the coverage or call stack may show methods that don't exist in the source code. Don't immediately suspect that the bytecode has been tampered with. First, check if it's a bridge method generated by the compiler.

3. Erasing Can Cause Overloading Conflicts

If two boxes are distinguished only by List<String> and List<Integer>, after the labels are erased, the shelf is left with two entries named List. The JVM descriptor cannot determine which one to choose.

The following two methods cannot be declared simultaneously:

java
void process(List<String> values) {}
void process(List<Integer> values) {}

After erasure, they both become process(List). The JVM method descriptor cannot distinguish between them. Alternatives include using different method names, adding non-generic parameters with different types, or merging the logic into a single generic method. Simply having different return types is insufficient to constitute Java overloading.

4. Only Reifiable Types Can Support Complete Runtime Checks

A type that retains sufficient information at runtime is called a reifiable type. Examples include primitive types, non-generic classes, raw types, unbounded wildcard parameterized types, and arrays composed of reifiable components.

java
if (value instanceof List<?>) {
    List<?> list = (List<?>) value;
}

// value instanceof List<String> // Compilation failed

The JVM can confirm that an object is a List, but it cannot use this information to guarantee that all elements are Strings. If an interface requires this guarantee, it must iterate through the elements and decide whether to reject the entire input or report the specific location when an incompatible element is encountered.

The same boundary also explains these limitations:

java
// new T()
// T.class
// new T[10]
// new List<String>[10]

The compiler does not know the constructor of T, and there is no single runtime Class object that represents any T. Generic classes cannot directly or indirectly inherit Throwable, otherwise the catch clause cannot distinguish exceptions based on the erased type arguments.

5. Array Covariance and Generic Invariance

Arrays retain their component type at runtime:

java
Object[] values = new String[1];
values[0] = 42; // ArrayStoreException

Assignment compiles because String[] is a subtype of Object[]; however, the array object knows its components are String, so it rejects integers at runtime. Generics, on the other hand, reject the assignment from List<Integer> to List<Number> at compile time.

When needing to obtain an array from a generic collection, the runtime component type is passed to the caller:

java
static <T> T[] toArray(
        List<T> values,
        java.util.function.IntFunction<T[]> factory) {
    return values.toArray(factory);
}

String[] tools = toArray(List.of("Hammer"), String[]::new);

A general-purpose mutable sequence typically prefers List<T>. Only when the API boundary explicitly requires an array does the caller assume responsibility for the component type and the additional rules introduced by array covariance.

6. Raw type, unchecked cast, and generic varargs

The type arguments must be reference types, so you must use List<Integer>, not List<int>. Boxing integers into generic collections introduces conversion and memory overhead; large-scale numerical computations require benchmarking, not relying on the default standard collections being fast enough.

The following generic varargs create a view of the erased array:

java
static <T> List<T> combine(List<T>... groups) {
    // ...
}

The compiler may report possible heap pollution. Raw types, unchecked casts, and leaks or writes to generic varargs arrays all violate the static type promise on the heap.

@SafeVarargs is the author's commitment to the safety of the method body, not a button to disable warnings. It can only be used when you are certain the method neither writes incompatible values nor passes the varargs array to untrusted code. When it is possible to change to List<List<T>>, ordinary collection parameters are usually easier to review.

7. Class<T> Return a Layer's Runtime Type to the API

Some repository checks must occur at runtime. Rather than pretending the box remembers the full generic argument, let the caller pass a Class<T> type token. It can verify the specific runtime class of a layer, but cannot recover the List<String> nested in the String.

The caller can explicitly provide a Class token:

java
final class TypeRegistry {
    private final Map<Class<?>, Object> values = new HashMap<>();

    <T> void put(Class<T> type, T value) {
        values.put(type, type.cast(value));
    }

    <T> Optional<T> find(Class<T> type) {
        return Optional.ofNullable(values.get(type)).map(type::cast);
    }
}

Class.cast performs runtime checks at the registration boundary, and the return type of find(String.class) also changes to Optional<String>. This pattern is suitable for registering configurations, processors, or services by runtime class.

It can only express runtime Class. List<String> and List<Integer> both correspond to List.class, and cannot obtain Class<List<String>>. When a framework needs to save nested types, it typically uses Type, type token subclasses, or explicit type description objects; this boundary will be encountered again when we discuss reflection in the next chapter.

8. Disassemble Class Files to Verify Three Boundaries

This time, you can't just run main. First, let javac inspect the source code, then use javap to open the class file and look for bridge methods, and finally use runtime assertions to check the type token. Master Chen's tool chest takes you from source code to the JVM, leaving different type information at each stop.

First, prepare a temporary directory:

bash
chapter12_erasure_root=$(mktemp -d)
readonly chapter12_erasure_root
mkdir -p "$chapter12_erasure_root/out"
cd "$chapter12_erasure_root"

Save as ErasureRuntimeDemo.java:

java
import java.lang.reflect.Method;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;

public class ErasureRuntimeDemo {
    public static void main(String[] args) {
        Box<String> words = new Box<>("Hammer");
        Box<Integer> counts = new Box<>(3);
        check(words.getClass().equals(counts.getClass()),
                "Real-time classes should be shared among real-time parameters of different types");

        boolean bridgeFound = false;
        for (Method method : StringSource.class.getDeclaredMethods()) {
            if (method.isBridge() && method.isSynthetic()) {
                bridgeFound = true;
            }
        }
        check(bridgeFound, "The compiler should generate synthetic bridge method");
        Source<String> source = new StringSource();
        checkEquals("Hammer", source.get());

        TypeRegistry registry = new TypeRegistry();
        registry.put(String.class, "west");
        registry.put(Integer.class, 3);
        checkEquals(Optional.of("west"), registry.find(String.class));
        checkEquals(Optional.of(3), registry.find(Integer.class));
        checkEquals(Optional.empty(), registry.find(Long.class));

        Map<Class<?>, Object> snapshot = registry.snapshot();
        expect(UnsupportedOperationException.class,
                () -> snapshot.put(Long.class, 9L));

        Object value = java.util.List.of("Hammer");
        check(value instanceof java.util.List<?>,
                "Unbounded wildcard types can do instanceof Check");

        System.out.println("Shared runtime class:" + words.getClass().getSimpleName());
        System.out.println("Detected bridged method:" + bridgeFound);
        System.out.println("Type registration:" + registry.find(String.class)
                + " / " + registry.find(Integer.class));
        System.out.println("Erase and runtime boundary checks passed");
    }

    interface Source<T> {
        T get();
    }

    static final class StringSource implements Source<String> {
        @Override
        public String get() {
            return "Hammer";
        }
    }

    static final class Box<T> {
        private final T value;
        Box(T value) {
            this.value = Objects.requireNonNull(value, "value");
        }
        T get() {
            return value;
        }
    }

    static final class TypeRegistry {
        private final Map<Class<?>, Object> values = new HashMap<>();

        <T> void put(Class<T> type, T value) {
            values.put(type, type.cast(value));
        }

        <T> Optional<T> find(Class<T> type) {
            return Optional.ofNullable(values.get(type)).map(type::cast);
        }

        Map<Class<?>, Object> snapshot() {
            return Collections.unmodifiableMap(new HashMap<>(values));
        }
    }

    static <T extends Throwable> T expect(
            Class<T> type, Runnable action) {
        try {
            action.run();
        } catch (Throwable failure) {
            if (type.isInstance(failure)) return type.cast(failure);
            throw new AssertionError(
                    "expected=" + type + ", actual=" + failure, failure);
        }
        throw new AssertionError("expected exception=" + type.getName());
    }

    static void check(boolean condition, String message) {
        if (!condition) throw new AssertionError(message);
    }

    static void checkEquals(Object expected, Object actual) {
        if (!Objects.equals(expected, actual)) {
            throw new AssertionError(
                    "expected=" + expected + ", actual=" + actual);
        }
    }
}
bash
javac --release 17 -Xlint:all -d out ErasureRuntimeDemo.java
java -cp out ErasureRuntimeDemo

Expected output:

text
Shared runtime class: Box
Detected bridge method: true
Type registration: Optional[west] / Optional[3]
Erasure and runtime boundary checks passed

Save the records and clean up:

bash
cd
ls -ld -- "$chapter12_erasure_root"
rm -r -- "$chapter12_erasure_root"

Leaving the Workshop Before Clearing Two Boundaries

The compiler first verifies the parametric type relationships during compilation, and then executes the code at runtime based on the erased classes and necessary checks. Bridge methods maintain polymorphism, List<?> allows checking "it is a List", Class<T> lets the caller explicitly return a layer of type information; neither can they recover List<String>'s nested actual parameters out of thin air.

Finally, let the compiler reject four writing styles: overloading List<String> with List<Integer>, instanceof List<String>, new T[10], and generic exception classes. Use javap -v to compare with the reflection-detectable bridge method. The source code and bytecode are then connected.

Referencing Java 17's Official Rules

Next Step: Annotations and Reflection

Generic types explain how compilers retain type relationships while erasing most type arguments at runtime. The next chapter will show another information pathway: annotations write metadata into declarations, and reflection reads classes, methods, and fields at runtime.

Continue learning Chapter 13: Annotations

Built with VitePress | Software Systems Atlas