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.

mid senior

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
// 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.

Java
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 lambdaPrefer anonymous class
Single SAM interfaceNeed multiple methods (not a pure SAM)
Short behavior, clear intentMust override equals / other Object methods on the instance
Streams, callbacks, comparatorsAbstract class with stateful template methods

Method references

Shorthand when a lambda only delegates to an existing method—often more readable.

FormExampleLambda equivalent
StaticInteger::parseInts -> Integer.parseInt(s)
Instance (bound)System.out::printlnx -> System.out.println(x)
Instance (unbound)String::toLowerCases -> s.toLowerCase()
ConstructorArrayList::new() -> new ArrayList<>()
Array constructorString[]::newn -> new String[n]
Java
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.

InterfaceAbstract methodRole
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

Java
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

FactoryWhen
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()

Java
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

Java
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
⚠️ Pitfall

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).

One stream → one terminal operation. Reusing a closed stream throws IllegalStateException.

Java
List<String> activeEmails = users.stream()
    .filter(User::isActive)
    .map(User::email)
    .map(String::toLowerCase)
    .distinct()
    .sorted()
    .toList();  // terminal — Java 16+

Creating streams

SourceExampleNotes
Collectionlist.stream(), parallelStream()Spliterator-backed
ArraysArrays.stream(arr), Arrays.stream(arr, from, to)Primitive overloads
Fixed valuesStream.of("a", "b")Small immutable sources
EmptyStream.empty()
GenerateStream.generate(supplier)Infinite unless limited
IterateStream.iterate(seed, UnaryOperator)Java 9+ iterate(seed, Predicate, op) finite
FilesFiles.lines(path)Must close stream (try-with-resources)
BuilderStream.builder()Dynamic build
Java
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.

OperationPurpose
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/DoublePrimitive streams—no boxing
Java
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.

OperationResult
forEach / forEachOrderedSide effect (void)
collect(Collector)Mutable reduction to collection/map
reduceOptional or identity fold
count, min, maxPrimitive summary
findFirst, findAnyOptional element (parallel: any faster)
anyMatch, allMatch, noneMatchboolean short-circuit
toArrayArray
toList() 16+Unmodifiable list
Java
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);
💡 Pro Tip

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.

Java
// 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

Java
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

Java
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

Java
IntSummaryStatistics stats = orders.stream()
    .collect(Collectors.summarizingInt(Order::amountCents));
// stats.getAverage(), getMax(), getCount()

Unmodifiable & teeing Java 10+ / 12+

Java
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

RiskMitigation
Non-thread-safe structuresDon’t mutate ArrayList from forEach; use collect
Order-dependent opsUse forEachOrdered or avoid parallel
Blocking I/O inside mapStarves common pool—use dedicated Executor
Nested parallel streamsCan oversubscribe CPUs
System.gc-like side effectsKeep pipelines pure
Java
long count = hugeList.parallelStream()
    .filter(this::expensiveCheck)
    .count();

// Custom pool — use ForkJoinTask outside Stream API for control
🎯 Interview Tip

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.

Java
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.

Java
// 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);
📦 Real World

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.

🔬 Under the Hood

Stream pipelines fuse intermediate ops where possible (single pass). Stateful ops (sorted, distinct) may buffer. Spliterator characteristics (SIZED, ORDERED) affect parallel splitting efficiency.