Contents

Java generics are purely a compile-time construct. The compiler enforces type constraints and inserts casts, then removes all generic type information before generating bytecode — a process called type erasure. At runtime, List<String> and List<Integer> are both just List; they share a single Class object. This was a deliberate compatibility decision so that generic code could interoperate with pre-generics (Java 1.4) code without requiring changes to the JVM.

// TYPE ERASURE: generic type parameters are removed at compile time. // The bytecode contains no information about T, E, K, V, etc. // Source code (what you write): List<String> names = new ArrayList<>(); names.add("Alice"); String first = names.get(0); // no cast needed — compiler handles it // After erasure (what the bytecode looks like): List names2 = new ArrayList(); // raw type names2.add("Alice"); String first2 = (String) names2.get(0); // compiler inserts cast for you // Proof — these two method signatures are IDENTICAL after erasure: // void process(List<String> list) { ... } // void process(List<Integer> list) { ... } // → compile error: erasure of both is void process(List list) — duplicate! // Proof — List<String> and List<Integer> are the same class at runtime: List<String> strings = new ArrayList<>(); List<Integer> ints = new ArrayList<>(); System.out.println(strings.getClass() == ints.getClass()); // true System.out.println(strings.getClass().getName()); // java.util.ArrayList // Proof — you cannot get the type argument from a List at runtime: List<String> myList = new ArrayList<>(); // myList.getClass().getTypeParameters()[0] gives "E" (the name), NOT "String" java.lang.reflect.TypeVariable<?>[] params = myList.getClass().getTypeParameters(); System.out.println(params[0].getName()); // "E" — the parameter name, not the argument // What IS retained at runtime (not erased): // - Class declarations: class Pair<A, B> — you can get A and B names via reflection // - Field generic types (if declared with concrete types) // - Method return/parameter types with concrete generic bounds // These are stored in the class file as "Signature" attributes for reflection use.

The compiler replaces every type parameter with its erasure: an unbounded T becomes Object, and T extends Comparable becomes Comparable (the leftmost bound). Wherever the source code relies on the generic type — a return value or field read — the compiler automatically inserts a CHECKCAST bytecode instruction. You can inspect the erasure directly by running javap -verbose on the compiled class file and examining the method descriptors, which contain only raw types.

// Erasure rules: // T (unbounded) → Object // T extends Comparable<T> → Comparable (leftmost bound) // T extends A & B → A (leftmost bound) // Generic class source: class Box<T> { private T value; Box(T value) { this.value = value; } T getValue() { return value; } static <T extends Comparable<T>> T max(T a, T b) { return a.compareTo(b) >= 0 ? a : b; } } // After erasure — equivalent bytecode: // class Box { // private Object value; // T → Object // Box(Object value) { ... } // Object getValue() { return value; } // static Comparable max(Comparable a, Comparable b) { // T extends Comparable → Comparable // return a.compareTo(b) >= 0 ? a : b; // } // } // The compiler inserts CHECKCAST instructions at every use site: Box<String> box = new Box<>("hello"); String s = box.getValue(); // bytecode: CHECKCAST java/lang/String // Raw type usage (avoid in new code — pre-Java 5 compatibility only): Box rawBox = new Box("hello"); // raw — no type safety String val = (String) rawBox.getValue(); // you must cast manually rawBox.setValue(42); // compiler warning but ALLOWED — can corrupt later // Why raw types are dangerous: List raw = new ArrayList<String>(); // raw reference to parameterized list raw.add(42); // int sneaks in — no compile error with raw type List<String> typed = (List<String>) raw; // unchecked cast warning String item = typed.get(0); // ClassCastException at runtime — not at add() time! Never use raw types in new code. They exist only for backward compatibility with pre-Java-5 libraries. Every raw type usage is a potential ClassCastException waiting to happen — one that the compiler cannot detect for you.

When a generic class or interface is subclassed with a concrete type, type erasure creates a signature mismatch between the overriding method and the erased supertype method. To preserve polymorphism, the compiler generates a synthetic bridge method in the subclass that matches the erased signature and delegates to the concrete method. These show up in reflection with Method.isBridge() == true and should be filtered out when iterating a class's methods to avoid processing them twice.

// Bridge methods are synthetic methods generated by the compiler // to preserve polymorphism after type erasure. // Source: interface Comparable<T> { int compareTo(T other); // after erasure → int compareTo(Object other) } class Length implements Comparable<Length> { final int value; Length(int value) { this.value = value; } @Override public int compareTo(Length other) { // concrete method return Integer.compare(this.value, other.value); } } // Problem after erasure: // - Comparable requires: int compareTo(Object) // - Length provides: int compareTo(Length) ← different signature! // Without a bridge, polymorphism would break (casting through interface would fail) // Compiler generates a synthetic bridge method in Length: // public synthetic bridge int compareTo(Object other) { // return this.compareTo((Length) other); // delegates to real method + cast // } // You can see bridge methods via reflection: for (java.lang.reflect.Method m : Length.class.getMethods()) { if (m.isBridge()) { System.out.println("Bridge: " + m); } } // Output: Bridge: public int Length.compareTo(java.lang.Object) // Bridge methods and covariant return types: class Base { Object get() { return "base"; } } class Sub extends Base { @Override String get() { return "sub"; } // covariant — String is a subtype of Object } // Compiler generates bridge in Sub: // public bridge Object get() { return this.get(); } // bridges Base.get() contract // Bridge methods are invisible in normal usage; you only encounter them when // iterating getMethods() and need to filter them out: java.lang.reflect.Method[] methods = Length.class.getDeclaredMethods(); java.util.Arrays.stream(methods) .filter(m -> !m.isBridge() && !m.isSynthetic()) .forEach(System.out::println);

A reifiable type has its complete type information available at runtime: raw types, non-generic concrete classes, primitive arrays, and unbounded wildcards are all reifiable. Non-reifiable types lose their generic argument information through erasure — List<String>, a bare type parameter T, and bounded wildcards like List<? extends Number> are non-reifiable. Only reifiable types can legally appear in instanceof checks and array creation expressions; using a non-reifiable type in either context is a compile error.

// REIFIABLE type — full type information available at runtime // NON-REIFIABLE type — type info partially or fully erased at runtime // Reifiable types (safe to use with instanceof, arrays, reflection): Object obj = "hello"; System.out.println(obj instanceof String); // OK — String is reifiable System.out.println(obj instanceof int[]); // OK — primitive arrays are reifiable System.out.println(obj instanceof Object[]); // OK // Non-reifiable types (erased — cannot use with instanceof): List<String> ls = new ArrayList<>(); // obj instanceof List<String> ← COMPILE ERROR — cannot check erased type // obj instanceof List<?> ← OK — unbounded wildcard is reifiable (kinda) System.out.println(obj instanceof List<?>); // allowed — checks raw List // Type | Reifiable? // ----------|-------------------------------------------- // String | Yes — concrete class // int[] | Yes — primitive array // String[] | Yes — array of concrete class // List | Yes — raw type // List<?> | Yes — unbounded wildcard // List<String> | No — erased to List // T (parameter)| No — erased to its bound (Object or bound type) // List<? extends Number> | No — bounded wildcard is erased // Varargs + non-reifiable = heap pollution warning: @SafeVarargs // suppresses warning when method doesn't expose the array static <T> List<T> listOf(T... elements) { return java.util.Arrays.asList(elements); } // Without @SafeVarargs, calling listOf("a","b") gives an unchecked warning // because compiler creates a T[] (Object[] at runtime) — type info is gone // Generic array creation is forbidden: // T[] arr = new T[10]; // COMPILE ERROR // List<String>[] arr2 = new List<String>[10]; // COMPILE ERROR // Workaround — create Object[] and cast (generates unchecked warning): @SuppressWarnings("unchecked") T[] createArray(int size) { return (T[]) new Object[size]; }

instanceof List<String> is a compile error because at runtime the JVM only knows the object is a List — the String argument has been erased and does not exist in the bytecode. You can test instanceof List<?> (unbounded wildcard, which is reifiable) or instanceof List (raw type), but neither tells you what element type the list holds. If you need a runtime type check, you must inspect each element individually or carry a Class<T> token.

// instanceof checks type at runtime, but generic type info is erased. // The JVM has NO idea whether a List was parameterised as String or Integer. void checkTypes(Object obj) { // These work — reifiable types: if (obj instanceof String s) { System.out.println("String: " + s); } if (obj instanceof List<?> list) { // unbounded wildcard OK System.out.println("Some List of size " + list.size()); } // These do NOT compile: // if (obj instanceof List<String>) { } // ERROR — illegal generic type for instanceof // if (obj instanceof Map<String,?>) { } // ERROR — partially parameterized } // Runtime check with explicit element inspection (workaround): boolean isStringList(Object obj) { if (!(obj instanceof List<?> list)) return false; for (Object element : list) { if (!(element instanceof String)) return false; } return true; } // Class<T> as a runtime type token (the proper solution — see next section): class TypeSafeContainer<T> { private final Class<T> type; private T value; TypeSafeContainer(Class<T> type) { this.type = type; } void set(Object v) { value = type.cast(v); // runtime check via Class — throws ClassCastException on failure } T get() { return value; } boolean isInstance(Object obj) { return type.isInstance(obj); // equivalent of obj instanceof T — works at runtime! } } TypeSafeContainer<String> c = new TypeSafeContainer<>(String.class); c.set("hello"); // OK // c.set(42); // ClassCastException at runtime — caught here, not silently later System.out.println(c.isInstance("world")); // true System.out.println(c.isInstance(123)); // false

Heap pollution occurs when a variable of a parameterized type holds a reference to an object of the wrong type — usually caused by mixing raw types with generic ones. The compiler emits unchecked warnings at the source of potential pollution, but the ClassCastException surfaces at a later, seemingly unrelated point in the code, making it hard to diagnose. @SuppressWarnings("unchecked") should be used sparingly and only after manually verifying the type safety of the suppressed code, with a comment explaining why it is safe.

// HEAP POLLUTION: a variable of parameterized type refers to an object // of the wrong type. Caused by mixing raw types and generics. // Example 1 — raw type assignment: @SuppressWarnings("unchecked") void heapPollution() { List<String> strings = new ArrayList<>(); List raw = strings; // raw reference — no warning here raw.add(42); // Integer added to List<String> — warning String s = strings.get(0); // ClassCastException! (not at add() above) } // Unchecked warnings tell you WHERE heap pollution may originate: // "unchecked cast" — (T) someObject where T is erased // "unchecked call" — calling a method on raw type with generic args // "unchecked conversion"— assigning raw type to parameterized type // Varargs and heap pollution: static void unsafe(List<String>... lists) { // compiler warns here Object[] array = lists; // List<String>[] → Object[] (valid upcast) array[0] = List.of(42); // store List<Integer> in slot 0 — heap pollution! String s = lists[0].get(0); // ClassCastException } // Safe varargs — don't expose the array: @SafeVarargs // tells compiler (and callers) this method is safe static <T> void safeLog(T... items) { for (T item : items) System.out.println(item); // only reads — safe } // Suppressing warnings responsibly: // Only use @SuppressWarnings("unchecked") when you have MANUALLY verified safety: @SuppressWarnings("unchecked") static <T> T coerce(Object o) { // Safe because caller controls both T and o: return (T) o; // unchecked cast — we know what we're doing } // Document WHY it is safe. Never suppress warnings just to silence the compiler. Unchecked warnings are the compiler's way of saying "I cannot verify type safety here because of erasure." Treat them seriously — every suppressed warning is a potential ClassCastException at an unexpected location in your code.

Because T is erased, you can preserve type information at runtime by accepting a Class<T> parameter alongside the generic type parameter. This token enables runtime type checks via type.isInstance(obj) and type-safe casts via type.cast(obj) without an unchecked warning. Jackson uses this pattern for simple type-aware deserialization, passing User.class to readValue() so the deserializer knows which class to instantiate at runtime.

// Pass Class<T> explicitly so the type is available at runtime. // This is called the "type token" or "class token" pattern. // Type-safe heterogeneous container (Bloch, Effective Java Item 33): class TypesafeMap { private final Map<Class<?>, Object> map = new HashMap<>(); <T> void put(Class<T> type, T value) { map.put(Objects.requireNonNull(type), type.cast(value)); } <T> T get(Class<T> type) { return type.cast(map.get(type)); // runtime-safe cast via Class } } TypesafeMap tsm = new TypesafeMap(); tsm.put(String.class, "Hello"); tsm.put(Integer.class, 42); tsm.put(Double.class, 3.14); System.out.println(tsm.get(String.class)); // "Hello" System.out.println(tsm.get(Integer.class)); // 42 // tsm.put(String.class, 42); ← ClassCastException — type token prevents this // Generic factory using class token: class Registry<T> { private final Class<T> type; Registry(Class<T> type) { this.type = type; } T create() throws Exception { return type.getDeclaredConstructor().newInstance(); } boolean isCompatible(Object obj) { return type.isInstance(obj); } Class<T> getType() { return type; } } Registry<StringBuilder> reg = new Registry<>(StringBuilder.class); StringBuilder sb = reg.create(); // creates new instance without knowing T at compile time // Limitation — class token doesn't work for parameterized types: // Class<List<String>> c = List<String>.class; // COMPILE ERROR — no class literal for parameterized type // Only raw class literals exist: List.class, Map.class — → see TypeToken pattern below // Casting with Class (safer than unchecked cast): Object obj = "world"; String s = String.class.cast(obj); // throws ClassCastException with clear message // vs (String) obj // throws ClassCastException with less context

A plain Class<T> token cannot represent parameterized types like List<String> because no class literal for a parameterized type exists. The supertype token pattern works around this by creating an anonymous subclass of a generic abstract class — the Java class file format stores the generic superclass signature (including type arguments) in metadata that is NOT erased, so getGenericSuperclass() can recover it at runtime. Guava's TypeToken and Jackson's TypeReference are both built on this technique, enabling correct deserialization of complex nested generics like List<Map<String, Integer>>.

// Problem: Class<T> token cannot represent parameterized types like List<String>. // Solution: Supertype token — capture the generic type via an anonymous subclass. // The trick: an anonymous subclass of a generic class records the type argument // in its "Signature" attribute (not erased!) and reflection can read it. import java.lang.reflect.*; // Minimal TypeToken implementation: abstract class TypeToken<T> { private final Type type; protected TypeToken() { // getGenericSuperclass() returns ParameterizedType for "TypeToken<List<String>>" Type superclass = getClass().getGenericSuperclass(); if (!(superclass instanceof ParameterizedType pt)) { throw new IllegalArgumentException("TypeToken must be parameterized"); } this.type = pt.getActualTypeArguments()[0]; // captures the T argument } public Type getType() { return type; } @Override public String toString() { return type.toString(); } } // Usage — capture List<String> at runtime: TypeToken<List<String>> token = new TypeToken<List<String>>() {}; // Note: {} creates an anonymous subclass — this is the key trick! System.out.println(token.getType()); // Output: java.util.List<java.lang.String> // Full type information is now available: ParameterizedType pType = (ParameterizedType) token.getType(); System.out.println(pType.getRawType()); // interface java.util.List System.out.println(pType.getActualTypeArguments()[0]); // class java.lang.String // Gson uses this pattern to deserialize generic types: // Gson gson = new Gson(); // Type listType = new TypeToken<List<String>>() {}.getType(); // List<String> result = gson.fromJson(json, listType); // Jackson uses TypeReference similarly: // TypeReference<List<User>> ref = new TypeReference<>() {}; // List<User> users = mapper.readValue(json, ref); // Guava's TypeToken — much richer API (type resolution, subtype checks): // TypeToken<List<String>> tt = new TypeToken<List<String>>() {}; // tt.isSubtypeOf(Iterable.class); // true // tt.getRawType(); // List.class // Summary of erasure workarounds: // | Problem | Workaround | // |---------------------------------|-------------------------------------| // | T instanceof check | Class<T>.isInstance(obj) | // | Create T[] | (T[]) new Object[n] + @SuppressWarn | // | List<T> type at runtime | TypeToken supertype token pattern | // | Deserialize generic JSON | Gson TypeToken / Jackson TypeRef | // | Type-safe heterogeneous map | Class<T> keys with type.cast() | The supertype token trick relies on the fact that Java stores generic type signatures of superclasses in the class file. When you write new TypeToken<List<String>>() {}, the anonymous subclass's superclass signature records List<String> permanently — bypassing erasure completely.