Java Tutorial
🔍

Java Generics in the Collections Framework

Generics in the Collections Framework

The Java Collections Framework and generics arrived together in Java 5, and that was not a coincidence — every core collection type was redesigned around type parameters at the same time. Before Java 5, List, Map, and Set stored raw Object references. Every retrieval needed a cast. Every cast was a runtime gamble. Generics changed the contract: List<E>, Map<K,V>, and Set<E> now carry the element type as part of their signature, and the compiler verifies every operation against it. This article brings together every concept from the Generics series — type parameters, bounded types, wildcards, PECS, and iterators — and shows where each one appears in the collections you write every day.

What Generics Changed for Collections

The practical difference is where errors appear. Without generics, a wrong-type insertion fails at the cast site — at runtime, possibly in production. With generics, it fails at the insertion site — at compile time, during development.

PRE-GENERICS (Java 1.4):

  List orders = new ArrayList();
  orders.add("ORD-001");
  orders.add(42);                          // silently accepted - no error

  String id = (String) orders.get(0);     // cast - works here
  String bad = (String) orders.get(1);    // ClassCastException at RUNTIME

  Map inventory = new HashMap();
  inventory.put("P001", 120);
  int qty = (int) inventory.get("P001");  // cast required - no guarantee

POST-GENERICS (Java 5+):

  List<String> orders = new ArrayList<>();
  orders.add("ORD-001");
  // orders.add(42);  <- COMPILE ERROR - caught immediately

  String id = orders.get(0);              // no cast - compiler knows it is String

  Map<String, Integer> inventory = new HashMap<>();
  inventory.put("P001", 120);
  int qty = inventory.get("P001");        // Integer auto-unboxed - no cast

THE STRUCTURAL SHIFT:
  Type mismatches moved from RUNTIME ClassCastException
  to COMPILE-TIME error.
  The cast is still in the bytecode - but the compiler verified it is correct.

Basic Overview - How Generics Appear Across the Collections API

1. TYPE PARAMETERS ON EVERY COLLECTION INTERFACE

   Fresher view  : every collection declares a type parameter.
                   List<E>, Set<E>, Queue<E>, Map<K,V>, Iterator<E>.
                   When you write List<String>, E = String everywhere:
                   add(String), get() returns String, no casts anywhere.

   Deeper view   : the compiler tracks E at every usage site.
                   List<String>.get() compiles to Object in bytecode
                   (type erasure) with an automatic CHECKCAST instruction
                   that the compiler proves will never fail.
                   Two parameterizations List<String> and List<Integer>
                   share the same ArrayList.class at runtime.

2. WILDCARDS IN COLLECTION UTILITY METHODS

   Fresher view  : Collections.sort(), Collections.copy(), addAll()
                   accept wildcards so one method works with
                   List<Integer>, List<Double>, List<String> - not
                   just one exact parameterized type.

   Deeper view   : Collections.copy(List<? super T> dest,
                   List<? extends T> src) uses full PECS.
                   src is the producer (? extends - read from it).
                   dest is the consumer (? super - write into it).
                   This lets dest be List<Number> when src is
                   List<Integer> - Integer IS-A Number.

3. BOUNDED TYPES IN SORTED COLLECTIONS

   Fresher view  : TreeSet and TreeMap keep elements in order.
                   Ordering requires comparison - so elements must
                   implement Comparable, or a Comparator must be
                   provided. The generic API enforces this choice.

   Deeper view   : TreeSet<E>'s no-arg constructor requires E to
                   implement Comparable<? super E> at runtime -
                   violation throws ClassCastException on first insert.
                   The Comparator overload TreeSet(Comparator<? super E>)
                   accepts any comparator for E or its supertypes,
                   applying the PECS consumer-super pattern.

4. Iterator<E> AND THE FOR-EACH LOOP

   Fresher view  : the for-each loop works because collections
                   implement Iterable<E>, whose iterator() method
                   returns Iterator<E>. That Iterator's next()
                   returns E - the exact element type, no cast.

   Deeper view   : the compiler translates for (String s : list)
                   into a while loop using list.iterator() and
                   it.next(). The CHECKCAST the compiler inserts
                   for the E return type is verified correct at
                   compile time. No cast is ever written in
                   application code, but the type safety guarantee
                   holds end to end.

List - Type-Safe Element Sequences

List<E> is the foundation of most collection usage. Its type parameter E propagates through every method: add(E), get(int) returning E, set(int, E), indexOf(Object), contains(Object), iterator() returning Iterator<E>. The two Object-based methods (contains, indexOf) remain Object for backward compatibility — they always return false or -1 rather than throwing when passed the wrong type.

// File: ListGenericsDemo.java import java.util.*; import java.util.function.*; public class ListGenericsDemo { // Generic method with bounded param - sorts any Comparable list static <T extends Comparable<T>> List<T> sortedCopy(List<T> source) { List<T> copy = new ArrayList<>(source); Collections.sort(copy); return copy; } // PECS in a custom utility - reads from producer, writes to consumer static <T> void copyIf(List<? extends T> source, List<? super T> dest, Predicate<? super T> predicate) { for (T item : source) { // source PRODUCES T (? extends) if (predicate.test(item)) { dest.add(item); // dest CONSUMES T (? super) } } } public static void main(String[] args) { List<String> products = new ArrayList<>( List.of("USB Hub", "Wireless Mouse", "Keyboard", "Laptop Stand", "Webcam")); System.out.println("=== sortedCopy - bounded <T extends Comparable<T>> ==="); List<String> sorted = sortedCopy(products); System.out.println("Original : " + products); System.out.println("Sorted : " + sorted); System.out.println(); System.out.println("=== sortedCopy - same method with List<Integer> ==="); List<Integer> prices = new ArrayList<>(List.of(1299, 499, 2499, 799, 3499)); List<Integer> sortedPrices = sortedCopy(prices); System.out.println("Sorted prices: " + sortedPrices); System.out.println(); System.out.println("=== copyIf - PECS: ? extends source, ? super dest ==="); List<Object> destObj = new ArrayList<>(); // List<Object> satisfies ? super String copyIf(products, destObj, name -> name.length() > 7); System.out.println("Names longer than 7 chars (into List<Object>): " + destObj); System.out.println(); System.out.println("=== for-each uses Iterator<E> - no cast in sight ==="); int total = 0; for (int price : prices) { // Integer unboxed to int - no explicit cast total += price; } System.out.println("Total: Rs." + total); System.out.println(); System.out.println("=== Collections.addAll uses ? super T internally ==="); List<Number> numList = new ArrayList<>(); Collections.addAll(numList, 10, 20, 30); // Integer added - Number satisfies ? super Integer System.out.println("numList: " + numList); } }
Output:
=== sortedCopy - bounded <T extends Comparable<T>> ===
Original : [USB Hub, Wireless Mouse, Keyboard, Laptop Stand, Webcam]
Sorted   : [Keyboard, Laptop Stand, USB Hub, Webcam, Wireless Mouse]

=== sortedCopy - same method with List<Integer> ===
Sorted prices: [499, 799, 1299, 2499, 3499]

=== copyIf - PECS: ? extends source, ? super dest ===
Names longer than 7 chars (into List<Object>): [Wireless Mouse, Keyboard, Laptop Stand, Webcam]

=== for-each uses Iterator<E> - no cast in sight ===
Total: Rs.8495

=== Collections.addAll uses ? super T internally ===
numList: [10, 20, 30]

Map<K,V> - Two Independent Type Parameters

Map<K,V> is the canonical two-type-parameter design. K and V are completely independent placeholders — a Map<String, Integer> uses String for keys and Integer for values, and neither constrains the other. The iteration entry type Map.Entry<K,V> inherits both parameters, giving both getKey() returning K and getValue() returning V fully typed at the call site.

// File: MapGenericsDemo.java import java.util.*; import java.util.function.*; public class MapGenericsDemo { // Inverts Map<K,V> to Map<V,K> - uses Map.Entry<K,V> to iterate static <K, V> Map<V, K> invert(Map<K, V> source) { Map<V, K> result = new LinkedHashMap<>(); for (Map.Entry<K, V> entry : source.entrySet()) { K key = entry.getKey(); // K - typed, no cast V value = entry.getValue(); // V - typed, no cast result.put(value, key); } return result; } // Groups items by a key function - List<T> -> Map<K, List<T>> static <T, K> Map<K, List<T>> groupBy(List<T> items, Function<T, K> keyFn) { Map<K, List<T>> groups = new LinkedHashMap<>(); for (T item : items) { groups.computeIfAbsent(keyFn.apply(item), k -> new ArrayList<>()).add(item); } return groups; } // Merges two maps using ? extends K and ? extends V - accepts any subtype maps static <K, V> Map<K, V> merge(Map<? extends K, ? extends V> first, Map<? extends K, ? extends V> second) { Map<K, V> result = new LinkedHashMap<>(first); result.putAll(second); // putAll accepts Map<? extends K, ? extends V> return result; } public static void main(String[] args) { Map<String, Integer> stockLevels = new LinkedHashMap<>(); stockLevels.put("P001-Mouse", 120); stockLevels.put("P002-Stand", 45); stockLevels.put("P003-USBHub", 200); stockLevels.put("P004-Keyboard", 28); System.out.println("=== Map.Entry<K,V> - both key and value fully typed ==="); for (Map.Entry<String, Integer> entry : stockLevels.entrySet()) { String id = entry.getKey(); // String - no cast Integer qty = entry.getValue(); // Integer - no cast String status = qty < 50 ? "LOW" : "OK"; System.out.printf(" %-22s qty=%-4d [%s]%n", id, qty, status); } System.out.println(); System.out.println("=== invert - Map<String,Integer> to Map<Integer,String> ==="); Map<Integer, String> inverted = invert(stockLevels); inverted.forEach((qty, id) -> System.out.printf(" qty=%-4d -> %s%n", qty, id)); System.out.println(); System.out.println("=== groupBy - group product IDs by stock status ==="); List<String> ids = List.of("P001-Mouse", "P002-Stand", "P003-USBHub", "P004-Keyboard"); Map<String, List<String>> byStatus = groupBy( ids, id -> stockLevels.getOrDefault(id, 0) >= 50 ? "IN_STOCK" : "LOW_STOCK"); byStatus.forEach((status, list) -> System.out.println(" " + status + ": " + list)); System.out.println(); System.out.println("=== merge two maps with ? extends K, ? extends V ==="); Map<String, Integer> warehouseA = Map.of("P001-Mouse", 60, "P003-USBHub", 100); Map<String, Integer> warehouseB = Map.of("P002-Stand", 30, "P004-Keyboard", 15); Map<String, Integer> combined = merge(warehouseA, warehouseB); System.out.println(" Combined: " + combined); } }
Output:
=== Map.Entry<K,V> - both key and value fully typed ===
  P001-Mouse             qty=120  [OK]
  P002-Stand             qty=45   [LOW]
  P003-USBHub            qty=200  [OK]
  P004-Keyboard          qty=28   [LOW]

=== invert - Map<String,Integer> to Map<Integer,String> ===
  qty=120  -> P001-Mouse
  qty=45   -> P002-Stand
  qty=200  -> P003-USBHub
  qty=28   -> P004-Keyboard

=== groupBy - group product IDs by stock status ===
  IN_STOCK: [P001-Mouse, P003-USBHub]
  LOW_STOCK: [P002-Stand, P004-Keyboard]

=== merge two maps with ? extends K, ? extends V ===
  Combined: {P001-Mouse=60, P003-USBHub=100, P002-Stand=30, P004-Keyboard=15}

Set and Sorted Collections

Set<E> uses one type parameter, but sorted sets — TreeSet<E> and SortedSet<E> — surface a concrete generic contract: either E must implement Comparable<? super E>, or a Comparator<? super E> must be supplied. The wildcard on the comparator is the PECS consumer pattern: the comparator consumes E values, so ? super E allows a Comparator<Object> to sort a Set<Product>.

// File: SetGenericsDemo.java import java.util.*; public class SetGenericsDemo { record Product(String id, String name, double price) {} public static void main(String[] args) { System.out.println("=== HashSet<String> - type-safe, no duplicates ==="); Set<String> categories = new HashSet<>(); categories.add("Electronics"); categories.add("Accessories"); categories.add("Electronics"); // silently deduplicated categories.add("Furniture"); System.out.println("Categories : " + categories); System.out.println("Has Electronics: " + categories.contains("Electronics")); System.out.println(); System.out.println("=== TreeSet<String> - natural order via Comparable<String> ==="); TreeSet<String> sorted = new TreeSet<>(categories); System.out.println("Sorted : " + sorted); System.out.println("First : " + sorted.first()); System.out.println("Last : " + sorted.last()); System.out.println("headSet(<F) : " + sorted.headSet("F")); System.out.println(); System.out.println("=== TreeSet with Comparator<? super Product> ==="); // Product does NOT implement Comparable - Comparator is required // Comparator<Product> satisfies Comparator<? super Product> Comparator<Product> byPrice = Comparator.comparingDouble(Product::price); TreeSet<Product> byPriceSet = new TreeSet<>(byPrice); byPriceSet.add(new Product("P001", "Wireless Mouse", 799.0)); byPriceSet.add(new Product("P002", "Laptop Stand", 1299.0)); byPriceSet.add(new Product("P003", "USB Hub", 499.0)); byPriceSet.add(new Product("P004", "Keyboard", 2499.0)); System.out.println("Products sorted by price:"); byPriceSet.forEach(p -> System.out.printf(" %-20s Rs.%.2f%n", p.name(), p.price())); System.out.println(); System.out.println("=== Set operations - addAll / retainAll use ? extends E / ? ==="); Set<Integer> setA = new HashSet<>(Set.of(499, 799, 1299, 2499)); Set<Integer> setB = new HashSet<>(Set.of(799, 1299, 3499)); Set<Integer> union = new HashSet<>(setA); union.addAll(setB); // addAll accepts Collection<? extends Integer> System.out.println("Union : " + new TreeSet<>(union)); Set<Integer> intersection = new HashSet<>(setA); intersection.retainAll(setB); // retainAll accepts Collection<?> System.out.println("Intersection: " + new TreeSet<>(intersection)); } }
Output:
=== HashSet<String> - type-safe, no duplicates ===
Categories  : [Accessories, Electronics, Furniture]
Has Electronics: true

=== TreeSet<String> - natural order via Comparable<String> ===
Sorted      : [Accessories, Electronics, Furniture]
First       : Accessories
Last        : Furniture
headSet(<F) : [Accessories, Electronics]

=== TreeSet with Comparator<? super Product> ===
Products sorted by price:
  USB Hub              Rs.499.00
  Wireless Mouse       Rs.799.00
  Laptop Stand         Rs.1299.00
  Keyboard             Rs.2499.00

=== Set operations - addAll / retainAll use ? extends E / ? ===
Union       : [499, 799, 1299, 2499, 3499]
Intersection: [799, 1299]

Collections Utility Methods and PECS

java.util.Collections is a non-generic class containing exclusively generic static methods. Its signatures are the most instructive PECS examples in the JDK. Reading each signature tells you exactly how the method uses its parameters.

// File: CollectionsUtilityDemo.java import java.util.*; public class CollectionsUtilityDemo { public static void main(String[] args) { System.out.println("=== Collections.sort with Comparator<? super T> ==="); // sort(List<T> list, Comparator<? super T> c) // Comparator<Object> satisfies Comparator<? super String> List<String> items = new ArrayList<>( List.of("Webcam", "Keyboard", "Hub", "Mouse", "Monitor")); Collections.sort(items); // natural order System.out.println("Natural : " + items); Collections.sort(items, Comparator.comparingInt(String::length)); System.out.println("By length: " + items); System.out.println(); System.out.println("=== Collections.copy - PECS in one signature ==="); // copy(List<? super T> dest, List<? extends T> src) // src PRODUCES T values -> ? extends T (producer) // dest CONSUMES T values -> ? super T (consumer) List<Integer> source = List.of(499, 799, 1299, 2499); List<Number> dest = new ArrayList<>(Arrays.asList(0, 0, 0, 0)); Collections.copy(dest, source); // List<Number> satisfies ? super Integer (Number IS a supertype of Integer) System.out.println("Copied into List<Number>: " + dest); System.out.println(); System.out.println("=== Collections.min / max - Comparable or Comparator<? super T> ==="); List<Integer> scores = List.of(72, 95, 88, 64, 91, 79); System.out.println("Min: " + Collections.min(scores)); System.out.println("Max: " + Collections.max(scores)); System.out.println(); System.out.println("=== Collections.frequency - Object parameter for backwards compat ==="); List<String> tags = List.of("sale", "new", "sale", "trending", "new", "sale"); System.out.println("'sale' count : " + Collections.frequency(tags, "sale")); System.out.println("'trending' count: " + Collections.frequency(tags, "trending")); System.out.println(); System.out.println("=== Collections.unmodifiableList - preserves type parameter ==="); List<String> mutable = new ArrayList<>(List.of("Alpha", "Beta", "Gamma")); List<String> immutable = Collections.unmodifiableList(mutable); String first = immutable.get(0); // String - type parameter preserved System.out.println("get(0) as String: " + first.toUpperCase()); try { immutable.add("Delta"); } catch (UnsupportedOperationException e) { System.out.println("add() blocked: UnsupportedOperationException"); } } }
Output:
=== Collections.sort with Comparator<? super T> ===
Natural : [Hub, Keyboard, Monitor, Mouse, Webcam]
By length: [Hub, Mouse, Webcam, Monitor, Keyboard]

=== Collections.copy - PECS in one signature ===
Copied into List<Number>: [499, 799, 1299, 2499]

=== Collections.min / max - Comparable or Comparator<? super T> ===
Min: 64
Max: 95

=== Collections.frequency - Object parameter for backwards compat ===
'sale' count   : 3
'trending' count: 1

=== Collections.unmodifiableList - preserves type parameter ===
get(0) as String: ALPHA
add() blocked: UnsupportedOperationException

Iterator and Iterable

The for-each loop is built on two generic interfaces. Iterable<T> declares Iterator<T> iterator(). Iterator<T> declares T next(), boolean hasNext(), and void remove(). When the compiler encounters for (String s : list), it generates a while loop using list.iterator() typed as Iterator<String> — next() returns String directly, with no explicit cast anywhere in application code.

// File: IteratorGenericsDemo.java import java.util.*; import java.util.function.*; public class IteratorGenericsDemo { // Custom generic Iterable - implements Iterable<T> for any type T static class FilteredIterable<T> implements Iterable<T> { private final List<T> source; private final Predicate<T> filter; FilteredIterable(List<T> source, Predicate<T> filter) { this.source = List.copyOf(source); this.filter = filter; } @Override public Iterator<T> iterator() { return new Iterator<T>() { private int index = 0; private T pending; private boolean ready = false; private boolean advance() { while (index < source.size()) { T candidate = source.get(index++); if (filter.test(candidate)) { pending = candidate; ready = true; return true; } } ready = false; return false; } @Override public boolean hasNext() { return ready || advance(); } @Override public T next() { if (!ready && !advance()) throw new NoSuchElementException(); ready = false; return pending; // returns T - fully typed } }; } } public static void main(String[] args) { System.out.println("=== Manual Iterator<String> - next() returns String ==="); List<String> cities = List.of("Mumbai", "Pune", "Nashik", "Nagpur", "Aurangabad"); Iterator<String> it = cities.iterator(); while (it.hasNext()) { String city = it.next(); // String - no cast System.out.println(" " + city.toUpperCase()); } System.out.println(); System.out.println("=== for-each compiles to the same Iterator<String> loop ==="); for (String city : cities) { // identical bytecode to above System.out.println(" " + city.length() + " chars"); } System.out.println(); System.out.println("=== Custom FilteredIterable<Integer> - only prices > 1000 ==="); List<Integer> allPrices = List.of(499, 1299, 799, 2499, 399, 1799, 649); FilteredIterable<Integer> expensive = new FilteredIterable<>(allPrices, price -> price > 1000); System.out.print("Expensive: "); for (Integer price : expensive) { // for-each uses our generic Iterator<Integer> System.out.print("Rs." + price + " "); } System.out.println(); System.out.println(); System.out.println("=== Safe removal via Iterator.remove() ==="); List<Integer> mutablePrices = new ArrayList<>( List.of(499, 1299, 799, 2499, 399, 1799)); Iterator<Integer> remover = mutablePrices.iterator(); while (remover.hasNext()) { Integer price = remover.next(); // Integer - typed by Iterator<Integer> if (price < 1000) remover.remove(); // safe in-loop removal } System.out.println("Remaining (>= Rs.1000): " + mutablePrices); } }
Output:
=== Manual Iterator<String> - next() returns String ===
  MUMBAI
  PUNE
  NASHIK
  NAGPUR
  AURANGABAD

=== for-each compiles to the same Iterator<String> loop ===
  6 chars
  4 chars
  6 chars
  6 chars
  9 chars

=== Custom FilteredIterable<Integer> - only prices > 1000 ===
Expensive: Rs.1299 Rs.2499 Rs.1799

=== Safe removal via Iterator.remove() ===
  Remaining (>= Rs.1000): [1299, 2499, 1799]

Basic Example

import java.util.*; public class Main { static double sum(List<? extends Number> numbers) { double total = 0; for (Number n : numbers) { total += n.doubleValue(); } return total; } public static void main(String[] args) { List<String> fruits = new ArrayList<>(); fruits.add("Apple"); fruits.add("Banana"); // fruits.add(42); // would not compile - List<String> only accepts String Map<String, Integer> prices = new HashMap<>(); prices.put("Apple", 40); prices.put("Banana", 20); System.out.println("Fruits: " + fruits); System.out.println("Apple price: " + prices.get("Apple")); List<Integer> intList = List.of(10, 20, 30); System.out.println("Sum of intList: " + sum(intList)); } }

Output

Fruits: [Apple, Banana]
Apple price: 40
Sum of intList: 60.0
  1. ›List<String> and Map<String, Integer> carry their element types as part of the declaration, so the compiler rejects a wrong-type insertion at compile time instead of failing later with a ClassCastException.
  2. ›prices.get("Apple") returns an Integer directly — no cast needed, because the compiler already knows the map's value type.
  3. ›sum(List<? extends Number> numbers) uses a wildcard so the same method accepts a List<Integer>, a List<Double>, or any other numeric list, not just one exact type.
  4. ›Inside sum, each element is read as a Number and widened with doubleValue(), which is exactly what the ? extends Number bound guarantees is safe to do.

Generic Concepts in Collections - Quick Reference

ConceptWhere It AppearsExample
Type parameter <E>Every collection interface and classList<E>, Set<E>, Queue<E>
Two type parameters <K,V>Map typesMap<K,V>, Map.Entry<K,V>
Bounded <T extends Comparable<T>>Generic sort/min/max methodsCollections.sort(List<T>)
Unbounded wildcard ?Read-only utilitiesCollection<?> parameter in frequency()
Upper wildcard ? extends TProducer parameters (read from)addAll(Collection<? extends E>)
Lower wildcard ? super TConsumer parameters (write into)copy(List<? super T> dest, ...)
Both wildcards — PECSCollections.copy()copy(List<? super T>, List<? extends T>)
Comparator<? super E>Sorted collections and sort methodsTreeSet(Comparator<? super E>), sort(Comparator<? super T>)
Generic Iterator<E>For-each loop compilationfor (String s : list)
Diamond <> inferenceEvery instantiationnew ArrayList<>(), new HashMap<>()

Best Practices

Always declare collection variables with full type parameters — never raw types. List<String> over List. Map<String, Integer> over Map. Raw types opt out of all compile-time type verification and produce unchecked warnings throughout. Every unchecked warning is a potential runtime ClassCastException that the compiler cannot protect against.

Use the most abstract collection type in method parameters. A method that only iterates should accept Iterable<? extends T> — it works with List, Set, Queue, and any custom iterable. A method that only reads should accept Collection<? extends T>. A method that needs indexed access needs List<T>. The more abstract the parameter, the more call sites the method serves.

Apply PECS to every collection parameter in utility or framework code. For parameters your method reads from, use ? extends T. For parameters your method writes into, use ? super T. For parameters that do both, use a named type parameter <T>. Apply PECS consistently and utility methods become genuinely reusable across the entire type hierarchy they serve.

Return List.copyOf() or Collections.unmodifiableList() from methods that expose internal collections. Both preserve the full generic type — the caller receives List<String>, not List<?> or List<Object>. List.copyOf() is independent of the original; Collections.unmodifiableList() is a live view. Both prevent callers from modifying the internal state through the returned reference.

Common Mistakes

Mistake 1 - Using Raw Collection Types in New Code

import java.util.ArrayList; import java.util.List; // WRONG - raw List stores Object, retrieval requires cast, // wrong-type insertions produce unchecked warnings and ClassCastExceptions List products = new ArrayList(); products.add("Laptop"); products.add(1299); // silently accepted - different type String first = (String) products.get(1); // ClassCastException at runtime // CORRECT - parameterized type enforces the contract at compile time List<String> productNames = new ArrayList<>(); productNames.add("Laptop"); // productNames.add(1299); // COMPILE ERROR - caught immediately String firstSafe = productNames.get(0); // no cast - compiler verified

Mistake 2 - Passing List Where List Is Expected
import java.util.List; // WRONG - List<Object> only accepts an actual List<Object> due to invariance. // In a well-typed codebase, List<Object> is almost never created. // This method is unusable with List<String>, List<Integer>, etc. static void logAllBroken(List<Object> items) { items.forEach(System.out::println); } List<String> names = List.of("Ananya", "Priya"); // logAllBroken(names); // COMPILE ERROR // CORRECT - wildcard makes the method accept any parameterized List static void logAll(List<?> items) { items.forEach(System.out::println); } // logAll(names); // works - ? accepts List<String>

Mistake 3 - Modifying a Collection During for-each Iteration

import java.util.ArrayList; import java.util.Iterator; import java.util.List; // WRONG - structural modification during for-each (which uses Iterator internally) // throws ConcurrentModificationException static void removeLowStockBroken(List<Integer> quantities) { for (Integer qty : quantities) { if (qty < 50) quantities.remove(qty); // ConcurrentModificationException } } // CORRECT - use Iterator<T>.remove() which is safe during iteration static void removeLowStock(List<Integer> quantities) { Iterator<Integer> it = quantities.iterator(); while (it.hasNext()) { Integer qty = it.next(); // Integer - typed by Iterator<Integer> if (qty < 50) it.remove(); // safe in-loop removal via iterator } }

Mistake 4 - Declaring ? extends T When ? super T Is Needed (Wrong PECS Side)

import java.util.List; // WRONG - ? extends T is for reading (producer side). // This method needs to WRITE integers into dest - that requires ? super T. // ? extends prevents add() - the method body cannot compile. static void fillBroken(List<? extends Integer> dest, int value) { // dest.add(value); // COMPILE ERROR - cannot add to ? extends } // CORRECT - ? super T is for writing (consumer side) // dest can be List<Integer>, List<Number>, or List<Object> static void fill(List<? super Integer> dest, int value, int times) { for (int i = 0; i < times; i++) { dest.add(value); // Integer into ? super Integer - safe } }

Interview Questions

Q1. How do generics improve the Collections Framework compared to pre-Java-5 code?

Before Java 5, all collections stored Object references — add() accepted anything, get() returned Object requiring a downcast, and type mismatches caused ClassCastException at runtime often far from the actual mistake. Generics parameterize every collection — List<E>, Map<K,V>, Set<E> — making the element type part of the compile-time contract. The compiler verifies every insertion and retrieval, rejects type mismatches as errors before the code can run, and inserts automatic casts in bytecode that it has already verified are correct. The practical shift is from runtime bugs to compile-time errors.

Q2. Why can't a List be passed where a List is expected?

Generic types are invariant in Java — List<String> is not a subtype of List<Object> even though String is a subtype of Object. The invariance rule exists for type safety: if List<String> were a List<Object>, a method holding a List<Object> reference could call add(42) on what is actually a List<String>, silently corrupting it. The solution is the unbounded wildcard: List<?> accepts any parameterized list for read-only structural operations, and List<? extends String> accepts any subtype list for typed reading.

Q3. What does Collections.copy(List<? super T> dest, List<? extends T> src) demonstrate?

This signature demonstrates PECS — Producer Extends, Consumer Super — in the most complete form in the JDK. src is a producer: the method reads T values from it, so it uses ? extends T. dest is a consumer: the method writes T values into it, so it uses ? super T. The ? super T on dest allows a List<Number> to receive Integer values when T = Integer, because Number is a supertype of Integer. A simpler declaration copy(List<T>, List<T>) would force both lists to be exactly the same parameterized type, rejecting the List<Number> destination.

Q4. How does TreeSet use generics differently from HashSet?

Both TreeSet<E> and HashSet<E> use a single element type parameter E. The difference is the ordering contract. HashSet uses hashCode() and equals() from Object and requires nothing special of E. TreeSet maintains sorted order, which requires either that E implements Comparable<? super E> (natural ordering — a violation throws ClassCastException at the first insertion) or that a Comparator<? super E> is provided at construction. The Comparator<? super E> in TreeSet's constructor is the PECS consumer pattern — the comparator consumes E values for comparison, so ? super E allows general-purpose comparators to be passed.

Q5. How does the for-each loop connect to Java generics?

for (String s : list) compiles to a while loop using list.iterator(). Because List<String> implements Iterable<String>, iterator() returns Iterator<String>, whose next() method returns String directly. The compiler inserts a CHECKCAST to String in the bytecode — verified correct because the list was typed — and no explicit cast is written in application code. Before generics, using an iterator required (String) it.next() at every call site, with no compile-time guarantee the cast would succeed.

Q6. What is the correct way to remove elements from a List during iteration?

The for-each loop internally uses an Iterator. Calling the list's own remove() method inside a for-each loop structurally modifies the list while the iterator is active, which triggers ConcurrentModificationException. The correct approach is to obtain the iterator explicitly — Iterator<T> it = list.iterator() — and call it.remove() rather than list.remove(). The Iterator.remove() method is designed for safe in-loop removal: it removes the element last returned by next() and updates the iterator's internal state so iteration continues correctly. The element type T from Iterator<T> ensures it.next() returns a typed value without a cast.

FAQs

Why do contains() and remove() on List accept Object instead of E?

For backward compatibility with pre-Java-5 code. Before generics, contains(Object) was the only option. Changing it to contains(E) would break existing code that passed non-E arguments — which always return false rather than failing. The designers preserved the Object signature in these read-only query methods. This means list.contains(42) on a List<String> compiles and returns false rather than being rejected, which is occasionally surprising but rarely harmful.

Is there a performance difference between generic and raw collections?

No. After type erasure, the compiled bytecode for List<String> and a raw List is essentially identical — both use Object internally. The automatic CHECKCAST instructions the compiler inserts at typed read sites are the same bytecode an explicit cast would produce. The JIT applies the same optimizations to both. The one performance concern with generic collections is boxing: List<Integer> boxes int values to Integer objects. That cost comes from boxing, not from generics.

Can you have a Map whose keys are generic?

Yes. Map<List<String>, Integer> uses a List<String> as the key type. The key type must correctly implement hashCode() and equals() for HashMap (or Comparable for TreeMap). List does implement both — hashCode() is based on element hash codes, equals() compares element by element. Using mutable objects as HashMap keys is risky regardless of generics: if the key's state changes after insertion, get() will fail to find it.

What is the difference between List.copyOf() and Collections.unmodifiableList()?

Both return an unmodifiable List<E> that preserves the type parameter. The difference is independence: List.copyOf(source) creates a completely independent copy — subsequent changes to source do not affect the copy. Collections.unmodifiableList(source) returns a view — changes to source are visible through the view. For encapsulation, List.copyOf() is usually preferred when returning an internal collection from a method; the caller cannot observe internal state changes through their copy.

Why does addAll() use Collection<? extends E> instead of Collection?

addAll(Collection<? extends E> c) uses the PECS producer pattern: the argument collection produces E values that addAll() reads and inserts. The ? extends E allows passing a Collection<String> to a List<CharSequence>.addAll() call — because String is a subtype of CharSequence. Without the wildcard, addAll() on a List<CharSequence> would reject any Collection<String> due to generic invariance, making the method far less useful for real-world collection manipulation.

Can you create a generic array, like new ArrayList[10]?

No — generic array creation is a compile error. Arrays are covariant and reifiable at runtime, while generic types are invariant and erased. Mixing them creates potential heap pollution: an ArrayList<String>[] reference could point to ArrayList<Integer> elements through array covariance, circumventing the type system. The standard workaround is new ArrayList[10] (raw array, suppressed unchecked warning) or, preferably, using a List<List<String>> instead of an array of lists. This limitation is a consequence of type erasure and is explained in the Type Erasure article in this series.

Summary

Generics are not a layer added on top of the Collections Framework — they are structurally woven through every interface, every class, every utility method. List<E> makes insertions and retrievals type-safe. Map<K,V> gives both the key and value their own independent type. Set<E> plus TreeSet's Comparator<? super E> demonstrates PECS at the constructor level. Collections.copy(List<? super T> dest, List<? extends T> src) is the textbook PECS example with both sides in one signature. Iterator<E> powers the for-each loop so elements come out typed without a cast.

Every pattern from the Generics series has a home in collections: bounded type parameters in sort and min and max; unbounded wildcards in frequency and contains; upper-bounded wildcards in addAll and copyOf; lower-bounded wildcards in copy destination and Comparator parameters; generic iterators behind every for-each loop.

Writing List<Product> instead of raw List is not ceremonial. It is the decision that moves an entire class of bugs from production ClassCastException to compile-time error — caught in seconds rather than discovered in hours.

What to Read Next

Practice Programs

Put this topic into practice — each program has full code, output and a step-by-step explanation you can run online.

Browse all Java programs →