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.
![]()
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 List | List<String> | |
|---|---|---|
Adding an Integer | compiles, with a warning | error: incompatible types |
| Reading an element | Object, cast by hand | String, no cast written |
| Where a mistake shows up | run time, at the cast | compile time, at the call |
| What the failure looks like | ClassCastException | a 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.

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.
| Letter | Conventional meaning | Seen in |
|---|---|---|
T | type, no further meaning | Box<T>, Comparable<T> |
E | element of a collection | List<E>, Set<E> |
K, V | key and value | Map<K, V> |
N | number | numeric utility classes |
R | result of a transformation | mapping methods |
S, U, V | second, third, fourth type | Pair<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

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 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.
| Type | Accepts as argument | add("x") | get(0) returns |
|---|---|---|---|
List (raw) | any list | compiles, unchecked warning | Object |
List<Object> | only List<Object> | compiles | Object |
List<?> | any list | error: ... cannot be converted to CAP#1 | Object |
List<? extends Number> | any list of a Number subtype | same error | Number |
List<? super Integer> | any list of an Integer supertype | compiles | Object |
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.

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.
| Rule | Why |
|---|---|
| Parameterize every type you declare | a raw type wipes every generic signature on the class |
extends for what you read from | the element type is some unknown subtype |
super for what you write into | the element type is some unknown supertype |
| No wildcard on a return type | it forces the restriction on every caller |
| Generic method when a type repeats | one named T ties the positions together |
| Bound only as far as you need | T extends Number erases to Number, T erases to Object |
Narrow every @SuppressWarnings | the 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.