12.3 Type Erasure and Runtime Boundaries
After completing Invariance, Wildcards, and PECS, spend about 40 minutes examining what happens when generics cross compilation boundaries. You'll observe type erasure, bridge methods, reifiable types, array conflicts, and
Class<T>type tokens in practice.
Does Runtime Distinguish Box<String> from Box<Integer>?
The compiler prevents a wrong value from being written to Box<String>. At runtime, are Box<String> and Box<Integer> still different classes?
Although these two boxes carry different type relationships in the source code, instances of the same Box definition share one runtime Class object. 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 parameter T without an explicit upper bound erases to Object. Use this complete small container to observe it:
final class Box<T> {
private final T value;
Box(T value) { this.value = java.util.Objects.requireNonNull(value); }
T get() { return value; }
}The byte code descriptor of get returns Object. When the result of Box<String>.get() is needed as String, the compiler adds a checked cast at the call site. A discarded result may need no cast. 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:
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, an ordinary Box object has no automatic runtime tag recording the caller's concrete type argument.
2. Bridge Methods Maintain Polymorphism After Erasure
In the class file, the interface's T has been erased to a broader type while the subclass still declares a String return. The compiler generates a bridge method so calls through the erased interface still dispatch to the more specific implementation.
interface Source<T> {
T get();
}
final class StringSource implements Source<String> {
@Override
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().
Save the interface and implementation as StringSource.java, then inspect them from the command line:
javac --release 17 -encoding UTF-8 StringSource.java
javap -v StringSourceYou 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 overloads differ only by List<String> and List<Integer>, erasure gives them the same JVM descriptor, so the runtime cannot distinguish them.
The following two methods cannot be declared simultaneously:
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. Reifiable Types and the Limits of instanceof
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.
Object value = java.util.List.of("Hammer");
if (value instanceof List<?>) {
List<?> list = (List<?>) value;
}
// value instanceof List<String> // Compilation failedHere value has the compile-time type Object. The JVM can confirm that it is a List, but cannot infer that every element is String. Java 17 also permits some concrete generic checks that need no unchecked conversion, such as List<String> words = new ArrayList<>(); boolean concrete = words instanceof ArrayList<String>;. That check relies on the existing static List<String> guarantee and tests only the ArrayList class at runtime; it does not inspect the elements. 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:
// 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:
Object[] values = new String[1];
values[0] = 42; // ArrayStoreExceptionAssignment 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 caller supplies the runtime component type:
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.
A generic varargs call creates an array when it needs to pack arguments. Here the elements are List<T>, so the runtime array component is the erased List type:
@SafeVarargs
static <T> List<T> combine(List<T>... groups) {
List<T> result = new java.util.ArrayList<>();
for (List<T> group : groups) result.addAll(group);
return result;
}Removing SafeVarargs from this example produces a possible heap pollution warning with -Xlint:all. This body only reads the lists and copies their elements; it neither writes to nor leaks the parameter array. Raw types, unverified casts, or unsafe use of a generic varargs array can allow values that violate their static type promises.
@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. In Java 17, SafeVarargs is allowed on variable-arity constructors and static, final, or private variable-arity methods. An ordinary overridable instance method cannot use it. A List<List<T>> parameter is often easier to review when it fits the API.
7. Class<T> Supplies One Level of Runtime Type Information
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 String element argument inside List<String>.
The caller can explicitly provide a Class token:
final class TypeRegistry {
private final Map<Class<?>, Object> values = new HashMap<>();
<T> void put(Class<T> type, T value) {
requireReferenceType(type);
values.put(type, type.cast(java.util.Objects.requireNonNull(value, "value")));
}
<T> Optional<T> find(Class<T> type) {
requireReferenceType(type);
return Optional.ofNullable(values.get(type)).map(type::cast);
}
private static void requireReferenceType(Class<?> type) {
java.util.Objects.requireNonNull(type, "type");
if (type.isPrimitive()) {
throw new IllegalArgumentException("reference type token required");
}
}
}Class.cast performs runtime checks at the registration boundary, and the return type of find(String.class) also changes to Optional<String>. This registry rejects null tokens, null values, and primitive tokens so an empty Optional means only “unregistered.” Integer.class and int.class differ: int.class has the static type Class<Integer>, but its cast method cannot accept an Integer object. This single-threaded in-memory registry uses exact token lookup; registering Integer.class does not satisfy Number.class. Its snapshot is also shallow.
It can only express runtime Class. List<String> and List<Integer> have the same raw class, List.class. There is no List<String>.class literal. When a framework needs to save nested types, it typically uses Type, type token subclasses, or explicit type description objects; those descriptions retain type metadata but do not automatically inspect every collection element. The next chapter returns to this boundary.
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. Source, class files, and runtime objects retain different kinds of type information.
First, prepare a temporary directory:
chapter12_erasure_root=$(mktemp -d)
readonly chapter12_erasure_root
mkdir -p "$chapter12_erasure_root/out"
cd "$chapter12_erasure_root"Save as ErasureRuntimeDemo.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()),
"Different type arguments should share the same runtime class");
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<?>,
"An unbounded wildcard type supports this instanceof check");
System.out.println("Shared runtime class: " + words.getClass().getSimpleName());
System.out.println("Detected bridge method: " + bridgeFound);
System.out.println("Type registration: " + registry.find(String.class)
+ " / " + registry.find(Integer.class));
System.out.println("Erasure 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) {
requireReferenceType(type);
values.put(type, type.cast(java.util.Objects.requireNonNull(value, "value")));
}
<T> Optional<T> find(Class<T> type) {
requireReferenceType(type);
return Optional.ofNullable(values.get(type)).map(type::cast);
}
private static void requireReferenceType(Class<?> type) {
java.util.Objects.requireNonNull(type, "type");
if (type.isPrimitive()) {
throw new IllegalArgumentException("reference type token required");
}
}
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);
}
}
}javac --release 17 -encoding UTF-8 -Xlint:all -d out ErasureRuntimeDemo.java
java -cp out ErasureRuntimeDemo
javap -v -classpath out 'ErasureRuntimeDemo$StringSource'Expected main output follows. The subsequent javap output should contain ACC_BRIDGE, ACC_SYNTHETIC, and a bridge get method returning Object:
Shared runtime class: Box
Detected bridge method: true
Type registration: Optional[west] / Optional[3]
Erasure and runtime boundary checks passedSave the records and clean up:
cd
ls -ld -- "$chapter12_erasure_root"
rm -r -- "$chapter12_erasure_root"Separate the Compile-Time and Runtime 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
- JLS 4.6: Type Erasure.
- JLS 4.7: Reifiable Types.
- JLS 4.12.2: Heap Pollution.
- JLS 15.20.2: Runtime Type Comparison.
Method.isBridgeandClass.cast.
Next Step: Annotations and Reflection
Generics let the compiler check type relationships while runtime objects retain their erased classes. Annotations offer another information channel: metadata on declarations that reflection can inspect when retained at runtime.