Type Erasure

notes  •  uni  •  java
Cewau  │  Published: 2025-11-20

Reifiable Types

Preliminary. Because of type erasure, not all type information present at compile time may be present at runtime.

  • Such affected types are called non-reifiable (in a sense, they cannot be made real.)
/* class G<T> { */
  T val;
  G<String> gs;
  G<T> gt;
  G<? extends String> gws;
/* } */

These are all non-reifiable, because what JVM at runtime sees is

  Object val;
  G gs;
  G gt;
  G gws;

Crucially, the compiler attaches certain assumptions on the CTTs, which may no longer hold at runtime. For example, the compiler expects a G<String> instance to only contain a String, but at runtime it can contain any Object.

On the flip side, some examples of reifiable types are

int i;
String s;
G g;
G<?> gw;
G<?>[] gwa;

Notably, unbounded wildcards are reifiable, because intuitively its job really is to tell the compiler not to attach any additional information (i.e. assumptions) to the Generic type.

Important Caveat. Intuitively, G<? extends Object> should be reifiable. But this only became true somewhere between Java 17 and 21.

  • Yet, across all Java versions, G<?> <: G<? extends Object> does hold. Similarly for G<? super Object> <: G<Object>. (Credits: @JavaLim)

Related. Somewhere between Java 11 and 17, the behaviour of instanceof changed from rejecting all non-reifiable RHS to rejecting them only if (RHS) LHS is an unsafe cast.

Heap Pollution

Talking about (non-)reifiable types is important because Java’s type safety lies on the assumptions it makes on these types.

String x = Integer.valueOf(1); // Suppose this passes
/* ... */
x.length(); // 💥 Integer instance does not have length()

Obviously, above would never happen because an Integer cannot be assigned to String, not even at runtime. Notice that the compiler and JVM can be guaranteed to be on the same page, iff the types are reifiable.

G<String> g = new G<Integer>(); // [?]
String x = g.val; // 💥 Integer cannot be assigned to String

/* After type erasure (what the JVM sees) */

G g = new G();
String x = (String) g.val; // 💥 ClassCastException

Fortunately, while above ([?]) cannot be caught by the JVM, it does get caught by the compiler. But this is not always the case, as detailed subsequently.

  • When this inevitably happens, we say a heap pollution has occurred.

Note. Troubleshooting is actually not complicated due to the (near-)robustness of Java’s type system. While the compiler cannot flag these as errors, it always knows when one may occur. Hence it throws a warning, calling it an unchecked operation.

Casting

This is an obvious one — casting is always technically unsafe. The problem lies in the assumption that the compiler can safely delegate the check to runtime.

G<?> g = new G<Integer>();
G<String> gs = (G<String>) g;
//             <-----------> ❗ Unchecked cast

JVM does not have enough information to validate the cast. In other words, all (legal) non-reifiable casts are unchecked.

Note. This is less about implicit vs. explicit casting, and more about whether the cast narrows to a non-reifiable type. In particular, one specific implicit cast suffers from this (see below).

Caveat. The above statement is a little too broad; consider

class G<T> {}
class H<T> extends G<T> {}

G<String> gs = (G<String>) new H<String>(); // 💥 ClassCastException

Java can catch all shenanigans involved in this snippet, so the cast is checked. It sure is hard to come up with a universal statement that captures all behaviours…

Raw Types

Even though raw types are reifiable (i.e. casting to them is “checked”), raw types as a whole can be thought of as disabling the compiler’s “checker”. Having a raw type is fine, but any (“narrowing”) operation on it is unsafe (though legal).

Some examples (non-exhaustive):

class G<T> {
  T val;
  T produce() { return null; }
  void consume(T t) {}
}
G g = new G<String>();   // ✅ Reifiable implicit cast
g.val = 2;               // ❗ Assumes Integer <: T
g.consume("Hello");      // ❗ Assumes String <: T
String s = g.val;        // ❌ (Sanity check) explicit cast required
Object o1 = g.val;       // ✅ No assumption made on T
Object o2 = g.produce(); // ✅ No assumption made on T
G<Integer> gi = g;       // ❗ Non-reifiable *implicit* cast

Note the last line. Due to backward compatibility, casting out of a raw type can also be implicit.

Note 1. The first two warnings are not exactly about heap pollution, but a closely related problem that Java may crash on theoretically 100% safe code. But ultimately the root cause is identical.

  • You may notice that an unchecked warning message mentions “uses unchecked or unsafe operations”. I suspect this is what “unsafe” refers to.

Note 2. Raw types really only exist for backward compatibility and should never be used. Thus Java has an (opt-in) linter -Xlint:rawtypes which issues a warning upon any sighting of raw types.

Array Covariance

Suppose non-reifiable arrays are instantiable. Then based on normal Java rules,

G<String>[] gsa = new G<String>[1]; // ✅ (Hypothetical)
G<?>[] gwa = gsa;                   // ✅ Array covariance
gwa[0] = new G<Integer>();          // ✅ Reifiable implicit cast (🤔)
G<String> gs = gsa[0];              // ✅ Array indexing

Obviously something is very wrong (a heap pollution has occurred), but according to both the compiler and JVM, every step was perfectly legal.

Ultimately, array covariance lies in the assumption that the RTT information of the array prevents any illegal storage (as they always say “arrays are reifiable”). However, instead of issuing a warning at line 3 (or even line 2), Java decides to just ban non-reifiable array instantiation altogether.

Varargs

Not a “root cause” by itself, but worth a highlight.

<T> void foo(T... elements) {}

Since varargs is just syntactic sugar over array arguments, we are in fact attempting array instantiation, the type of which may or may not be reifiable. Obviously non-reifiable instantiation is not possible in Java (see above), so the actual pseudocode is more like

void foo(Object[] elements) {}          // ❗

/* From a reifiable context */
foo(new String[] { s1, s2, s3 });       // ✅

/* From a non-reifiable context */
foo((T[]) new Object[] { t1, t2, t3 }); // ❗

That is, an implicit unchecked cast upon invocation; the difference from

<T> void foo(T[] elements) {}

being that the former warns at method declaration as well (presumably because the forced array instantiation is embedded within the syntactic sugar itself).

The API developer can add @SuppressWarnings("unchecked") at the method declaration, but this does not prevent unchecked warnings at every call site with non-reifiable variadic arguments, like so:

foo("Hello", "world");                 // ✅ new String[2]
foo(new G<String>(), new G<String>()); // ❗ (G<String>[]) new G[2]
<T> void bar(T t) {
  foo(t, t);                           // ❗ (T[]) new Object[2]
}

This “causes programmer friction”, hence Java added an annotation @SafeVarargs, reflecting the API developer’s guarantee that the method does not unsafely handle the implicitly created array. With this annotation, there will not be unchecked warnings at call sites like above.

Now obviously the developer could potentially abuse the annotation, hence when Java sees that the annotation is not used “properly”:

  • It is used on methods which do not need the annotation
  • There exists any sign of unsafe handling. Notably, any form of its assignment, including return
    • Caveat. It is safe to pass the varargs directly into another SafeVarargs method.

It issues a different type of warning, known as a varargs warning.

Note. The @SafeVarargs method MUST be non-overridable (contains any of private / static / final), otherwise it is a compile error. Should be reasonable.

Reference. [1]