JAVA / GENERICS AND TYPE ERASURE
Unchecked casts and heap pollution warnings
Read unchecked cast and heap pollution warnings, trace the ClassCastException they cause back to its real source, and either prove or remove the cast.
What you will learn
- Read an unchecked cast warning as: the runtime check ignores the type arguments.
- Trace a ClassCastException on a cast-free line back to the unchecked cast upstream.
- Scope @SuppressWarnings("unchecked") to one declaration and justify it in a comment.
- Add @SafeVarargs only when the T[] parameter never leaves the method body.
Understanding Unchecked casts and heap pollution warnings
A cast is unchecked when the type it names carries type arguments that will not exist at runtime. The expression (List<String>) o compiles to a single checkcast java/util/List: the JVM confirms the object is some list and stops there, because erasure already deleted the <String> part. The warning is javac telling you that the check it inserted is weaker than the claim you wrote, so nothing will notice the difference at that line.
Heap pollution is the state that follows: a variable of type List<String> referring to a list that holds Integers. No operation fails immediately, because the erased signatures are add(Object) and Object get(int); the failure waits for the first place where the compiler had to insert a cast to String to keep the declared type honest. That is why the stack trace usually points at a line containing no cast at all, often in unrelated code, so the trace names the victim while the culprit is the unchecked cast that installed the wrong object. Collections.checkedList(list, String.class) helps here because it reinstates the element check, moving the exception onto the add that pollutes.
Generic varargs open the same hole without an explicit cast. A T... items parameter is compiled as an array of T's erasure, so calling such a method where T is itself a type variable creates an Object[]; if the method lets that array escape by returning or storing it, a caller expecting String[] gets a ClassCastException on the array. @SafeVarargs and @SuppressWarnings("unchecked") are assertions rather than fixes: they say you verified by hand what the compiler could not, so keep them on the narrowest declaration possible and record the reasoning beside them.
import java.util.ArrayList;
import java.util.List;
public class HeapPollution {
// The claim is List<T>; the JVM will only ever verify List.
@SuppressWarnings("unchecked")
static <T> List<T> forceCast(Object o) {
return (List<T>) o;
}
public static void main(String[] args) {
List<Integer> numbers = new ArrayList<>();
numbers.add(42);
List<String> names = forceCast(numbers); // passes: it really is a List
System.out.println("size = " + names.size());
names.add("polluted"); // add(Object) after erasure
System.out.println("numbers now holds " + numbers);
try {
String first = names.get(0); // javac inserts (String) here
System.out.println(first.toUpperCase());
} catch (ClassCastException e) {
System.out.println(e.getMessage());
}
}
}
Erasure means an unchecked cast is verified only down to the raw type, so the type error it hides resurfaces later at a compiler-inserted check in code that never mentions a cast.
Worked examples
Varargs array that escapes
Shows how a generic T... parameter becomes Object[] and pollutes the caller's array type.
public class VarargsPollution {
static <T> T[] pack(T... items) { // -Xlint:varargs: possible heap pollution
return items; // the array escapes the method
}
static <T> T[] packPair(T a, T b) {
return pack(a, b); // T erases to Object, so this makes Object[]
}
@SafeVarargs
static <T> String describe(T... items) {
return items.length + " " + items.getClass().getSimpleName();
}
public static void main(String[] args) {
System.out.println(describe("a", "b"));
System.out.println(describe(1, 2, 3));
try {
String[] pair = packPair("a", "b");
System.out.println(pair.length);
} catch (ClassCastException e) {
System.out.println(e.getMessage());
}
}
}
Example explained
Line 1pack returns its own varargs array, and that escape is exactly what the varargs warning is about.
Line 2Inside packPair the element type is a type variable, so the array is created as Object[]; the call site's inserted cast to String[] is what fails.
Line 3describe only reads items.length, so @SafeVarargs is an honest claim, and the printed class names show the arrays really are String[] and Integer[] there.
Line 4Putting @SafeVarargs on pack would compile and silence the warning while leaving the same failure in place.
Replacing the cast with a conversion
Turns an unproven (List<String>) cast into per-element checks so the failure happens at the boundary.
import java.util.ArrayList;
import java.util.List;
public class CheckedConversion {
static List<String> toStringList(Object o) {
List<?> raw = (List<?>) o; // fully checked: erasure is exactly List
List<String> copy = new ArrayList<>();
for (Object e : raw) {
copy.add(String.class.cast(e)); // checked once per element
}
return copy;
}
public static void main(String[] args) {
List<Object> mixed = new ArrayList<>();
mixed.add("ok");
mixed.add(7);
System.out.println(toStringList(List.of("a", "b")));
try {
toStringList(mixed);
} catch (ClassCastException e) {
System.out.println(e.getMessage());
}
}
}
Example explained
Line 1(List<?>) o carries no type argument to erase, so the JVM check matches the claim and javac emits no unchecked warning.
Line 2String.class.cast(e) performs the element check that an erased (List<String>) cast can never perform.
Line 3The exception now names the offending element and is thrown while data crosses into typed code, not on some later read.
Line 4The result is a fresh ArrayList<String>, so nothing downstream can be surprised by an Integer inside it.
Important notes
@SafeVarargs is only legal where the method cannot be overridden: static, final, private (Java 9 and later), and constructors, because an override could break the promise the annotation makes.
The exact ClassCastException wording depends on the JDK; the module and loader detail is Java 9 and later, and when diagnosing pollution what matters is which line throws, not the phrasing.
Common mistakes
Annotating a whole method or class with @SuppressWarnings("unchecked") to quiet one line, which silently accepts every future unchecked cast added there and leaves the resulting ClassCastException with no warning trail.
Debugging the line in the stack trace instead of the cast: names.get(0) is only where the compiler placed the check, while the wrong object was installed by an unchecked cast that may live in another class.
Adding @SafeVarargs to a method that returns or stores its T... array, which removes the heap pollution warning and hands callers an array cast failure such as [Ljava.lang.Object; cannot be cast to [Ljava.lang.String;.
Try it yourself
Change, predict, then run
Write static <T> List<T> cast(Object o) that performs an unchecked cast, use it to view an ArrayList<Integer> as a List<String>, and confirm the exception only appears when you read an element into a String variable. Then wrap the original list with Collections.checkedList(list, String.class) and watch the failure move to the add call that pollutes it.
Open the Java workspaceCheck your understanding
A method holds List<String> names = (List<String>) obj; where obj is really an ArrayList<Integer> containing one Integer. names.size() runs fine and names.add("hi") runs fine. Where does the ClassCastException come from?
- At the cast, as soon as the @SuppressWarnings annotation is removed.
- At names.add("hi"), because the list's element check rejects the String.
- At the first read that assigns an element to a String, where javac inserted a checkcast.
- At ArrayList's internal type check the next time its backing array grows.
Show answer
The written cast erases to checkcast java/util/List, which passes because the object is a list, and both add and get operate on Object after erasure, so nothing fails until the compiler's own cast to String on a read. Option 0 is tempting, but @SuppressWarnings only switches a compile-time diagnostic off; adding or removing it never changes the bytecode or the checks the JVM performs.