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
? extendsand? superusing 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
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.
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:
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 failedWildcards 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... | Declare | Can read as | Can add |
|---|---|---|---|
| Sum weights from any list of parcels | List<? extends Parcel> | Parcel | nothing except null |
| Load fragile parcels onto any compatible dock | List<? super FragileParcel> | Object | FragileParcel and subtypes |
| Only need the list's size or contents as objects | List<?> | Object | nothing except null |
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.
| Interface | Method | Use 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.
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.
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.0Notice 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
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
peekline above never prints. A pipeline with nocollect,countorforEachcompiles 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 asInteger::sum. - Mutating outside state in a lambda. Adding to an external
ArrayListfromforEachworks sequentially and corrupts data in a parallel stream. Letcollectbuild the result. - Raw types. Writing
Listinstead ofList<String>compiles with an "unchecked" warning, then fails later withClassCastExceptionfar from the real mistake. - Unwrapping an Optional blindly.
opt.get()on an empty value throwsNoSuchElementException. PreferorElse,orElseThrowormap.
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()andinstanceof List<String>are illegal. - Generics are invariant; use
? extendswhen you only read from a structure and? superwhen you only write to it. - A lambda implements a functional interface; captured locals must be effectively final, and
thisis 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.
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.