Java Full Course 2026: Wildcards in Generics | ?, ? extends, ? super
Generics in Java provide a powerful mechanism to design general-purpose, type-safe classes and methods. However, they introduce unique behaviors—such as breaking traditional inheritance hierarchies—that require a deep understanding of wildcards (?), upper bounds (extends), and lower bounds (super). This chapter explores why generics behave this way, how wildcards restore flexibility without sacrificing type safety, and how Java handles generics under the hood via Type Erasure.
1. The Evolution of General-Purpose Classes & The Invariance Problem
To write reusable code, Java developers historically relied on the Object class to create general-purpose containers. Because Object is the root of Java's class hierarchy, any data type could be stored in an Object-based container. However, this approach lacked type safety and required explicit, error-prone casting at runtime.
Java introduced Generics to solve this problem by using type parameters (placeholders like <T>) to enforce type safety at compile time.
// A simple generic container
public class Box<T> {
private T value;
public void setValue(T value) {
this.value = value;
}
public T getValue() {
return value;
}
}
While generics solved the type safety issue, they introduced a fundamental architectural constraint: Generics break the traditional parent-child relationship.
In standard Java inheritance, a parent class reference can point to a child class object. However, a generic class instantiated with a parent type has absolutely no relationship with the same generic class instantiated with a child type.
2. The Parent-Child Relationship Breakage in Generics
To understand how generics break inheritance, consider a standard class hierarchy:
Animal (Parent)
^
| (extends)
Dog (Child)
class Animal {
void eat() { System.out.println("Animal eating"); }
void walk() { System.out.println("Animal walking"); }
}
class Dog extends Animal {
void bark() { System.out.println("Dog barking"); }
}
Because of polymorphism, the following assignment is perfectly valid:
Animal a = new Dog(); // Valid: Dog IS-A Animal
Now, let us apply this to Java's List framework (which heavily utilizes generics). If a Dog is an Animal, it is logical to assume that a list of Dogs can be assigned to a reference of a list of Animals:
List<Dog> dogs = new ArrayList<>();
// Compile-Time Error: Type mismatch
List<Animal> animals = dogs;
Even though Dog is a subclass of Animal, List<Dog> is not a subclass of List<Animal>. In generic terminology, these two types are completely invariant to each other.
Conceptual Type Relationship
3. Why Generics are Invariant: A Deep Dive into Type Safety
Java enforces invariance on generics to guarantee compile-time type safety. If Java allowed List<Animal> animals = dogs;, the type safety of the entire application would collapse.
The Failure Scenario
Imagine a scenario where the compiler allowed this assignment:
List<Dog> dogs = new ArrayList<>();
dogs.add(new Dog());
// Assume this was allowed:
List<Animal> animals = dogs;
// Since 'animals' is a List<Animal>, we can legally add any Animal to it
animals.add(new Cat()); // Cat is also an Animal!
The Memory State After the Operation
If another part of the program iterates through dogs expecting only Dog objects, it will crash at runtime:
for (Dog d : dogs) {
d.bark(); // Throws ClassCastException when it encounters the Cat object!
}
To prevent these runtime failures, Java's compiler blocks the assignment List<Animal> animals = dogs; at compile time.
4. Arrays vs. Generics: Covariance vs. Invariance
Unlike generics, Java arrays are covariant. This means if A is a subclass of B, then A[] is a subclass of B[].
Dog[] dogs = new Dog[10];
Animal[] animals = dogs; // Valid compilation!
While this compiles, it is highly unsafe and can lead to runtime crashes.
animals[0] = new Animal(); // Compiles fine, but throws ArrayStoreException at runtime
Comparison of Arrays and Generics
5. Unbounded Wildcards (?): The Quest for Flexibility
Invariance prevents us from writing flexible, reusable methods. For example, consider a method designed to print elements of a list:
public static void printList(List<Animal> list) {
for (Animal a : list) {
a.eat();
}
}
You cannot pass a List<Dog> to this method, even though every dog can eat. To resolve this, Java introduced Wildcards represented by the question mark (?).
An unbounded wildcard (List<?>) represents a list of an unknown, specific type.
public static void printAnyList(List<?> list) {
for (Object obj : list) {
System.out.println(obj.getClass().getName());
}
}
The Read-Only Nature of Unbounded Wildcards
Because the compiler does not know what specific type the wildcard ? represents, it restricts operations on the list to ensure safety:
- Writing is Blocked: You cannot add any element (except
null) to aList<?>.JAVAList<?> list = new ArrayList<Dog>(); list.add(new Dog()); // Compile-Time Error! - Reading is Restricted: You can only read elements as
ObjectbecauseObjectis the only guaranteed supertype of any unknown type.JAVAObject obj = list.get(0); // Valid
6. Upper Bounded Wildcards (? extends T): Safe Reading (Covariance)
To allow reading subclass elements safely without losing their specific parent type information, Java provides Upper Bounded Wildcards using the extends keyword.
List<? extends Animal> list;
This declaration means: "A list of some unknown type that is either Animal or a subclass of Animal."
Animal (Upper Bound)
/ \
Dog Cat (Allowed Subtypes)
Code Implementation Example
public static void eatAll(List<? extends Animal> animals) {
for (Animal a : animals) {
a.eat(); // Valid: Every element is guaranteed to be at least an Animal
}
}
Why Writing is Blocked in ? extends T
Even though the compiler knows the elements are at least Animals, it still blocks write operations:
public static void addAnimals(List<? extends Animal> animals) {
// Compile-Time Error!
animals.add(new Dog());
}
Why? If the method is called with a
List<Cat>, adding aDogto it would violate type safety. Because the compiler cannot verify the actual runtime type of the list, it prohibits adding elements entirely.
7. Lower Bounded Wildcards (? super T): Safe Writing (Contravariance)
If you need to write elements to a generic collection, you must use a Lower Bounded Wildcard using the super keyword.
List<? super Dog> list;
This declaration means: "A list of some unknown type that is either Dog or a superclass of Dog (such as Animal or Object)."
Object (Allowed Supertype)
|
Animal (Allowed Supertype)
|
Dog (Lower Bound)
Code Implementation Example
public static void addDogs(List<? super Dog> dogList) {
dogList.add(new Dog()); // Valid: Dog can safely reside in List<Dog>, List<Animal>, or List<Object>
dogList.add(new Labrador()); // Valid: Labrador is a subclass of Dog
// dogList.add(new Animal()); // Compile-Time Error: Animal is a superclass, not a subclass of Dog
}
Why Reading is Restricted in ? super T
When reading from a List<? super Dog>, the compiler cannot guarantee whether the list contains Dog, Animal, or Object. Therefore, it only allows reading elements as Object.
Object obj = dogList.get(0); // Valid
// Dog d = dogList.get(0); // Compile-Time Error!
8. The PECS Rule: Producer Extends, Consumer Super
To easily remember when to use which wildcard, Java architects use the PECS guideline:
- Producer (
extends): If your generic container primarily produces data (you read from it), use? extends T. - Consumer (
super): If your generic container primarily consumes data (you write to it), use? super T.
Summary of Wildcard Behaviors
9. Type Parameter T vs. Wildcard ?: When to Use Which
Developers often struggle to choose between using a named type parameter (like <T>) and a wildcard (?).
1. Use Type Parameter T when:
- You need to express a relationship between multiple parameters or between a parameter and the return type.
JAVA
// T binds the input type directly to the return type public <T> T firstElement(List<T> list) { return list.get(0); } - You need to perform both read and write operations on the same generic variable.
2. Use Wildcard ? when:
- The type parameter is only used once in the method signature (e.g., to allow flexible input arguments).
JAVA
// No relationship needs to be maintained; wildcards are cleaner public void printList(List<? extends Animal> list) { ... }
10. Under the Hood: Type Erasure and JVM Internals
A common misconception is that the Java Virtual Machine (JVM) understands generics. In reality, the JVM has absolutely no knowledge of generics.
Generics are purely a compile-time construct. During compilation, the Java compiler applies a process called Type Erasure to convert generic code into standard, non-generic bytecode for backward compatibility.
The Compilation Workflow
Type Erasure Rules Applied by the Compiler
Rule 1: Replace Unbounded Types with Object
If a type parameter is unbounded (T), it is replaced with Object in the compiled bytecode.
- Source Code Written by Developer:
JAVA
public class Box<T> { private T value; public T getValue() { return value; } } - Equivalent Bytecode Generated by Compiler:
JAVA
public class Box { private Object value; public Object getValue() { return value; } }
Rule 2: Replace Bounded Types with the Bound Class
If a type parameter is bounded (T extends Bound), it is replaced with the bound class.
- Source Code Written by Developer:
JAVA
public class NumericBox<T extends Number> { private T value; } - Equivalent Bytecode Generated by Compiler:
JAVA
public class NumericBox { private Number value; }
Rule 3: Insert Casts Automatically
Because the compiler erases generic types to Object or the bound class, it must insert explicit casts when retrieving values to ensure type safety.
- Source Code Written by Developer:
JAVA
Box<Integer> box = new Box<>(); box.setValue(10); Integer val = box.getValue(); - Equivalent Bytecode Generated by Compiler:
JAVA
Box box = new Box(); box.setValue(10); Integer val = (Integer) box.getValue(); // Cast inserted automatically
11. The Mechanics of Bridge Methods
Type erasure can sometimes break polymorphism when a class extends a generic parent class. To preserve inheritance and method overriding, the compiler automatically generates helper methods called Bridge Methods.
The Polymorphism Conflict
Consider a generic parent class and its subclass:
class Parent<T> {
public void set(T value) { System.out.println("Parent set"); }
}
class Child extends Parent<String> {
@Override
public void set(String value) { System.out.println("Child set: " + value); }
}
After type erasure, the classes are compiled as:
class Parent {
public void set(Object value) { ... }
}
class Child extends Parent {
// This does NOT override set(Object) because the signatures differ!
public void set(String value) { ... }
}
Because the signatures differ (set(Object) vs set(String)), method overriding is broken in the compiled bytecode.
The Compiler's Solution: Bridge Method Generation
To fix this, the compiler generates a hidden bridge method inside the Child class:
class Child extends Parent {
// Generated Bridge Method
public void set(Object value) {
set((String) value); // Delegates to the specific overridden method
}
public void set(String value) {
System.out.println("Child set: " + value);
}
}
The bridge method overrides Parent.set(Object) and safely delegates execution to the type-specific Child.set(String) method at runtime.
12. Why Generics Do Not Support Primitive Types
In Java, you cannot instantiate a generic class using a primitive type:
// Compile-Time Error!
List<int> list = new ArrayList<>();
You must use the corresponding wrapper class instead:
List<Integer> list = new ArrayList<>(); // Valid
The Architectural Reason
This restriction is a direct consequence of Type Erasure:
- During compilation, the type parameter
Tis erased toObject. - In Java,
Objectis the supertype of all reference types (classes, interfaces, arrays). - Primitive types (like
int,double,char) are stored directly in stack memory and do not inherit fromObject. - Because a primitive value cannot be cast to an
Objectreference, the JVM's erased bytecode cannot support primitives in generic structures.
13. Summary and Key Takeaways
- Generics are Invariant:
List<Dog>is not a subtype ofList<Animal>. This prevents runtime type pollution by catching invalid write operations at compile time. - Arrays are Covariant:
Dog[]is a subtype ofAnimal[]. This design is less safe because it can throw anArrayStoreExceptionat runtime. - Wildcards (
?) Restore Flexibility: They allow generic containers to accept varying type arguments while maintaining type safety. - Upper Bounded Wildcards (
? extends T):- Used for reading data safely.
- Guarantees elements are at least of type
T. - Prevents write operations.
- Lower Bounded Wildcards (
? super T):- Used for writing data safely.
- Guarantees the container can accept elements of type
Tor its subclasses. - Restricts read operations to returning
Object.
- PECS Rule: Use
extendsfor producers (reading) andsuperfor consumers (writing). - Type Erasure: Generics are a compile-time safety layer. The compiler erases all generic type parameters to
Objector their bounds, inserts casts, and generates bridge methods to maintain polymorphism at runtime. - No Primitive Support: Generics do not support primitives because primitives do not inherit from
Object, which is the default target for type erasure.