Functional Programming & Streams
Java 8 added lambdas, the java.util.function package, Optional, and the Stream API—declarative pipelines over collections and I/O without mutating shared state in the happy path. This chapter is interview gold and daily bread in Spring services, batch jobs, and data transforms.
Lambda expressions
A lambda is a concise implementation of a functional interface (exactly one abstract method). The compiler uses invokedynamic to link the lambda body at runtime— not always a new anonymous class per call site.
Syntax evolution: anonymous class → lambda
// Java 7 — verbose anonymous Runnable
Runnable r1 = new Runnable() {
@Override
public void run() {
System.out.println("tick");
}
};
// Java 8+ — lambda (parameter types optional when inferable)
Runnable r2 = () -> System.out.println("tick");
Comparator<String> byLen = (a, b) -> Integer.compare(a.length(), b.length());
// Block body with explicit return
Function<String, Integer> len = s -> {
if (s == null) return 0;
return s.length();
};
Effectively final capture
Lambdas may read local variables and parameters only if they are effectively final (assigned once). You cannot reassign count inside the lambda if count is used from the enclosing scope.
int limit = 10; // effectively final
list.stream().filter(s -> s.length() < limit).toList();
// limit = 20; // compile error if lambda still captures limit
Instance and static fields can be read and written (not recommended—hurts clarity and thread safety).
Lambda vs anonymous class — when to use which
| Prefer lambda | Prefer anonymous class |
|---|---|
| Single SAM interface | Need multiple methods (not a pure SAM) |
| Short behavior, clear intent | Must override equals / other Object methods on the instance |
| Streams, callbacks, comparators | Abstract class with stateful template methods |
Method references
Shorthand when a lambda only delegates to an existing method—often more readable.
| Form | Example | Lambda equivalent |
|---|---|---|
| Static | Integer::parseInt | s -> Integer.parseInt(s) |
| Instance (bound) | System.out::println | x -> System.out.println(x) |
| Instance (unbound) | String::toLowerCase | s -> s.toLowerCase() |
| Constructor | ArrayList::new | () -> new ArrayList<>() |
| Array constructor | String[]::new | n -> new String[n] |
list.stream().map(String::trim).forEach(System.out::println);
users.stream().map(User::name).toList();
// Constructor ref with args
Function<String, Path> pathFactory = Path::of; // Path.of(String) for single segment
Functional interfaces (java.util.function)
Standard SAM types for mapping, filtering, side effects, and factories. Most include default composition methods—build pipelines without nested lambdas.
| Interface | Abstract method | Role |
|---|---|---|
Function<T,R> | R apply(T) | Map T → R |
BiFunction<T,U,R> | R apply(T,U) | Map two inputs → R |
Predicate<T> | boolean test(T) | Filter |
BiPredicate<T,U> | boolean test(T,U) | Pair filter |
Consumer<T> | void accept(T) | Side effect (no return) |
BiConsumer<T,U> | void accept(T,U) | Pair side effect |
Supplier<T> | T get() | Lazy supply / factory |
UnaryOperator<T> | T apply(T) | Function where T == R |
BinaryOperator<T> | T apply(T,T) | Reducer for same type |
Composing functions and predicates
Function<String, String> trim = String::trim;
Function<String, String> lower = String::toLowerCase;
Function<String, String> pipeline = trim.andThen(lower); // trim then lower
Function<String, String> reverse = lower.compose(trim); // apply trim before lower
Predicate<String> nonBlank = s -> !s.isBlank();
Predicate<String> shortEnough = s -> s.length() <= 80;
Predicate<String> ok = nonBlank.and(shortEnough);
Predicate<String> bad = ok.negate();
Supplier<Instant> clock = Instant::now;
Supplier<List<User>> cache = () -> expensiveLoad(); // lazy until get()
Primitive specializations avoid boxing: IntPredicate, LongFunction, ToIntFunction<T>, etc.
Optional<T>
A container for zero or one value—explicit absence instead of null.
Tony Hoare called null references his billion-dollar mistake; Optional pushes
“maybe missing” into the type system for return values, not everywhere.
Creating Optional
| Factory | When |
|---|---|
Optional.of(value) | Non-null value guaranteed—NPE if null |
Optional.ofNullable(value) | Value may be null → empty |
Optional.empty() | Shared empty instance |
Consuming — avoid raw get()
Optional<User> user = repository.findByEmail(email);
// BAD — same as null without check
// User u = user.get();
User u = user.orElse(GUEST);
User u2 = user.orElseGet(() -> createGuest()); // supplier only if empty
User u3 = user.orElseThrow(() -> new NotFoundException(email));
user.ifPresent(u -> audit.log("login", u.id()));
user.ifPresentOrElse(
u -> sendWelcome(u),
() -> log.warn("unknown {}", email));
Transforming
Optional<String> city = user
.filter(u -> u.isActive())
.map(User::address)
.flatMap(Address::city); // map that returns Optional — avoids Optional<Optional<T>>
String label = city.orElse("unknown");
Optional as return type only — not field or parameter
- Return type — documents “may be absent” at call site
- Fields — doubles null problem; serialization/JPA awkward
- Parameters — forces callers to wrap; use overloads or nullable with validation instead
- Collections — never
List<Optional<T>>; filter nulls or use empty list
orElse(expensive()) always evaluates expensive()—use orElseGet for lazy fallback. Do not use Optional in hot loops for fields that are usually present—plain null checks can be faster and clearer.
Stream API — pipeline model
A Stream is a sequence of elements supporting aggregate operations. Operations chain: source → zero or more intermediate (lazy) → one terminal (eager). Streams do not store data—they pull from a source (collection, generator, I/O).
Source: list.stream(), Files.lines(path), Stream.iterate(...)
│
▼ intermediate (lazy — fused until terminal)
filter → map → flatMap → distinct → sorted → limit
│
▼ terminal (triggers execution)
collect / reduce / forEach / count / findFirst
│
▼
Result or side effect
One stream → one terminal operation. Reusing a closed stream throws IllegalStateException.
List<String> activeEmails = users.stream()
.filter(User::isActive)
.map(User::email)
.map(String::toLowerCase)
.distinct()
.sorted()
.toList(); // terminal — Java 16+
Creating streams
| Source | Example | Notes |
|---|---|---|
| Collection | list.stream(), parallelStream() | Spliterator-backed |
| Arrays | Arrays.stream(arr), Arrays.stream(arr, from, to) | Primitive overloads |
| Fixed values | Stream.of("a", "b") | Small immutable sources |
| Empty | Stream.empty() | |
| Generate | Stream.generate(supplier) | Infinite unless limited |
| Iterate | Stream.iterate(seed, UnaryOperator) | Java 9+ iterate(seed, Predicate, op) finite |
| Files | Files.lines(path) | Must close stream (try-with-resources) |
| Builder | Stream.builder() | Dynamic build |
try (Stream<String> lines = Files.lines(path)) {
long count = lines.filter(l -> !l.isBlank()).count();
}
Stream<Integer> ids = Stream.iterate(1, n -> n + 1).limit(100);
Stream<UUID> tokens = Stream.generate(UUID::randomUUID).limit(10);
Intermediate operations (lazy)
Return a new stream; not executed until a terminal op. Short-circuit ops (limit) can stop early.
| Operation | Purpose |
|---|---|
filter(Predicate) | Keep matching elements |
map(Function) | Transform element-wise |
flatMap(Function→Stream) | One-to-many flatten (lists, Optional) |
distinct() | Unique per equals/hashCode |
sorted() / sorted(Comparator) | Sort (stateful) |
peek(Consumer) | Debug tap—don’t mutate in peek |
limit(n) / skip(n) | Truncate / drop head |
mapToInt/Long/Double | Primitive streams—no boxing |
List<String> words = sentences.stream()
.flatMap(s -> Arrays.stream(s.split("\\s+")))
.map(String::toLowerCase)
.filter(w -> w.length() > 2)
.distinct()
.sorted(Comparator.comparingInt(String::length))
.limit(100)
.toList();
// flatMap for Optional
Optional<String> first = users.stream()
.map(User::middleName)
.flatMap(Optional::stream) // Java 9+ Optional.stream()
Terminal operations (eager)
Trigger pipeline execution; return a result, primitive count, or optional match.
| Operation | Result |
|---|---|
forEach / forEachOrdered | Side effect (void) |
collect(Collector) | Mutable reduction to collection/map |
reduce | Optional or identity fold |
count, min, max | Primitive summary |
findFirst, findAny | Optional element (parallel: any faster) |
anyMatch, allMatch, noneMatch | boolean short-circuit |
toArray | Array |
toList() 16+ | Unmodifiable list |
int total = orders.stream()
.mapToInt(Order::amountCents)
.sum();
Optional<Order> firstBig = orders.stream()
.filter(o -> o.amountCents() > 10_000)
.findFirst();
boolean anyLate = orders.stream().anyMatch(Order::isLate);
BigDecimal sum = invoices.stream()
.map(Invoice::total)
.reduce(BigDecimal.ZERO, BigDecimal::add);
Prefer collect over reduce then mutate—collectors handle mutable containers correctly. Use forEachOrdered on parallel streams when order matters.
Collectors in depth
Collectors implement mutable reduction: accumulate into a container, then finish.
Most common interview and production topic after groupingBy.
// Simple collectors
List<String> names = users.stream()
.map(User::name)
.collect(Collectors.toList()); // mutable ArrayList
Set<String> unique = stream.collect(Collectors.toSet());
String csv = stream.collect(Collectors.joining(", ", "[", "]"));
Long count = stream.collect(Collectors.counting());
toMap — merge function required for duplicates
Map<UUID, User> byId = users.stream()
.collect(Collectors.toMap(User::id, Function.identity()));
// Duplicate keys — specify merge
Map<String, User> byEmail = users.stream()
.collect(Collectors.toMap(
User::email,
Function.identity(),
(a, b) -> a)); // keep first on collision
groupingBy & partitioningBy
Map<Department, List<User>> byDept = users.stream()
.collect(Collectors.groupingBy(User::department));
Map<Department, Long> headcount = users.stream()
.collect(Collectors.groupingBy(User::department, Collectors.counting()));
Map<Boolean, List<User>> activeSplit = users.stream()
.collect(Collectors.partitioningBy(User::isActive));
Map<Department, String> names = users.stream()
.collect(Collectors.groupingBy(
User::department,
Collectors.mapping(User::name, Collectors.joining(", "))));
summarizingInt / statistics
IntSummaryStatistics stats = orders.stream()
.collect(Collectors.summarizingInt(Order::amountCents));
// stats.getAverage(), getMax(), getCount()
Unmodifiable & teeing Java 10+ / 12+
List<String> frozen = stream.collect(Collectors.toUnmodifiableList());
// teeing — two collectors in one pass
record Summary(long count, BigDecimal total) {}
Summary summary = orders.stream().collect(Collectors.teeing(
Collectors.counting(),
Collectors.mapping(Order::total, Collectors.reducing(BigDecimal.ZERO, BigDecimal::add)),
Summary::new));
Parallel streams
collection.parallelStream() splits work via the common ForkJoinPool
(ForkJoinPool.commonPool()). Good for CPU-bound, large, side-effect-free transforms—not a default for I/O.
When to use
- Large in-memory data, heavy per-element CPU (parsing, math, crypto)
- No shared mutable state; thread-safe sources
- Benchmark shows speedup (overhead exists for small lists)
Gotchas
| Risk | Mitigation |
|---|---|
| Non-thread-safe structures | Don’t mutate ArrayList from forEach; use collect |
| Order-dependent ops | Use forEachOrdered or avoid parallel |
| Blocking I/O inside map | Starves common pool—use dedicated Executor |
| Nested parallel streams | Can oversubscribe CPUs |
System.gc-like side effects | Keep pipelines pure |
long count = hugeList.parallelStream()
.filter(this::expensiveCheck)
.count();
// Custom pool — use ForkJoinTask outside Stream API for control
Parallel streams share the common pool with other JDK users. For mixed workloads, prefer explicit ExecutorService + chunking or reactive stacks—not parallelStream() on a servlet thread for DB calls.
Primitive streams
IntStream, LongStream, DoubleStream avoid boxing overhead
for numeric pipelines—specialized ops like sum, average, summaryStatistics.
int total = IntStream.rangeClosed(1, 100).sum();
double avg = users.stream()
.mapToInt(User::age)
.average()
.orElse(0);
// Back to object stream
List<String> labels = IntStream.of(1, 2, 3)
.mapToObj(i -> "item-" + i)
.toList();
// Watch boxing cost
Stream<Integer> boxed = IntStream.range(0, 1_000_000).boxed(); // allocates Integer
Newer stream & functional APIs
Stream.toList() Java 16+
Terminal op returning unmodifiable list—preferred over collect(toList()) when you do not need a mutable copy.
Collectors.teeing() Java 12+
Single pass, two reductions—see Collectors section (count + sum in one record).
Stream.mapMulti() Java 16+
Imperative-style flat expansion without building intermediate streams—efficient for small fan-out per element; replaces some flatMap patterns.
// mapMulti — emit 0..n elements per input
List<String> tokens = sentences.stream()
.mapMulti((sentence, consumer) -> {
for (String word : sentence.split("\\s+")) {
if (!word.isBlank()) consumer.accept(word);
}
})
.toList();
// Java 9+ useful stream methods
Stream.iterate(0, n -> n < 100, n -> n + 1); // finite iterate
Stream.ofNullable(maybeValue); // 0 or 1 element
stream.takeWhile(pred); // 9+
stream.dropWhile(pred);
REST layers map DTOs with streams; repositories return Optional or Page<T>; batch ETL uses Files.lines + collect groupingBy; metrics pipelines use summarizingInt. Avoid parallel streams on request threads doing JDBC—use async executors.
Stream pipelines fuse intermediate ops where possible (single pass). Stateful ops (sorted, distinct) may buffer. Spliterator characteristics (SIZED, ORDERED) affect parallel splitting efficiency.