Java Streams and Lambdas: Functional Style on the JVM
Transform collections with lambdas and the Stream API. Map, filter, reduce, collectors, parallel streams, and the pitfalls of lazy pipelines.
What you'll learn
- ✓Write lambdas and method references for functional interfaces
- ✓Build a stream pipeline of map, filter, and reduce
- ✓Use collectors to produce lists, sets, maps, and groupings
- ✓Recognize lazy evaluation and short circuit operations
- ✓Decide when (and when not) to use parallel streams
Prerequisites
Streams and lambdas turn Java from “verbose but explicit” into “concise and explicit”. A for loop tells the JVM how to iterate; a stream pipeline tells it what you want done. Once you internalize the operators, half the loops in your codebase disappear.
Lambdas in one minute
A lambda is shorthand for an instance of a functional interface (an interface with a single abstract method).
Runnable r = () -> System.out.println("hi");
java.util.function.Function<String, Integer> len = s -> s.length();
java.util.function.BiFunction<Integer, Integer, Integer> add = (a, b) -> a + b;
The compiler matches the lambda shape to the interface’s single method. Types are inferred from context.
Method references
When a lambda just forwards to an existing method, replace it with a method reference.
list.forEach(System.out::println); // instead of x -> System.out.println(x)
list.stream().map(String::toUpperCase) // instead of s -> s.toUpperCase()
.toList();
Four kinds: static (Integer::parseInt), bound instance (obj::method), unbound instance (String::length), and constructor (ArrayList::new).
A first pipeline
import java.util.*;
import java.util.stream.*;
List<String> names = List.of("Ada", "Linus", "Grace", "Dennis");
List<String> shouted = names.stream()
.filter(n -> n.length() > 3)
.map(String::toUpperCase)
.sorted()
.toList();
The pipeline reads top to bottom: source, intermediate operations, terminal operation. Nothing runs until the terminal call.
Intermediate vs terminal
Intermediate operations are lazy and return a new stream: filter, map, flatMap, sorted, distinct, limit, skip, peek.
Terminal operations consume the stream and produce a result: toList, collect, forEach, reduce, count, findFirst, anyMatch, min, max.
A stream can be consumed only once. Reusing it throws IllegalStateException.
reduce
reduce folds a stream into a single value. Provide an identity and a combining function.
int sum = IntStream.rangeClosed(1, 10).sum(); // built-in
int product = Stream.of(1, 2, 3, 4).reduce(1, (a, b) -> a * b);
String joined = Stream.of("a", "b", "c").reduce("", String::concat);
For strings, Collectors.joining is cleaner:
String csv = Stream.of("a", "b", "c").collect(Collectors.joining(","));
Collectors
Collectors is where most of the power lives. The everyday ones:
import static java.util.stream.Collectors.*;
List<String> list = stream.collect(toList()); // mutable list
List<String> immutable = stream.toList(); // unmodifiable
Set<String> set = stream.collect(toSet());
Map<String, Integer> map = stream.collect(toMap(k -> k, String::length));
Grouping is where streams really shine:
record Order(String customer, int amount) {}
List<Order> orders = List.of(
new Order("Ada", 100),
new Order("Ada", 50),
new Order("Linus", 200)
);
Map<String, List<Order>> byCustomer = orders.stream()
.collect(groupingBy(Order::customer));
Map<String, Integer> totalByCustomer = orders.stream()
.collect(groupingBy(Order::customer, summingInt(Order::amount)));
Partitioning splits into two buckets:
Map<Boolean, List<Integer>> evenOdd = IntStream.range(0, 10).boxed()
.collect(partitioningBy(n -> n % 2 == 0));
Primitive streams
Stream of Integer boxes every element. For heavy numeric work, use the primitive streams: IntStream, LongStream, DoubleStream.
double avg = IntStream.of(2, 4, 6, 8).average().orElse(0);
int sum = IntStream.range(0, 100).sum();
Convert with mapToInt, mapToObj, boxed:
int total = orders.stream().mapToInt(Order::amount).sum();
Optional
Operations like findFirst, min, and max return Optional. Treat it as a single-element container, not as a fancier null.
Optional<String> first = names.stream()
.filter(n -> n.startsWith("A"))
.findFirst();
String value = first.orElse("none");
first.ifPresent(System.out::println);
String upper = first.map(String::toUpperCase).orElse("NONE");
Avoid optional.get() without a presence check; it throws if empty. Avoid Optional as a method parameter or field; it is meant for return types.
Lazy and short circuit
Streams only do work the terminal operation demands. This is a feature: you can pipe huge sources through filter and map and bail at findFirst.
String first = Stream.generate(Math::random)
.map(d -> Double.toString(d))
.filter(s -> s.startsWith("0.9"))
.findFirst()
.orElseThrow();
Stream.generate is infinite. findFirst short circuits as soon as one match arrives.
flatMap
Use flatMap when each element produces a sub stream and you want them flattened into one.
List<List<Integer>> nested = List.of(List.of(1, 2), List.of(3, 4), List.of(5));
List<Integer> flat = nested.stream()
.flatMap(List::stream)
.toList(); // [1, 2, 3, 4, 5]
Parallel streams: be careful
.parallel() runs the pipeline on the common ForkJoin pool. It can help for CPU heavy, embarrassingly parallel work on large datasets with stateless lambdas. It can also slow you down badly because of overhead and contention.
long primes = LongStream.rangeClosed(2, 10_000_000)
.parallel()
.filter(n -> isPrime(n))
.count();
Do not use parallel streams for I/O or when the operation has side effects, modifies shared state, or relies on encounter order. Measure before and after.
Common pitfalls
- Side effects in
maporfiltermake pipelines hard to reason about. UseforEachonly as a terminal sink. - Modifying the source collection from inside a stream is undefined behavior.
peekis for debugging. Do not use it for real work; some terminal operations may skip it.
Wrap up
Streams swap loops for declarative pipelines, lambdas make functional interfaces ergonomic, and collectors handle the bookkeeping. Combine these with Java Generics and the Collections Framework, and you have most of what you need to write modern, idiomatic Java.