Streams and Lambdas
Definition
A lambda is a short piece of code you can pass around, like x -> x * 2. A stream is a pipeline that filters, transforms and collects data. Nothing runs until you ask for a final result.
Why it exists
Loops mix "what to compute" with "how to loop", and passing behaviour used to need bulky anonymous classes. Lambdas and streams let you say what you want in a few readable lines.
How it works
Lambdas under the hood
A lambda is an instance of a functional interface (one abstract method). It is compiled to a private synthetic method plus an invokedynamic call site; the first execution links it via LambdaMetafactory, which spins an implementing class at runtime. It is not an anonymous inner class: no extra .class file, and this inside a lambda refers to the enclosing instance.
Function<String, Integer> length = String::length; // method reference
Supplier<List<String>> factory = ArrayList::new;
BiFunction<Integer, Integer, Integer> add = Integer::sum;
Runnable r = () -> System.out.println(this); // 'this' = the enclosing object
Captured local variables must be effectively final. The rule exists because lambdas may run on other threads later, and copying a changing local would be misleading; mutate state via an object or AtomicInteger if you must (and think twice).
Core functional interfaces: Function<T,R>, Predicate<T>, Consumer<T>, Supplier<T>, UnaryOperator<T>, BiFunction, and primitive specialisations (IntFunction, ToLongFunction...) that avoid boxing.
Stream pipelines are lazy
A pipeline has a source, zero or more intermediate operations (lazy, return a stream) and one terminal operation (triggers execution). Nothing happens until the terminal operation.
List<String> names = people.stream()
.filter(p -> p.age() >= 18) // intermediate
.map(Person::name) // intermediate
.sorted() // intermediate, stateful
.limit(10) // intermediate, short-circuiting
.toList(); // terminal (Java 16+, unmodifiable)
Elements flow through the stages one at a time (vertical processing), except at stateful barriers like sorted and distinct, which must see everything first.
Stream.of("a", "b", "c")
.peek(s -> System.out.println("peek " + s))
.anyMatch(s -> s.equals("a")); // prints only "peek a": short-circuits
| Operation kind | Examples | Notes |
|---|---|---|
| Stateless intermediate | filter, map, flatMap, peek | Parallelise well |
| Stateful intermediate | sorted, distinct, limit/skip (ordered) | Buffer or coordinate |
| Short-circuit terminal | findFirst, anyMatch, allMatch | Stop early (also on infinite streams) |
| Terminal | collect, reduce, forEach, count, toList | A stream can be consumed once |
peek is for debugging only; it may be skipped (e.g. count() on a sized stream can skip the pipeline entirely in newer JDKs).
Example code
Collectors worth knowing
record Order(String customer, String status, BigDecimal total) { }
Map<String, List<Order>> byCustomer = orders.stream()
.collect(Collectors.groupingBy(Order::customer));
Map<String, BigDecimal> revenue = orders.stream()
.collect(Collectors.groupingBy(Order::customer,
Collectors.mapping(Order::total, Collectors.reducing(BigDecimal.ZERO, BigDecimal::add))));
Map<Boolean, Long> paidCount = orders.stream()
.collect(Collectors.partitioningBy(o -> o.status().equals("PAID"), Collectors.counting()));
Map<String, Order> latestPerCustomer = orders.stream()
.collect(Collectors.toMap(Order::customer, o -> o,
(a, b) -> a.total().compareTo(b.total()) >= 0 ? a : b)); // merge function required for duplicates
String csv = names.stream().collect(Collectors.joining(", ", "[", "]"));
DoubleSummaryStatistics stats = orders.stream()
.collect(Collectors.summarizingDouble(o -> o.total().doubleValue()));
toMapthrowsIllegalStateExceptionon duplicate keys unless you supply a merge function, andNullPointerExceptionon null values.groupingByreturns aHashMapby default; pass a map factory (TreeMap::new) for ordering.Collectors.teeing(Java 12) computes two results in one pass and combines them.- Custom collectors implement supplier/accumulator/combiner/finisher; for new intermediate operations Java 24 finalised Stream Gatherers (
Gatherers.windowFixed,windowSliding,scan,fold, andGathererfor custom ones).
flatMap, reduce, primitives
List<String> words = lines.stream()
.flatMap(line -> Arrays.stream(line.split("\\s+")))
.toList();
int sum = IntStream.rangeClosed(1, 100).sum(); // no boxing
int total = list.stream().mapToInt(Integer::intValue).sum();
reduce needs an associative accumulator and an identity that is neutral, otherwise parallel results differ from sequential.
System design considerations
- Never run blocking calls inside a parallel stream: it starves the common pool
- Prefer collectors that produce immutable results
- Keep lambdas side-effect free so retries and reordering are safe
Style and performance notes
- Streams are about clarity, not speed. A simple
forloop is faster in hot paths, with no allocation of lambdas, pipeline stages and boxed values. - Avoid side effects in
map/filter; keep side effects inforEachonly at the end. - Beware stream reuse:
IllegalStateException: stream has already been operated upon or closed. - Streams that wrap resources (
Files.lines,Files.walk) must be closed with try-with-resources. - Checked exceptions inside lambdas do not compose; wrap them or write a small functional interface that throws, rather than hiding them with sneaky throws.
try (Stream<String> lines = Files.lines(path)) {
long errors = lines.filter(l -> l.contains("ERROR")).count();
}
When to use it, and when not
Use it when
- You transform, filter or group collections and the pipeline stays readable
- You pass small pieces of behaviour to APIs (comparators, callbacks)
- The work is CPU-bound and large enough that parallel streams pay off (rarely)
Think twice when
- Logic needs mutation of shared state, early exits with complex conditions or checked exceptions
- The pipeline is on a hot path where boxing and allocation matter
- You would use parallelStream for blocking I/O
Parallel streams: use sparingly
parallelStream() splits the source (via Spliterator) and runs on the common ForkJoinPool, sized to cores - 1 and shared by the whole JVM.
Use it only when all hold:
- A large data set (rule of thumb: tens of thousands of elements) and meaningful CPU work per element (microseconds, not nanoseconds).
- A splittable source (
ArrayList, arrays, ranges; notLinkedList,Stream.iterate, orBufferedReader.lines). - Stateless, non-interfering, side-effect-free operations.
- No blocking I/O inside the pipeline: blocking tasks starve the common pool for everyone (including other parallel streams,
CompletableFuture.supplyAsyncwithout an executor, and unrelated libraries).
// BROKEN: shared mutable state in a parallel stream
List<Integer> out = new ArrayList<>();
IntStream.range(0, 1000).parallel().forEach(out::add); // lost updates / exceptions
// Correct: let collect() handle the combining
List<Integer> ok = IntStream.range(0, 1000).parallel().boxed().toList();
Measure with JMH before and after; parallel streams are often slower for small or I/O-bound workloads. For controlled parallelism run the stream inside your own ForkJoinPool or, better for I/O, use virtual threads or an executor (see Virtual threads).
Optional: guidelines
Optional<T> models "a result that may be absent" in return types. Do not use it for fields, method parameters, collection elements or Serializable classes.
String city = user.map(User::address)
.map(Address::city)
.filter(c -> !c.isBlank())
.orElse("unknown");
user.orElseThrow(() -> new UserNotFoundException(id)); // preferred over get()
user.ifPresentOrElse(this::greet, this::askToRegister);
orElse(x)evaluatesxeagerly; useorElseGet(() -> expensive())when it is costly.- Never call
get()withoutisPresent(); preferorElseThrow(). - Do not return
nullfrom a method that returnsOptional. - Use
OptionalInt/OptionalLongfor primitives.
Common mistakes
- Using
parallelStream()by reflex, or with blocking calls in the common pool. - Mutating external state from lambdas.
Optionalas a field or parameter; callingget()blindly.- Boxing in numeric pipelines (use
mapToIntand friends). - Re-consuming a stream; not closing I/O-backed streams.
- Overlong stream chains that are less readable than a loop.
Interview questions
1. What does "streams are lazy" mean?
Intermediate operations only build a pipeline description; work happens at the terminal operation and elements are pulled through the stages one by one, enabling short-circuiting and fusion.
2. Difference between map and flatMap?
map transforms each element to one element; flatMap transforms each element to a stream and flattens the results into a single stream.
3. When should you use a parallel stream?
Large, CPU-bound, stateless computations on splittable sources, after measuring. Avoid with blocking I/O, small data, shared mutable state, or ordered/stateful operations.
4. findFirst vs findAny?
findFirst respects encounter order; findAny may return any element and is cheaper in parallel.
5. How is a lambda different from an anonymous class?
Lambdas compile via invokedynamic, have no separate class file at compile time, and this refers to the enclosing instance; anonymous classes create a new type with their own this.
6. What does effectively final mean and why does it exist?
A local variable never reassigned after initialisation; lambdas capture values (not variables), so allowing reassignment would be ambiguous and unsafe across threads.
7. Why can toMap throw, and how do you handle it?
Duplicate keys raise IllegalStateException; supply a merge function. Null values raise NullPointerException; filter them or use groupingBy/HashMap.merge.
8. Optional.orElse vs orElseGet?
orElse evaluates its argument eagerly even when a value is present; orElseGet calls the supplier only when empty.
9. What are Stream Gatherers?
A Java 24 final API that lets you define custom intermediate stream operations (windowing, scanning, folding) via stream.gather(...), complementing collectors which only customise terminal operations.
Check yourself
1When does the code inside map() in stream.map(f).filter(g) run?
2Collectors.toMap throws IllegalStateException on some input. What is the usual cause?
3Which workload suits parallelStream()?
Key takeaways
- Pipelines are lazy and pull-based; terminal operations trigger them.
- Know
groupingBy,toMap(merge function!),partitioningBy,teeing. - Parallel streams share the common pool and need CPU-heavy, stateless, splittable work.
Optionalis for return values only.
Finished this page? Track your progress (saved only in your browser).