Generics, lambdas and the streams API
Generic types and bounded parameters, functional interfaces, Optional, and stream pipelines with collectors.
Generics and bounds
public class Box<T> { // a type parameter
private final T value;
public Box(T value) { this.value = value; }
public T get() { return value; }
}
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;
}
// PECS: Producer Extends, Consumer Super
static void copy(List<? extends Number> from, List<? super Number> to) {
to.addAll(from);
}
List<Object> sink = new ArrayList<>();
copy(List.of(1, 2, 3), sink);- Generics are erased at run time:
List<String>andList<Integer>are the same class, so you cannot test for the type parameter or createnew T[]. ? extends Tis a read-only view — you can readTout of it, never put one in.? super Tis a write-only view — you can addT, but reading gives youObject.- A raw type such as
Listwithout a parameter erases every check on that variable; treat it as a warning you must fix. - A bounded parameter (
T extends Comparable<T>) tells the compiler which methods are available inside the method body.
Lambdas, method references and Optional
@FunctionalInterface
interface Validator<T> { boolean test(T value); }
Validator<String> notBlank = s -> !s.isBlank();
Validator<String> shortEnough = notBlank.and(s -> s.length() <= 40);
List<String> cleaned = names.stream()
.map(String::trim) // unbound instance method reference
.filter(notBlank)
.toList();
Optional<String> first = names.stream().findFirst();
String shown = first.map(String::toUpperCase).orElse("none");
String required = first.orElseThrow(() -> new IllegalStateException("empty"));| Functional interface | Shape | Typical use |
|---|---|---|
Function<T,R> | T to R | map |
BiFunction<T,U,R> | T, U to R | Combining two values |
Predicate<T> | T to boolean | filter |
Consumer<T> | T to nothing | forEach, logging |
Supplier<T> | nothing to T | Lazy default, factories |
UnaryOperator<T> | T to T | In-place transformation |
Runnable | nothing to nothing | Threads and executors |
Optionalis a return type. Do not use it as a field type, a parameter type or an element type in a collection.- Never call
get()without proving the value is present;orElseThrowstates the intent far better. - A lambda can capture only effectively final locals, because the captured value is copied at creation time.
- Method references are shorter and usually clearer, but they are not faster than the equivalent lambda.
- Annotate your own functional interfaces with
@FunctionalInterfaceso the compiler checks the single-abstract-method rule.
Stream pipelines and collectors
record Order(String sku, int qty, BigDecimal price) {}
Map<String, Integer> qtyBySku = orders.stream()
.collect(Collectors.groupingBy(Order::sku, Collectors.summingInt(Order::qty)));
BigDecimal total = orders.stream()
.map(o -> o.price().multiply(BigDecimal.valueOf(o.qty())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
Map<Boolean, List<Order>> bulkAndSmall = orders.stream()
.collect(Collectors.partitioningBy(o -> o.qty() > 10));
Map<String, Integer> merged = orders.stream()
.collect(Collectors.toMap(Order::sku, Order::qty, Integer::sum));- A stream is consumed once; storing a stream and reusing it throws
IllegalStateException. - Nothing happens until a terminal operation such as
collect,reduceorforEachruns — intermediate operations are lazy. - The merge function in
toMapis not optional in practice: without it, a duplicate key throws. parallel()helps only for CPU-bound work on genuinely large collections; it never helps for I/O, which is already concurrent.- Keep side effects out of intermediate operations. A
peekthat mutates a list makes the pipeline order-dependent and hard to debug.
⚠️
Parallel streams run on the shared common ForkJoinPool, the same one used by other parallel work in the JVM. One blocking or slow task there stalls everything else, and the pool size is the CPU count — not your concurrency requirement.
FAQ
When is a plain loop better than a stream?
When the logic branches, throws often, or needs index arithmetic. Streams win for filter-map-collect pipelines with no side effects; a loop wins when the control flow is the point.
Why does my stream compile but throw at run time?
Usual suspects are a reused stream, a duplicate key in
toMap without a merge function, and an orElse that evaluates an expensive expression eagerly. Read the exception name — each case has a distinct one.Related
Control flow, methods and the modern main Dates, text and numbers
Last refreshed 2026-09-18.