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> and List<Integer> are the same class, so you cannot test for the type parameter or create new T[].
  • ? extends T is a read-only view — you can read T out of it, never put one in.
  • ? super T is a write-only view — you can add T, but reading gives you Object.
  • A raw type such as List without 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 interfaceShapeTypical use
Function<T,R>T to Rmap
BiFunction<T,U,R>T, U to RCombining two values
Predicate<T>T to booleanfilter
Consumer<T>T to nothingforEach, logging
Supplier<T>nothing to TLazy default, factories
UnaryOperator<T>T to TIn-place transformation
Runnablenothing to nothingThreads and executors
  • Optional is 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; orElseThrow states 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 @FunctionalInterface so 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, reduce or forEach runs — intermediate operations are lazy.
  • The merge function in toMap is 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 peek that 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.

Control flow, methods and the modern main Dates, text and numbers

Last refreshed 2026-09-18.