Skip to main content
PProfileMatch

Java Generics, Lambdas and Streams API Explained with Examples

ProgrammingLesson 1 of 46 min read

What you'll learn

  • Write a bounded generic method and explain what type erasure does to it
  • Choose between ? extends and ? super using the producer/consumer rule
  • Turn a one-method interface into a lambda or method reference and know what it can capture
  • Build stream pipelines with filter, map, groupingBy and Optional, and avoid the classic stream traps

On this page

  1. Type erasure: generics exist for the compiler, not the JVM
  2. Wildcards and the producer/consumer rule
  3. Lambdas are short implementations of one-method interfaces
  4. Streams: describe a pipeline, then trigger it
  5. Common mistakes
  6. How this shows up in real projects and interviews

Run this: two lists, one holding strings and one holding integers, and ask whether their classes are the same. The answer printed below is true, and understanding why is the key that unlocks generics, wildcards and most of the confusing compiler messages you will meet in this lesson.

Type erasure: generics exist for the compiler, not the JVM

When javac compiles List<String>, it checks every use against the type argument, inserts casts where needed, and then erases the argument. At run time there is just a List. That is how Java 5 added generics without breaking old bytecode, and it explains the limits: you cannot write new T(), new T[10] or x instanceof List<String>, and two methods that differ only in List<String> versus List<Integer> clash because they erase to the same signature.

A bound tells the compiler what a type parameter can do. In the method below, T extends Comparable<T> promises that compareTo exists, so the body needs no casts. The same method works for integers and strings because both implement Comparable of themselves.

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

public class ErasureDemo {
    // T must be comparable with itself, so compareTo needs no cast
    static <T extends Comparable<T>> T largest(List<T> items) {
        T best = items.get(0);
        for (T item : items) {
            if (item.compareTo(best) > 0) {
                best = item;
            }
        }
        return best;
    }

    public static void main(String[] args) {
        List<String> names = new ArrayList<>();
        List<Integer> weights = new ArrayList<>();
        System.out.println(names.getClass() == weights.getClass());
        System.out.println(largest(List.of(12, 47, 30)));
        System.out.println(largest(List.of("crate", "pallet", "drum")));
    }
}

Output

true
47
pallet

Wildcards and the producer/consumer rule

Generics are invariant: a List<FragileParcel> is not a List<Parcel>, even though FragileParcel extends Parcel. If it were allowed, you could add a plain Parcel through the supertype reference and corrupt the fragile-only list. The compiler stops it:

Invariance.java (does not compile)
import java.util.ArrayList;
import java.util.List;

public class Invariance {
    static class Parcel {}
    static class FragileParcel extends Parcel {}

    public static void main(String[] args) {
        List<FragileParcel> fragile = new ArrayList<>();
        List<Parcel> parcels = fragile;
        parcels.add(new Parcel());
    }
}

Output

Invariance.java:10: error: incompatible types: List<FragileParcel> cannot be converted to List<Parcel>
        List<Parcel> parcels = fragile;
                               ^
1 error
error: compilation failed

Wildcards give you flexibility back, safely. Ask what the method does with the collection. If it only reads items out, the collection is a producer and you write ? extends Parcel. If it only writes items in, it is a consumer and you write ? super FragileParcel. This is often shortened to PECS: producer extends, consumer super.

You want to...DeclareCan read asCan add
Sum weights from any list of parcelsList<? extends Parcel>Parcelnothing except null
Load fragile parcels onto any compatible dockList<? super FragileParcel>ObjectFragileParcel and subtypes
Only need the list's size or contents as objectsList<?>Objectnothing except null
Wildcards.java
import java.util.ArrayList;
import java.util.List;

public class Wildcards {
    static class Parcel {
        final double kg;
        Parcel(double kg) { this.kg = kg; }
    }

    static class FragileParcel extends Parcel {
        FragileParcel(double kg) { super(kg); }
    }

    // We only read Parcels out of the list: a producer, so "extends"
    static double totalKg(List<? extends Parcel> source) {
        double sum = 0;
        for (Parcel p : source) {
            sum += p.kg;
        }
        return sum;
    }

    // We only put FragileParcels into the list: a consumer, so "super"
    static void loadFragile(List<? super FragileParcel> dock, int count) {
        for (int i = 0; i < count; i++) {
            dock.add(new FragileParcel(2.5));
        }
    }

    public static void main(String[] args) {
        List<Parcel> mixedDock = new ArrayList<>();
        mixedDock.add(new Parcel(10));
        loadFragile(mixedDock, 2);

        List<FragileParcel> fragileDock = new ArrayList<>();
        loadFragile(fragileDock, 3);

        System.out.println(totalKg(mixedDock));
        System.out.println(totalKg(fragileDock));
    }
}

Output

15.0
7.5

Lambdas are short implementations of one-method interfaces

A functional interface has exactly one abstract method. A lambda is a compact way to supply that method, and the compiler infers which interface from context. The @FunctionalInterface annotation is optional but makes the compiler reject a second abstract method later. You rarely have to write your own: java.util.function already covers the common shapes.

InterfaceMethodUse it when you need to...
Predicate<T>boolean test(T t)answer yes or no about a value
Function<T, R>R apply(T t)convert a value into another
Supplier<T>T get()produce a value lazily
Consumer<T>void accept(T t)do something with a value
UnaryOperator<T>T apply(T t)transform within one type
BiFunction<A, B, R>R apply(A a, B b)combine two inputs

Two rules govern what a lambda body can touch. Local variables it captures must be effectively final, meaning never reassigned after initialisation. And this inside a lambda means the enclosing object, unlike an anonymous class where this is the anonymous instance. A method reference such as String::toUpperCase or Shipment::kg is shorthand when the lambda would only call one existing method.

Rules.java
import java.util.function.BiFunction;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.function.UnaryOperator;

public class Rules {
    record Shipment(String id, String city, double kg, boolean express) {}

    @FunctionalInterface
    interface Surcharge {
        double apply(Shipment s);
    }

    public static void main(String[] args) {
        Surcharge perKg = s -> s.kg() * 1.5;
        Surcharge expressFlat = s -> s.express() ? 40 : 0;

        // a lambda that builds a new lambda out of two others
        BiFunction<Surcharge, Surcharge, Surcharge> sum = (a, b) -> s -> a.apply(s) + b.apply(s);

        Shipment parcel = new Shipment("S-1", "Pune", 12, true);
        System.out.println(sum.apply(perKg, expressFlat).apply(parcel));

        double cap = 50;                                   // effectively final
        Predicate<Shipment> overCap = s -> s.kg() > cap;
        Supplier<String> label = () -> "Over cap? " + overCap.test(parcel);
        UnaryOperator<String> shout = String::toUpperCase; // method reference
        System.out.println(label.get());
        System.out.println(shout.apply(parcel.city()));
    }
}

Output

58.0
Over cap? false
PUNE

Streams: describe a pipeline, then trigger it

A stream is not a collection. It is a one-shot description of a computation over a source: zero or more intermediate operations such as filter, map and sorted, ending in one terminal operation such as toList, collect, count or min. Nothing runs until the terminal operation is called, and the source is never modified. The report below answers five questions about a cargo depot without a single hand-written loop.

DepotReport.java
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
import java.util.DoubleSummaryStatistics;
import java.util.stream.Collectors;

public class DepotReport {
    record Shipment(String id, String city, double kg, boolean express) {}

    public static void main(String[] args) {
        List<Shipment> all = List.of(
            new Shipment("S-101", "Pune", 12.5, false),
            new Shipment("S-102", "Mumbai", 48.0, true),
            new Shipment("S-103", "Pune", 7.0, true),
            new Shipment("S-104", "Nashik", 120.0, false),
            new Shipment("S-105", "Mumbai", 15.5, false),
            new Shipment("S-106", "Pune", 33.0, false));

        List<String> heavyIds = all.stream()
            .filter(s -> s.kg() > 30)
            .sorted(Comparator.comparingDouble(Shipment::kg).reversed())
            .map(Shipment::id)
            .toList();
        System.out.println("Heavy: " + heavyIds);

        Map<String, Double> kgByCity = all.stream()
            .collect(Collectors.groupingBy(Shipment::city, TreeMap::new,
                     Collectors.summingDouble(Shipment::kg)));
        System.out.println(kgByCity);

        Map<Boolean, Long> expressSplit = all.stream()
            .collect(Collectors.partitioningBy(Shipment::express, Collectors.counting()));
        System.out.println(expressSplit);

        Optional<Shipment> lightestExpress = all.stream()
            .filter(Shipment::express)
            .min(Comparator.comparingDouble(Shipment::kg));
        System.out.println(lightestExpress.map(Shipment::id).orElse("none"));

        DoubleSummaryStatistics stats = all.stream().mapToDouble(Shipment::kg).summaryStatistics();
        System.out.printf("n=%d avg=%.2f max=%.1f%n", stats.getCount(), stats.getAverage(), stats.getMax());
    }
}

Output

Heavy: [S-104, S-102, S-106]
{Mumbai=63.5, Nashik=120.0, Pune=52.5}
{false=4, true=2}
S-103
n=6 avg=39.33 max=120.0

Notice the supporting cast. Comparator.comparingDouble(...).reversed() builds an ordering from a key. groupingBy takes an optional map factory, which is how the result became a sorted TreeMap instead of an unordered HashMap. partitioningBy always returns both true and false keys. min returns an Optional because an empty stream has no minimum, and mapToDouble switches to a primitive stream so that summaryStatistics works without boxing.

Common mistakes

StreamPitfalls.java
import java.util.List;
import java.util.TreeMap;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class StreamPitfalls {
    public static void main(String[] args) {
        List<String> cities = List.of("Pune", "Mumbai", "Pune", "Nashik");

        // 1. a stream is single-use
        Stream<String> pCities = cities.stream().filter(c -> c.startsWith("P"));
        System.out.println(pCities.count());
        try {
            pCities.count();
        } catch (IllegalStateException e) {
            System.out.println("reuse: " + e.getMessage());
        }

        // 2. no terminal operation means no work at all
        cities.stream().peek(c -> System.out.println("never printed: " + c)).filter(c -> c.length() > 5);

        // 3. short-circuiting stops early
        cities.stream().peek(c -> System.out.println("visited " + c)).anyMatch(c -> c.equals("Mumbai"));

        // 4. toMap with duplicate keys throws unless you say how to merge
        try {
            cities.stream().collect(Collectors.toMap(c -> c, String::length));
        } catch (IllegalStateException e) {
            System.out.println("toMap: " + e.getMessage());
        }
        System.out.println(new TreeMap<>(cities.stream().collect(Collectors.toMap(c -> c, c -> 1, Integer::sum))));
    }
}

Output

2
reuse: stream has already been operated upon or closed
visited Pune
visited Mumbai
toMap: Duplicate key Pune (attempted merging values 4 and 4)
{Mumbai=1, Nashik=1, Pune=2}
  • Reusing a stream. The second terminal call throws IllegalStateException: stream has already been operated upon or closed. Create a new stream from the source, or collect once into a list and stream that.
  • Forgetting the terminal operation. The first peek line above never prints. A pipeline with no collect, count or forEach compiles fine and does nothing.
  • `toMap` with duplicate keys. Without a merge function you get IllegalStateException: Duplicate key Pune (attempted merging values 4 and 4). Pass a third argument such as Integer::sum.
  • Mutating outside state in a lambda. Adding to an external ArrayList from forEach works sequentially and corrupts data in a parallel stream. Let collect build the result.
  • Raw types. Writing List instead of List<String> compiles with an "unchecked" warning, then fails later with ClassCastException far from the real mistake.
  • Unwrapping an Optional blindly. opt.get() on an empty value throws NoSuchElementException. Prefer orElse, orElseThrow or map.

How this shows up in real projects and interviews

Day to day, you will read far more generics than you write: repository interfaces like Repository<T, ID>, Optional<User> return types and Map<String, List<Order>> grouping results. Writing your own bounded generic type is usually reserved for shared libraries and utilities, so being able to read <T extends Comparable<? super T>> without panic matters more than inventing it.

In interviews, expect two flavours. Short coding questions are often solved most cleanly with a groupingBy or sorted(Comparator.comparing(...)), and interviewers watch whether you know when to stop chaining. Conceptual questions go after erasure, invariance and the effectively-final rule. Pair this lesson with Java Collections for the containers streams read from, and with concurrency when you are tempted by parallel().

Try it yourself

Extend DepotReport: add a Shipment list of your own with at least eight entries, then use one stream pipeline each to (1) find the city with the greatest total weight, (2) list each city's express shipment ids as a Map<String, List<String>>, and (3) produce a comma-separated string of ids sorted by weight descending using Collectors.joining.

Show a hint

For (1), group with summingDouble and then stream the map's entrySet() with Map.Entry.comparingByValue(). For (2), nest Collectors.mapping(Shipment::id, Collectors.toList()) inside groupingBy after filtering for express.

Key takeaways

  • Generics are checked by the compiler and erased at run time, which is why new T() and instanceof List<String> are illegal.
  • Generics are invariant; use ? extends when you only read from a structure and ? super when you only write to it.
  • A lambda implements a functional interface; captured locals must be effectively final, and this is the enclosing object.
  • A stream pipeline does nothing until a terminal operation runs, can be consumed once, and should not mutate outside state.
  • Use a loop when it is clearer; streams are a readability tool, not a performance switch.

Test yourself

A trick from this lesson's ride: Count the arrows: Function is one in, one out. Supplier has no input, Consumer has no output. Four more tricks like it wait along the route, each followed by a question.

Ride this lesson

Frequently asked questions

What is type erasure in Java generics?

Type erasure means the compiler checks generic types and then removes them from the bytecode, replacing each type parameter with its bound (or Object). At run time a List<String> and a List<Integer> are the same class, which is why you cannot create a generic array or test instanceof List<String>.

What is the difference between ? extends and ? super in Java?

? extends T accepts a structure of T or any subtype and is safe for reading items as T, but not for adding. ? super T accepts T or any supertype and is safe for adding T values, but reads come back as Object. Remember it as producer extends, consumer super.

Why can't I modify a variable inside a Java lambda?

A lambda may only capture local variables that are final or effectively final, because the captured value is copied and a later change in either place would silently diverge. If you need mutable state, use a one-element array, an AtomicInteger, or better, restructure with reduce or collect.