Before generics (pre-Java 5), collections held plain Object references:
List list = new ArrayList();
list.add("hello");
list.add(42); // no compile-time complaint — mixed types allowed
String s = (String) list.get(1); // compiles, but throws ClassCastException at runtime
Generics let you parameterize a type with the element type it holds, moving that error from a runtime surprise to a compile-time error:
List<String> list = new ArrayList<>();
list.add("hello");
list.add(42); // compile error — caught immediately
String s = list.get(0); // no cast needed
Benefits:
- Compile-time type checking — catches type mismatches before the program ever runs.
- Eliminates explicit casts — the compiler inserts them safely where needed.
- Enables generic algorithms — a single
sortmethod can work correctly onList<String>,List<Integer>, etc., without duplication.
Generics are implemented via type erasure (the compiler enforces the constraints, then strips the type parameter information for the compiled bytecode) — which explains several of their more advanced quirks and limitations.
Java generics are a compile-time only feature — the JVM itself has no concept of List<String> vs List<Integer>; both compile down to plain List (using Object, or the bound type, internally). This is type erasure: the compiler checks your generic type usage for correctness, inserts the necessary casts, and then discards the type parameter information.
Consequences / limitations:
- Can't create an instance of a type parameter:
new T()doesn't compile — the compiler has no idea whatTactually is at runtime. - Can't check generic types with
instanceof:if (obj instanceof List<String>)doesn't compile — onlyobj instanceof List(the raw type) is allowed, since the<String>information is erased. - Can't create a generic array directly:
new T[10]ornew List<String>[10]doesn't compile, due to interactions with array covariance and erasure that could otherwise let you store the wrong type undetected. - All parameterized instances share one class:
List<String>.classandList<Integer>.classare the sameClassobject at runtime —list1.getClass() == list2.getClass()istrueeven for different element types. - Overloading on erased types conflicts: you can't overload
void foo(List<String> s)andvoid foo(List<Integer> i)in the same class — after erasure, both becomevoid foo(List).
Erasure exists mainly for backward compatibility: it let pre-generics bytecode (Java 1.4 and earlier) keep working alongside generic code without a parallel, incompatible runtime type system.
Without a bound, a type parameter <T> can be anything, and the compiler only lets you treat values as Object — you can't call any type-specific method on them. A bounded type parameter restricts T to a subtype of a given type, unlocking that type's methods:
static <T extends Number> double sumAll(List<T> list) {
double sum = 0;
for (T t : list) sum += t.doubleValue(); // legal — Number guarantees doubleValue()
return sum;
}
sumAll(List.of(1, 2, 3)); // T = Integer, extends Number — OK
sumAll(List.of(1.0, 2.0)); // T = Double, extends Number — OK
sumAll(List.of("a", "b")); // compile error — String isn't a Number
Multiple bounds are allowed with &, useful for requiring both a base class and an interface — but only one class bound is allowed, and it must be listed first:
static <T extends Number & Comparable<T>> T max(List<T> list) {
T best = list.get(0);
for (T t : list) if (t.compareTo(best) > 0) best = t;
return best;
}
Despite the keyword extends being reused, it means "is a subtype of" in this context — it works for both class inheritance and interface implementation (<T extends Comparable<T>> is valid even though Comparable is an interface).
Wildcards express "some unknown type" with varying degrees of constraint, used mainly for method parameters that need flexibility beyond an exact generic type match:
List<?>(unbounded wildcard): a list of some type, but the compiler doesn't know which. You can call type-agnostic methods (size(),clear()), and read elements only asObject; you cannotadd()anything exceptnull(the compiler can't verify any specific type is safe to insert).
void printAll(List<?> list) {
for (Object o : list) System.out.println(o); // fine — read as Object
}
List<? extends T>(upper-bounded / "producer"): a list ofTor any subtype ofT. Safe to readT(or a supertype) out of it, since every element is guaranteed to be at least aT. Cannot add (exceptnull), because the compiler doesn't know the exact subtype — adding a plainTcould violate an actualList<Dog>masquerading asList<? extends Animal>.
double sum(List<? extends Number> nums) {
double s = 0;
for (Number n : nums) s += n.doubleValue(); // read OK
// nums.add(5); // compile error
return s;
}
List<? super T>(lower-bounded / "consumer"): a list ofTor any supertype ofT. Safe to add aT(or subtype) into it, since any supertype list can hold aT. Reading only guaranteesObject, since the actual list could be any supertype.
void addNumbers(List<? super Integer> list) {
list.add(1); list.add(2); // write OK
Object o = list.get(0); // only guaranteed to be Object
}
This read/write asymmetry is exactly the PECS principle: "Producer extends, Consumer super."
PECS — "Producer extends, Consumer super" — is Joshua Bloch's mnemonic (from Effective Java) for picking the right wildcard on a generic method parameter:
- If the parameter is a producer — your method only reads values out of it — use
? extends T. - If the parameter is a consumer — your method only writes values into it — use
? super T. - If it does both, don't use a wildcard at all — use the exact type
T.
The textbook example is Collections.copy:
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
for (int i = 0; i < src.size(); i++) {
dest.set(i, src.get(i));
}
}
srcis a producer ofT(the method only reads from it) →? extends T.destis a consumer ofT(the method only writes into it) →? super T.
This lets you call copy(List<Object>, List<Integer>) even though List<Object> isn't a List<Integer> and vice versa — without PECS-style wildcards, generics' lack of subtyping between List<Integer> and List<Object> would make such a utility method far less reusable.
PECS is the practical reasoning behind the read/write restrictions on ? extends/? super covered by wildcard generics — it's less something to memorize abstractly and more a direct consequence of what's actually type-safe to do with each.