Command Palette

Search for a command to run...

[Advanced Java] Generics in Java: Type Parameters, Bounded Types, Wildcards and Type Erasure

The Java Basics course used generic types on almost every page — List<String>, Map<String, Integer>, ArrayList<Student> — without ever saying where those angle brackets come from or how to put a pair of them on a class of your own. This article closes that gap.

Generics are a compile-time feature and nothing else. They add no field, no method and no bytecode instruction; what they add is a promise that javac checks and then deliberately discards. Both halves of that sentence matter — the checking is what turns a run-time crash into a build failure, and the discarding is what explains every strange restriction the language puts on type parameters.

A large T in angle brackets above two cards: a raw List caught at runtime and a typed List caught at compile time

Every error message, every line of output and every javap dump below was produced by compiling and running the code on OpenJDK 21.0.6. No timing measurements appear anywhere in this article, because generics have no run-time behaviour to time — the section on type erasure proves that structurally instead.

Why generics exist: the cast that fails at run time

Before Java 5 a collection held Object. Anything went in, and everything that came out had to be cast back by hand.

import java.util.ArrayList;
import java.util.List;

public class RawBox {
    public static void main(String[] args) {
        List names = new ArrayList();   // raw type: holds Object
        names.add("Ann");
        names.add("Ben");
        names.add(42);                  // nothing stops this

        for (Object o : names) {
            String s = (String) o;      // the cast you are forced to write
            System.out.println(s.toUpperCase());
        }
    }
}

It compiles. It even runs, for a while:

ANN
BEN
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')
	at RawBox.main(RawBox.java:12)

Two things are wrong here. The failure surfaces on line 12, at the cast, while the mistake was made on line 9 where the int was added — so the stack trace points at the victim rather than the culprit. And it surfaces in production rather than in the build. The compiler was not silent about it, but all it could offer was a warning:

RawBox.java:7: warning: [unchecked] unchecked call to add(E) as a member of the raw type List
        names.add("Ann");
                 ^
  where E is a type-variable:
    E extends Object declared in interface List

The same program with a type argument fails at the right line, at the right time, with the right message:

import java.util.ArrayList;
import java.util.List;

public class TypedBox {
    public static void main(String[] args) {
        List<String> names = new ArrayList<>();
        names.add("Ann");
        names.add("Ben");
        names.add(42);                  // rejected here, not at run time

        for (String s : names) {        // no cast needed
            System.out.println(s.toUpperCase());
        }
    }
}
TypedBox.java:9: error: incompatible types: int cannot be converted to String
        names.add(42);                  // rejected here, not at run time
                  ^
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
1 error

That note is worth acting on. javac -Xdiags:verbose prints the overload table it actually considered:

TypedBox.java:9: error: no suitable method found for add(int)
        names.add(42);
             ^
    method List.add(String) is not applicable
      (argument mismatch; int cannot be converted to String)
    method List.add(int,String) is not applicable
      (actual and formal argument lists differ in length)
1 error

The for (String s : names) loop needs no cast either, and that is not a convenience — it is the same guarantee viewed from the other end.

Raw ListList<String>
Adding an Integercompiles, with a warningerror: incompatible types
Reading an elementObject, cast by handString, no cast written
Where a mistake shows uprun time, at the castcompile time, at the call
What the failure looks likeClassCastExceptiona build that does not finish

Writing a generic class

A generic class declares one or more type parameters between angle brackets after its name, and then uses them exactly as it would use a real type.

public class Box<T> {
    private T value;

    public Box(T value) { this.value = value; }

    public T get() { return value; }

    public void set(T value) { this.value = value; }

    // T is the class's parameter; U is this method's own, fixed per call
    public <U> String describe(U label) {
        return label + " -> " + value;
    }

    @Override
    public String toString() { return "Box(" + value + ")"; }
}

T is not a type. It is a type variable: a placeholder that the compiler substitutes when someone writes new Box<String>(...). Inside the class body it behaves like a type whose only known supertype is Object, which is why value.length() would not compile there even though Box<String> will eventually hold a String.

import java.util.List;

public class Main {
    // a generic method: <T> is declared by the method itself
    static <T> T firstOf(List<T> items) {
        return items.get(0);
    }

    public static void main(String[] args) {
        Box<String> name = new Box<>("Ann");
        String s = name.get();                 // no cast
        System.out.println(s.length());

        Box<Integer> age = new Box<>(30);
        System.out.println(age.get() + 1);     // arithmetic, not Object

        System.out.println(name.describe(1));
        System.out.println(name.describe("id"));

        String first = firstOf(List.of("a", "b"));          // T inferred as String
        Integer one = Main.<Integer>firstOf(List.of(1, 2)); // T given explicitly
        System.out.println(first + " " + one);
    }
}
3
31
1 -> Ann
id -> Ann
a 1

describe is the interesting method. T belongs to Box and is decided once, when the object is created. U belongs to describe and is decided again on every call — which is why the two calls above bind it to Integer and then to String on the same object.

Side by side panels highlighting the class type parameter scope and the method type parameter scope in the same Box class

Compiling Box.java and Main.java together produces exactly two class files, Box.class and Main.class. There is no Box$String.class. One class serves every instantiation, and that single fact is the root of everything in the type-erasure section below.

Naming a type parameter

Type parameters are single uppercase letters by convention, and the letters are not arbitrary — they tell a reader what the parameter is for.

LetterConventional meaningSeen in
Ttype, no further meaningBox<T>, Comparable<T>
Eelement of a collectionList<E>, Set<E>
K, Vkey and valueMap<K, V>
Nnumbernumeric utility classes
Rresult of a transformationmapping methods
S, U, Vsecond, third, fourth typePair<K, V>, multi-parameter methods

A longer name is legal — class Box<Item> compiles — but it reads like a class name at the use site, and every Java reader has been trained on the single letters. A class can take several parameters, and each is independent:

public class Pair<K, V> {
    private final K key;
    private final V value;

    public Pair(K key, V value) { this.key = key; this.value = value; }

    public K key() { return key; }

    public V value() { return value; }

    public Pair<V, K> swapped() { return new Pair<>(value, key); }

    @Override
    public String toString() { return key + "=" + value; }

    public static void main(String[] args) {
        Pair<String, Integer> p = new Pair<>("age", 30);
        int age = p.value();          // unboxes directly, no cast
        System.out.println(p + " / " + p.swapped() + " / " + age);
    }
}
age=30 / 30=age / 30

Writing a generic method

A method can declare its own type parameters whether or not its class is generic. The declaration goes between the modifiers and the return type — static <T> T firstOf(...) — and its scope is the method.

Most of the time the type argument is inferred from the call, which is why firstOf(List.of("a", "b")) needs no annotation. When inference cannot decide, or when you want to force a choice, the argument is supplied before the method name: Main.<Integer>firstOf(...). That syntax requires a receiver, so a call inside the same class must be written Main.<Integer>firstOf(...) and not <Integer>firstOf(...).

A static method cannot use the class's type parameter

The class's T exists per object. A static member exists per class, so there is no object to take T from:

public class StaticT<T> {
    static void helper(T x) { }          // uses the class's T

    static <T> void ok(T x) { }          // declares its own T — legal
}
StaticT.java:2: error: non-static type variable T cannot be referenced from a static context
    static void helper(T x) { }          // uses the class's T
                       ^
1 error

The second method compiles because it declares a T of its own. It shadows the class's parameter and has nothing to do with it, which is a good reason to name a method's parameter something else.

Bounded type parameters

An unbounded T is only known to be an Object, so only Object's methods resolve on it:

import java.util.List;

public class Bounds {
    // unbounded: T is only known to be an Object
    static <T> double sumBroken(List<T> nums) {
        double total = 0;
        for (T n : nums) total += n.doubleValue();
        return total;
    }

    public static void main(String[] args) { }
}
Bounds.java:7: error: cannot find symbol
        for (T n : nums) total += n.doubleValue();
                                   ^
  symbol:   method doubleValue()
  location: variable n of type T
  where T is a type-variable:
    T extends Object declared in method <T>sumBroken(List<T>)
1 error

extends on a type variable fixes that by giving the compiler an upper bound. It is spelled extends whether the bound is a class or an interface:

import java.util.List;

public class Bounded {
    // T is at least a Number, so Number's methods are available
    static <T extends Number> double sum(List<T> nums) {
        double total = 0;
        for (T n : nums) total += n.doubleValue();
        return total;
    }

    // multiple bounds: class first, then interfaces, joined by &
    static <T extends Number & Comparable<T>> T max(List<T> items) {
        T best = items.get(0);
        for (T x : items) if (x.compareTo(best) > 0) best = x;
        return best;
    }

    public static void main(String[] args) {
        System.out.println(sum(List.of(1, 2, 3)));
        System.out.println(sum(List.of(1.5, 2.5)));
        System.out.println(max(List.of(3, 9, 4)));
        System.out.println(max(List.of(3.5, 9.25, 4.0)));
    }
}
6.0
4.0
9
9.25

A bound is also enforced at the use site. Counter<T extends Number> refuses a String:

BoundedClass.java:6: error: type argument String is not within bounds of type-variable T
    Counter<String> bad;
            ^
  where T is a type-variable:
    T extends Number declared in class Counter
1 error

Four declaration rows showing which methods each bound makes available, and the row that Java rejects

Multiple bounds

A type variable may have several bounds joined by &. At most one may be a class, and if there is one it must come first — the compiler is explicit about the ordering:

BoundOrder.java:2: error: interface expected here
    static <T extends Comparable<T> & Number> T pick(T a) { return a; }
                                      ^
1 error

The first bound is also the one the type variable erases to, which matters later: T extends Number & Comparable<T> becomes Number in the bytecode, not Comparable.

Why a declaration cannot say super

super is legal in a wildcard but not in a type-parameter declaration. Java does not even reach a semantic check for it — the parser gives up at the keyword:

public class SuperBound {
    static <T super Integer> void store(T x) { }

    public static void main(String[] args) { }
}
SuperBound.java:2: error: > expected
    static <T super Integer> void store(T x) { }
             ^
SuperBound.java:2: error: illegal start of type
    static <T super Integer> void store(T x) { }
              ^
SuperBound.java:2: error: <identifier> expected
    static <T super Integer> void store(T x) { }
                           ^
3 errors

The reason is that a lower bound would tell the compiler nothing useful. T extends Number says every T has doubleValue(); T super Integer would say only that T is somewhere between Integer and Object, and the one method set guaranteed across that whole range is Object's — which is what an unbounded T already gives you. A lower bound is only informative when you are supplying values rather than consuming them, and that is exactly the job of a wildcard.

Why a List<String> is not a List<Object>

Every String is an Object, so it is tempting to expect List<String> to be usable as a List<Object>. It is not:

import java.util.ArrayList;
import java.util.List;

public class Invariant {
    public static void main(String[] args) {
        List<String> names = new ArrayList<>();
        List<Object> objects = names;   // looks harmless, is not
        objects.add(42);
        String s = names.get(0);
    }
}
Invariant.java:7: error: incompatible types: List<String> cannot be converted to List<Object>
        List<Object> objects = names;   // looks harmless, is not
                               ^
1 error

The next two lines show why the compiler has to refuse. If the assignment were allowed, objects.add(42) would put an Integer into a list that names still believes holds only strings, and names.get(0) would blow up somewhere else entirely. Generics are invariant: List<A> and List<B> are unrelated types unless A and B are the same.

Arrays made the opposite choice, and it is instructive to see what that costs:

public class ArrayCovariance {
    public static void main(String[] args) {
        String[] names = new String[2];
        Object[] objects = names;   // arrays ARE covariant, and this compiles
        objects[0] = 42;            // fails at run time instead
    }
}
Exception in thread "main" java.lang.ArrayStoreException: java.lang.Integer
	at ArrayCovariance.main(ArrayCovariance.java:5)

Arrays carry their component type at run time and check every store, so the mistake is caught — just later, and by a check that costs something on every write. Generics move that check to compile time and pay nothing at run time, and invariance is the price. Wildcards are how you buy back the flexibility without giving up the guarantee.

Wildcards and PECS

A wildcard ? stands for some type I am not naming. Adding a bound to it says which direction that unknown type may vary in, and the direction you pick decides which operations remain legal.

The Object to Number to Integer chain with two range bars, and two cards showing which operation each wildcard rejects

The rule has a mnemonic: PECS — Producer Extends, Consumer Super. If the parameter produces values that you read, use extends. If it consumes values that you write, use super. The next two sections show the compile errors that make the rule non-negotiable.

Producer extends: reading is allowed, adding is not

import java.util.List;

public class ProducerExtends {
    // producer: we only read out of it
    static double sum(List<? extends Number> nums) {
        double total = 0;
        for (Number n : nums) total += n.doubleValue();   // reading is fine
        return total;
    }

    public static void main(String[] args) {
        System.out.println(sum(List.of(1, 2, 3)));            // List<Integer>
        System.out.println(sum(List.of(1.5, 2.5)));           // List<Double>
    }
}
6.0
4.0

One method now accepts List<Integer>, List<Double>, List<Long> and List<Number>. Reading is safe because whatever the element type turns out to be, it is a Number. Writing is not:

import java.util.List;

public class AddToExtends {
    static void addOne(List<? extends Number> nums) {
        nums.add(1);
    }

    public static void main(String[] args) { }
}
AddToExtends.java:5: error: incompatible types: int cannot be converted to CAP#1
        nums.add(1);
                 ^
  where CAP#1 is a fresh type-variable:
    CAP#1 extends Number from capture of ? extends Number
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
1 error

CAP#1 is the whole explanation. At the point of use the compiler captures the wildcard into a fresh, nameless type variable — some specific subtype of Number that it cannot name. The list might be a List<Double>; adding an Integer to it would corrupt it. Since no value is known to be an instance of CAP#1, nothing can be added.

Nothing except null, which is assignable to every reference type:

import java.util.ArrayList;
import java.util.List;

public class AddNull {
    static void tryIt(List<? extends Number> nums) {
        nums.add(null);   // the only value assignable to every possible T
    }

    public static void main(String[] args) {
        List<Integer> xs = new ArrayList<>(List.of(1, 2));
        tryIt(xs);
        System.out.println(xs);
    }
}
[1, 2, null]

Consumer super: adding is allowed, reading is not

? super Integer says the element type is Integer or something above it. Every one of those accepts an Integer:

import java.util.ArrayList;
import java.util.List;

public class ConsumerSuper {
    // consumer: we only write into it
    static void fillWithInts(List<? super Integer> sink) {
        sink.add(1);
        sink.add(2);
    }

    public static void main(String[] args) {
        List<Integer> ints = new ArrayList<>();
        List<Number> nums = new ArrayList<>();
        List<Object> objs = new ArrayList<>();
        fillWithInts(ints);
        fillWithInts(nums);
        fillWithInts(objs);
        System.out.println(ints + " " + nums + " " + objs);
    }
}
[1, 2] [1, 2] [1, 2]

Reading a specific type back out is what fails:

import java.util.List;

public class ReadFromSuper {
    static int firstInt(List<? super Integer> sink) {
        Integer first = sink.get(0);
        return first;
    }

    public static void main(String[] args) { }
}
ReadFromSuper.java:5: error: incompatible types: CAP#1 cannot be converted to Integer
        Integer first = sink.get(0);
                                ^
  where CAP#1 is a fresh type-variable:
    CAP#1 extends Object super: Integer from capture of ? super Integer
1 error

Read the capture description carefully: CAP#1 extends Object super: Integer. The list could be a List<Object> holding strings, so an element is not guaranteed to be an Integer. It is guaranteed to be an Object, and that assignment compiles:

import java.util.List;

public class ReadObject {
    static void show(List<? super Integer> sink) {
        Object first = sink.get(0);   // Object is the only guaranteed supertype
        System.out.println(first);
    }

    public static void main(String[] args) {
        show(List.of(7, 8));
    }
}
7

The same reasoning explains a bound you will meet constantly in the JDK. A naive maximum takes <T extends Comparable<T>>, which quietly excludes any type that inherits its compareTo from a supertype:

import java.util.List;

class Animal implements Comparable<Animal> {
    final int age;
    Animal(int age) { this.age = age; }
    public int compareTo(Animal other) { return Integer.compare(age, other.age); }
    public String toString() { return getClass().getSimpleName() + "(" + age + ")"; }
}

class Dog extends Animal {
    Dog(int age) { super(age); }
}

public class RecursiveBound {
    static <T extends Comparable<T>> T maxStrict(List<T> items) {
        T best = items.get(0);
        for (T x : items) if (x.compareTo(best) > 0) best = x;
        return best;
    }

    public static void main(String[] args) {
        List<Dog> dogs = List.of(new Dog(3), new Dog(7));
        System.out.println(maxStrict(dogs));
    }
}
RecursiveBound.java:23: error: method maxStrict in class RecursiveBound cannot be applied to given types;
        System.out.println(maxStrict(dogs));
                           ^
  required: List<T>
  found:    List<Dog>
  reason: inference variable T has incompatible equality constraints Animal,Dog
  where T is a type-variable:
    T extends Comparable<T> declared in method <T>maxStrict(List<T>)
1 error

Dog implements Comparable<Animal>, not Comparable<Dog>, so no T satisfies both constraints. Changing one word fixes it — Comparable is a consumer of the thing being compared, so it takes super:

    static <T extends Comparable<? super T>> T max(List<T> items) {
        T best = items.get(0);
        for (T x : items) if (x.compareTo(best) > 0) best = x;
        return best;
    }
Dog(7)

That is why the JDK is written the way it is. These are the real signatures, straight from javap java.util.Collections:

  public static <T extends java.lang.Comparable<? super T>> void sort(java.util.List<T>);
  public static <T> void sort(java.util.List<T>, java.util.Comparator<? super T>);
  public static <T> void copy(java.util.List<? super T>, java.util.List<? extends T>);
  public static <T extends java.lang.Comparable<? super T>> T max(java.util.Collection<? extends T>);
  public static <T> boolean addAll(java.util.Collection<? super T>, T...);

copy is PECS in one line: the destination consumes, so ? super T; the source produces, so ? extends T. Writing it yourself takes four lines:

import java.util.ArrayList;
import java.util.List;

public class Copy {
    // src produces T  -> extends; dest consumes T -> super
    static <T> void copy(List<? super T> dest, List<? extends T> src) {
        for (T item : src) dest.add(item);
    }

    public static void main(String[] args) {
        List<Integer> src = List.of(1, 2, 3);
        List<Number> dest = new ArrayList<>();
        Copy.<Number>copy(dest, src);
        System.out.println(dest);

        List<Object> anything = new ArrayList<>();
        copy(anything, src);
        System.out.println(anything);
    }
}
[1, 2, 3]
[1, 2, 3]

The unbounded wildcard

List<?> is List<? extends Object> written short. It says the element type is unknown and unconstrained, which makes it useful only for operations that do not mention the element type at all:

import java.util.List;

public class Unbounded {
    static int size(List<?> any) { return any.size(); }

    static void poke(List<?> any) { any.add("x"); }

    public static void main(String[] args) { }
}
Unbounded.java:6: error: incompatible types: String cannot be converted to CAP#1
    static void poke(List<?> any) { any.add("x"); }
                                            ^
  where CAP#1 is a fresh type-variable:
    CAP#1 extends Object from capture of ?
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
1 error

size compiles; poke does not. List<?> is not the same thing as a raw List and not the same thing as List<Object>: a raw List disables type checking and lets anything in with a warning, List<Object> accepts only a list declared to hold Object, and List<?> accepts any list but lets you write nothing into it.

TypeAccepts as argumentadd("x")get(0) returns
List (raw)any listcompiles, unchecked warningObject
List<Object>only List<Object>compilesObject
List<?>any listerror: ... cannot be converted to CAP#1Object
List<? extends Number>any list of a Number subtypesame errorNumber
List<? super Integer>any list of an Integer supertypecompilesObject

Type erasure

Everything above is checked by javac and then removed. After compilation, a type argument leaves no trace that the JVM can see. Three experiments make that concrete.

First, two lists of different element types have the same class at run time:

import java.util.ArrayList;
import java.util.List;

public class SameClass {
    public static void main(String[] args) {
        List<String> names = new ArrayList<>();
        List<Integer> ids = new ArrayList<>();
        System.out.println(names.getClass());
        System.out.println(ids.getClass());
        System.out.println(names.getClass() == ids.getClass());
    }
}
class java.util.ArrayList
class java.util.ArrayList
true

Second, two methods that differ only in a type argument are not two methods:

import java.util.List;

public class SameErasure {
    void handle(List<String> names) { }
    void handle(List<Integer> ids) { }
}
SameErasure.java:5: error: name clash: handle(List<Integer>) and handle(List<String>) have the same erasure
    void handle(List<Integer> ids) { }
         ^
1 error

Third, instanceof cannot ask about a type argument, because there is nothing left to ask:

import java.util.List;

public class InstanceOf {
    static boolean isStringList(Object o) {
        return o instanceof List<String>;
    }

    public static void main(String[] args) { }
}
InstanceOf.java:5: error: Object cannot be safely cast to List<String>
        return o instanceof List<String>;
               ^
1 error

o instanceof List<?> and o instanceof List both compile and both return true for any list, because both ask a question the run time can answer.

Holder.java feeding a class file that stores both an erased descriptor and a kept Signature attribute

What the class file actually stores

Compile this class and look at it with javap:

import java.util.List;

public class Holder<T extends Number> {
    private T value;
    private List<T> history;

    public T get() { return value; }

    public void set(T value) { this.value = value; }

    public <U> U passThrough(U x) { return x; }
}
javac Holder.java
javap -s -p Holder
Compiled from "Holder.java"
public class Holder<T extends java.lang.Number> {
  private T value;
    descriptor: Ljava/lang/Number;
  private java.util.List<T> history;
    descriptor: Ljava/util/List;
  public Holder();
    descriptor: ()V

  public T get();
    descriptor: ()Ljava/lang/Number;

  public void set(T);
    descriptor: (Ljava/lang/Number;)V

  public <U> U passThrough(U);
    descriptor: (Ljava/lang/Object;)Ljava/lang/Object;
}

The descriptor is what the JVM links against, and it shows the erasure rule in full. A bounded type variable erases to its first bound, so T extends Number becomes Ljava/lang/Number;. An unbounded one erases to Object, so U becomes Ljava/lang/Object;. A parameterized type erases to its raw form, so List<T> becomes Ljava/util/List;.

Because the return type is erased, the caller has to restore it. javac inserts exactly the cast you would have written by hand:

import java.util.List;

public class G {
    static int len(List<String> names) {
        return names.get(0).length();
    }
}
import java.util.List;

public class R {
    static int len(List names) {
        return ((String) names.get(0)).length();
    }
}

javap -c gives both methods the same body, instruction for instruction:

  static int len(java.util.List<java.lang.String>);
    Code:
       0: aload_0
       1: iconst_0
       2: invokeinterface #7,  2            // InterfaceMethod java/util/List.get:(I)Ljava/lang/Object;
       7: checkcast     #13                 // class java/lang/String
      10: invokevirtual #15                 // Method java/lang/String.length:()I
      13: ireturn
  static int len(java.util.List);
    Code:
       0: aload_0
       1: iconst_0
       2: invokeinterface #7,  2            // InterfaceMethod java/util/List.get:(I)Ljava/lang/Object;
       7: checkcast     #13                 // class java/lang/String
      10: invokevirtual #15                 // Method java/lang/String.length:()I
      13: ireturn

That is the honest answer to "do generics cost anything at run time". The generic version and the hand-cast version compile to identical bytecode, so there is nothing to measure — the difference between them exists only while javac is running.

The Signature attribute is why generics survive separate compilation

If erasure were the whole story, the compiler could not type-check code written against a library it did not compile in the same run. Holder is a .class file on the classpath; the caller of holder.get() still needs to know the return type is T. That information is stored, just not in the descriptor. javap -v shows both:

javap -v -p Holder
  public T get();
    descriptor: ()Ljava/lang/Number;
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: getfield      #7                  // Field value:Ljava/lang/Number;
         4: areturn
      LineNumberTable:
        line 7: 0
    Signature: #22                          // ()TT;

Signature is a class-file attribute holding the generic form as a string, and it exists at three levels. The class carries one:

Signature: #29                          // <T:Ljava/lang/Number;>Ljava/lang/Object;

Each generic field carries one, and so does each generic method. Those strings live in the constant pool alongside the descriptors:

  #21 = Utf8               ()Ljava/lang/Number;
  #22 = Utf8               ()TT;
  #27 = Utf8               (Ljava/lang/Object;)Ljava/lang/Object;
  #28 = Utf8               <U:Ljava/lang/Object;>(TU;)TU;
  #29 = Utf8               <T:Ljava/lang/Number;>Ljava/lang/Object;

So the slogan "generic information is erased" is imprecise in a way that matters. It is erased from the bytecode — the descriptors, the instructions, the linkage the JVM performs. It is preserved in the class file, as metadata that only javac and reflection read. That is why Box<String> still type-checks against a Box.class compiled last year, and why Method.getGenericReturnType() reports T for Holder.get where Method.getReturnType() reports the erasure, class java.lang.Number.

Bridge methods

Erasure creates one problem the compiler has to solve behind your back. Consider a class that fixes its supertype's parameter:

class Node<T> {
    T value;
    void set(T value) { this.value = value; }
}

public class Bridge extends Node<String> {
    @Override
    void set(String value) { super.set(value); }
}

Node.set erases to set(Object). Bridge.set is declared as set(String). Those are different descriptors, so as far as the JVM is concerned Bridge.set does not override anything — and a virtual call through a Node reference would run the wrong body. javac closes the gap by generating a second method:

  void set(java.lang.String);
    Code:
       0: aload_0
       1: aload_1
       2: invokespecial #7                  // Method Node.set:(Ljava/lang/Object;)V
       5: return

  void set(java.lang.Object);
    Code:
       0: aload_0
       1: aload_1
       2: checkcast     #11                 // class java/lang/String
       5: invokevirtual #13                 // Method set:(Ljava/lang/String;)V
       8: return

The second one is synthetic, and javap -v labels it:

  void set(java.lang.Object);
    descriptor: (Ljava/lang/Object;)V
    flags: (0x1040) ACC_BRIDGE, ACC_SYNTHETIC

It casts and delegates. That cast is normally invisible, because the compiler has already proved that only strings can arrive. Defeat the proof with a raw type and the bridge is where the program dies:

public class RawCall {
    public static void main(String[] args) {
        Node raw = new Bridge();
        raw.set(42);            // dispatches to the bridge method
    }
}
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')
	at Bridge.set(Bridge.java:6)
	at RawCall.main(RawCall.java:4)

Line 6 of Bridge.java is the class declaration. There is no set on that line — the frame belongs to a method you never wrote.

What erasure makes impossible

Four restrictions that look arbitrary all come from the same place, and one compile reports all four:

public class Impossible<T> {
    private static T shared;              // 1. static field of type T

    T[] makeArray(int n) {
        return new T[n];                  // 2. array creation
    }

    T makeOne() {
        return new T();                   // 3. instantiation
    }

    boolean isT(Object o) {
        return o instanceof T;            // 4. instanceof
    }
}
Impossible.java:2: error: non-static type variable T cannot be referenced from a static context
    private static T shared;              // 1. static field of type T
                   ^
Impossible.java:5: error: generic array creation
        return new T[n];                  // 2. array creation
               ^
Impossible.java:9: error: unexpected type
        return new T();                   // 3. instantiation
                   ^
  required: class
  found:    type parameter T
  where T is a type-variable:
    T extends Object declared in class Impossible
Impossible.java:13: error: Object cannot be safely cast to T
        return o instanceof T;            // 4. instanceof
               ^
4 errors

A static field belongs to the class, and the class exists once for every instantiation, so a static T would have to be String and Integer at the same time. new T[n] cannot work because an array records its component type at run time and there is no run-time T to record. new T() has no constructor to call — T might be an interface, or a class with no no-argument constructor. And instanceof T asks a question no run-time value can answer.

Two more restrictions come from the same source. A type argument must be a reference type, because the erased slot holds a reference:

Primitive.java:4: error: unexpected type
    List<int> numbers;
         ^
  required: reference
  found:    int
1 error

List<Integer> is the way, and each int is boxed on the way in. And a class cannot implement two parameterizations of the same interface, because after erasure they are one interface:

TwoParam.java:1: error: repeated interface
public class TwoParam implements Comparable<String>, Comparable<Integer> {
                                                               ^
TwoParam.java:1: error: Comparable cannot be inherited with different arguments: <java.lang.String> and <java.lang.Integer>
public class TwoParam implements Comparable<String>, Comparable<Integer> {
       ^
2 errors

catch (T e) is rejected for the same reason as instanceof: the JVM matches a handler by the class in the exception table, and T produces no such class.

CatchT.java:5: error: unexpected type
        } catch (T e) {
                 ^
  required: class
  found:    type parameter T
  where T is a type-variable:
    T extends Exception declared in class CatchT
1 error

The Class token workaround

Every one of those restrictions is really "there is no run-time T", so the fix is always the same: pass one in. A Class<T> is an ordinary object that survives to run time, and it carries the type argument in its own type.

public class Factory<T> {
    private final Class<T> type;   // the token that survives erasure

    public Factory(Class<T> type) { this.type = type; }

    public T create() throws ReflectiveOperationException {
        return type.getDeclaredConstructor().newInstance();
    }

    public boolean isInstance(Object o) {
        return type.isInstance(o);   // stands in for `o instanceof T`
    }

    @SuppressWarnings("unchecked")
    public T[] newArray(int n) {
        return (T[]) java.lang.reflect.Array.newInstance(type, n);
    }

    public static void main(String[] args) throws Exception {
        Factory<StringBuilder> f = new Factory<>(StringBuilder.class);
        StringBuilder sb = f.create();
        sb.append("built");
        System.out.println(sb);
        System.out.println(f.isInstance("a string"));
        System.out.println(f.newArray(3).getClass().getName());
    }
}
built
false
[Ljava.lang.StringBuilder;

newArray returns a genuine StringBuilder[], not an Object[] in disguise, because Array.newInstance was given a real class. This is the pattern behind Collection.toArray(T[]) and behind every framework that asks you to hand it a .class literal.

Unchecked warnings and what @SuppressWarnings actually promises

An unchecked warning means the compiler was asked to believe something it cannot verify. The commonest source is the array workaround:

public class NoSuppress<T> {
    private final T[] items;

    public NoSuppress(int capacity) {
        items = (T[]) new Object[capacity];
    }
}
NoSuppress.java:5: warning: [unchecked] unchecked cast
        items = (T[]) new Object[capacity];
                      ^
  required: T[]
  found:    Object[]
  where T is a type-variable:
    T extends Object declared in class NoSuppress
1 warning

@SuppressWarnings("unchecked") removes the message. It does not add a check, does not make the cast safe, and does not restrict anything at run time — it is a note to the compiler saying I have proved this by hand, stop asking. If the proof is wrong, the failure simply moves somewhere else:

public class Stack<T> {
    private final T[] items;
    private int size;

    @SuppressWarnings("unchecked")
    public Stack(int capacity) {
        // safe: items is private, never leaks, and only T ever goes in
        items = (T[]) new Object[capacity];
    }

    public void push(T item) { items[size++] = item; }

    public T pop() { return items[--size]; }

    public T[] leak() { return items; }   // this is the unsafe part
}
public class Main {
    public static void main(String[] args) {
        Stack<String> s = new Stack<>(4);
        s.push("a");
        System.out.println(s.pop());
        String[] bad = s.leak();
    }
}
a
Exception in thread "main" java.lang.ClassCastException: class [Ljava.lang.Object; cannot be cast to class [Ljava.lang.String; ([Ljava.lang.Object; and [Ljava.lang.String; are in module java.base of loader 'bootstrap')
	at Main.main(Main.java:6)

The comment in the constructor was true when it was written and became false the moment leak() was added. Notice where the exception lands: Main.java:6, in the caller. The suppressed cast is in Stack, the crash is somewhere else, and nothing in the stack trace mentions the annotation.

Casting one parameterized type to another is worse, because the corruption is silent:

import java.util.ArrayList;
import java.util.List;

public class Pollute {
    @SuppressWarnings("unchecked")
    static <T> List<T> pretend(List<?> any) {
        return (List<T>) any;   // the compiler is told to stop asking
    }

    public static void main(String[] args) {
        List<Integer> ints = new ArrayList<>(List.of(1, 2));
        List<String> lie = pretend(ints);
        lie.add("three");                 // no complaint at all
        System.out.println(ints);         // the Integer list now holds a String
        String s = lie.get(2);            // fine
        System.out.println(s);
        Integer boom = ints.get(2);       // the cast the compiler inserted here fails
    }
}
[1, 2, three]
three
Exception in thread "main" java.lang.ClassCastException: class java.lang.String cannot be cast to class java.lang.Integer (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')
	at Pollute.main(Pollute.java:17)

A List<Integer> printed [1, 2, three] and nothing complained until an unrelated line read it back. This is heap pollution: a parameterized variable pointing at an object that does not satisfy its parameterization.

⚠️ Put @SuppressWarnings("unchecked") on the smallest possible declaration — a local variable or a single method, never a class or a whole file — and write a comment saying why the cast is provably safe. A suppression with no justification is a bug that has not happened yet.

Generic varargs raise a related warning, because a T... parameter is really a T[] and Java just told you it cannot make one safely:

import java.util.Arrays;
import java.util.List;

public class Varargs {
    static <T> List<T> listOf(T... items) {
        return Arrays.asList(items);
    }

    public static void main(String[] args) {
        System.out.println(listOf("a", "b"));
    }
}
Varargs.java:5: warning: [unchecked] Possible heap pollution from parameterized vararg type T
    static <T> List<T> listOf(T... items) {
                                   ^
  where T is a type-variable:
    T extends Object declared in method <T>listOf(T...)
1 warning

@SafeVarargs is the correct annotation here, and it is stricter than @SuppressWarnings: it may only be applied to a method that cannot be overridden — static, final or private — and putting it anywhere else is an error rather than a warning:

Safe.java:9: error: Invalid SafeVarargs annotation. Instance method <T>notFinal(T...) is neither final nor private.
    <T> List<T> notFinal(T... items) { return Arrays.asList(items); }
                ^
  where T is a type-variable:
    T extends Object declared in method <T>notFinal(T...)
1 error

It also silences the call site, not only the declaration. Passing two List<String> values to an unannotated generic varargs method reports unchecked generic array creation for varargs parameter of type List<String>[]; the annotated version reports nothing.

The last warning worth recognising comes from raw types, and it is far more destructive than it looks. Using a class raw does not just erase the parameter you skipped; it erases every generic signature on that class, including ones with no relation to T:

import java.util.List;

class Store<T> {
    T item;
    List<String> tags() { return List.of("a"); }
}

public class RawWipes2 {
    public static void main(String[] args) {
        Store raw = new Store<String>();
        for (String tag : raw.tags()) System.out.println(tag);
    }
}
RawWipes2.java:11: error: incompatible types: Object cannot be converted to String
        for (String tag : raw.tags()) System.out.println(tag);
                                  ^
1 error

tags() returns List<String> and never mentions T, but through a raw Store it returns a raw List. Raw types exist only for compatibility with code written before 2004. Compile with -Xlint:all and they announce themselves:

Diamond.java:8: warning: [rawtypes] found raw type: ArrayList
        List<String> c = new ArrayList();
                             ^
  missing type arguments for generic class ArrayList<E>

Practical rules for your own generic APIs

Prefer a generic method to a wildcard when the type appears more than once. A wildcard is a single unknown; if two positions in a signature must agree, the wildcard cannot say so. The classic demonstration is swap:

import java.util.List;

public class SwapBad {
    static void swap(List<?> list, int i, int j) {
        list.set(i, list.set(j, list.get(i)));
    }

    public static void main(String[] args) { }
}
SwapBad.java:5: error: incompatible types: Object cannot be converted to CAP#1
        list.set(i, list.set(j, list.get(i)));
                                        ^
  where CAP#1 is a fresh type-variable:
    CAP#1 extends Object from capture of ?
1 error

The value read out and the value written back are the same element, but each ? captures independently, so the compiler will not connect them. Naming the type does connect them:

import java.util.ArrayList;
import java.util.List;

public class SwapGood {
    static <T> void swap(List<T> list, int i, int j) {
        list.set(i, list.set(j, list.get(i)));
    }

    public static void main(String[] args) {
        List<String> xs = new ArrayList<>(List.of("a", "b", "c"));
        swap(xs, 0, 2);
        System.out.println(xs);
    }
}
[c, b, a]

Use bounded wildcards on parameters, and never on return types. A wildcard in a parameter position widens what callers can pass. A wildcard in a return position narrows what callers can do with the result, and it is contagious — the caller has to declare a wildcard variable too, and is then stuck with the same restrictions:

import java.util.ArrayList;
import java.util.List;

public class BadReturn {
    static List<? extends Number> load() {
        return new ArrayList<Integer>();
    }

    public static void main(String[] args) {
        List<? extends Number> nums = load();
        nums.add(1);
    }
}
BadReturn.java:11: error: incompatible types: int cannot be converted to CAP#1
        nums.add(1);
                 ^
  where CAP#1 is a fresh type-variable:
    CAP#1 extends Number from capture of ? extends Number
1 error

Return List<Integer> and the caller decides for themselves whether to widen it.

Use the diamond, and never a raw type. new ArrayList<>() infers the argument from the left-hand side; new ArrayList() is a raw type, and javac -Xlint:all reports it twice — once as [rawtypes] and once as [unchecked] unchecked conversion.

RuleWhy
Parameterize every type you declarea raw type wipes every generic signature on the class
extends for what you read fromthe element type is some unknown subtype
super for what you write intothe element type is some unknown supertype
No wildcard on a return typeit forces the restriction on every caller
Generic method when a type repeatsone named T ties the positions together
Bound only as far as you needT extends Number erases to Number, T erases to Object
Narrow every @SuppressWarningsthe crash lands far from the suppression

FAQ

What are generics in Java and why are they needed?

Generics let a class, interface or method take a type as a parameter, so List<String> is a list whose elements the compiler knows are strings. Before Java 5 a collection held Object, every read needed an explicit cast, and a wrong cast produced a ClassCastException at run time — on the line that read the value, not the line that stored it. With a type argument the same mistake is error: incompatible types: int cannot be converted to String at the line that stores it, and the read needs no cast at all.

What is the difference between List<?>, List<Object> and a raw List?

A raw List turns off generic checking: it accepts any list, lets you add anything with an unchecked warning, and returns Object. List<Object> is fully checked but accepts only a list actually declared as List<Object>List<String> will not convert to it. List<?> accepts any list, returns Object, and refuses every add except add(null), because the element type is unknown. Use List<?> when you only need size(), isEmpty() or iteration as Object; never use a raw type.

What does ? extends mean and why can I not add to that list?

List<? extends Number> means a list of some single unknown subtype of Number — possibly List<Double>, possibly List<Integer>. Reading is safe because every element is a Number. Adding is not, because the compiler cannot prove your value belongs to whichever type it actually is; it reports incompatible types: int cannot be converted to CAP#1, where CAP#1 is the fresh type variable it invented for the capture. The single exception is add(null), since null is a member of every reference type.

When should I use super in a generic type?

When the parameter consumes values you supply. List<? super Integer> accepts List<Integer>, List<Number> and List<Object>, and every one of them can hold an Integer, so add compiles. Reading back gives Object — asking for an Integer produces incompatible types: CAP#1 cannot be converted to Integer. super also appears in bounds like Comparable<? super T>, which is what lets Collections.sort accept a List<Dog> whose compareTo is inherited from Animal.

What is type erasure in Java?

Erasure is the compiler replacing every type variable with its erasure and dropping the type arguments after checking them. An unbounded T becomes Object, a bounded T extends Number becomes Number, List<T> becomes List, and javac inserts a checkcast wherever a value comes back out. javap -s shows the result: public T get() has descriptor ()Ljava/lang/Number;. At run time new ArrayList<String>() and new ArrayList<Integer>() produce objects whose getClass() is the same java.util.ArrayList.

Why can I not create an array of a generic type in Java?

new T[n] gives error: generic array creation. An array stores its component type in the object header and checks it on every write — that is what throws ArrayStoreException — but erasure leaves no run-time T to store, so the array could not perform its own check. The workarounds are (T[]) new Object[n] with a narrowly scoped @SuppressWarnings("unchecked") and a private field that never escapes, or Array.newInstance(type, n) with a Class<T> token, which produces a real typed array.

Is there a performance cost to using generics in Java?

No. A generic method and the same method written with a hand-written cast compile to byte-for-byte identical instruction sequences — both end in invokeinterface, checkcast, invokevirtual. One class file serves every instantiation, so there is no code-size cost either. The one real cost is unrelated to generics as a language feature: a type argument must be a reference type, so List<Integer> boxes each int into an Integer object where an int[] would not.

What does the unchecked warning mean and is it safe to suppress it?

It means the compiler could not verify a cast or a call and is proceeding on trust — typically (T[]) new Object[n], a cast between parameterized types, or any call through a raw type. Suppressing it is safe only when you can state the proof it could not construct. @SuppressWarnings("unchecked") changes nothing at run time; if the proof is wrong you get heap pollution, and the ClassCastException lands in unrelated code that merely read the corrupted object.

Conclusion

Generics are one idea applied consistently: move a type from the value to the declaration, let the compiler check it, then throw it away. The checking is what replaces ClassCastException with a build error and removes casts from your code. The throwing away is what produces every restriction — no new T[], no new T(), no static T, no instanceof T, no two overloads differing only by type argument — and also what makes generics free, since the bytecode is exactly what you would have written by hand.

Between those two halves sit the parts that take practice. Invariance is not a limitation to work around but the guarantee itself: if List<String> were assignable to List<Object>, nothing above would hold. Wildcards restore the flexibility one direction at a time, and PECS is just the observation that a producer can only be read and a consumer can only be written. Bounds are the same trade in miniature — every capability you add to a type variable narrows the set of types allowed to fill it. And the Signature attribute is the detail that makes the whole scheme work across compilation units: the bytecode forgets, the class file remembers.

Next in this series: the SOLID principles — the five design rules that decide how responsibilities get split between classes, and how a codebase behaves when requirements change.

Related Posts

[Advanced Java] Advanced Enums in Java: Constructors, Constant Bodies, EnumMap and the Enum Singleton

Advanced enums in Java on OpenJDK 21: what javap shows an enum actually compiles to, fields and the implicitly private constructor, constant-specific class bodies and the extra class files they emit, abstract methods, enums implementing interfaces, EnumMap and EnumSet, exhaustive switch, the enum singleton reflection refuses to break, an enum state machine, and the ordinal and values() traps.

[Advanced Java] Functional Interfaces: Supplier, Consumer, Function and Predicate

Functional interfaces in java.util.function on OpenJDK 21: the shape grid behind all 43 of them, what @FunctionalInterface really checks, why an abstract equals does not break single-abstract-method status, andThen versus compose, the Predicate and Consumer combinators, the primitive specialisations and the boxing they remove, and how to write your own.

[Advanced Java] The Java Stream API: map, filter, reduce and collect

The Java Stream API on OpenJDK 21: the source-intermediate-terminal pipeline, laziness proved with an interleaved println trace, map, filter, all three reduce overloads, collect and the Collectors factory, primitive streams and the allocation cost of boxing, and the traps around peek, findAny, stateful lambdas and parallelStream.

[Advanced Java] Optional in Java: A Return Type, Not a Cure for NullPointerException

Optional in Java on OpenJDK 21: why it was designed for return types rather than as a null replacement, its two legal states and the null reference that defeats them, all twenty methods grouped by purpose, the orElse versus orElseGet evaluation trap counted in database hits, map versus flatMap with the real javac error, the five positions where Optional makes code worse, and the three places it genuinely belongs.