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 forG<? 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
SafeVarargsmethod.
- Caveat. It is safe to pass the varargs directly into another
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]