Declarative Data Processing with Java Streams

Part 8 of 9 in Functional Java Unleashed

The idea

Java Streams, introduced in Java 8, let you describe what you want from a collection rather than how to get it. Instead of looping through elements with an explicit for loop, you chain operations — filter out what you don’t need, map what remains into something else, collect the results back into a list or map.

This is more than syntactic sugar: it enables lazy evaluation (intermediate operations don’t execute until terminal operations like collect() or toList() are called), method chaining that reads like a query pipeline, and optional parallel execution with one word — .parallelStream(). The trade-off is mental overhead. When reading someone else’s chain of five or six operations in one expression, it takes practice to parse what’s actually happening.

Stream basics: filter, map, skip, flatMap

At the core, a Stream is a sequence of elements supporting two kinds of operations: intermediate (lazy transformations that return another stream) and terminal (actions that produce a result or side effect). Once terminal, the stream is consumed.

The simplest pair is filter + map. Filter removes elements matching a predicate; map transforms each element. They chain together because both return streams:

List<Integer> evens = numbers.stream()
        .filter(n -> n % 2 == 0)
        .toList();
// [2, 4, 6, 8, 10]

The flatMap method deserves special attention because it behaves differently from map. While map produces one output element per input element, flatMap flattens a stream of streams into a single flat stream. The example below generates all pairs (i, j) where i < j from two separate lists — without flatMap, you’d need nested loops.

List<Integer> a = List.of(1, 2);
List<Integer> b = List.of(3, 4);
a.stream()
        .flatMap(i -> b.stream().map(j -> Map.entry(i, j)))
        .filter(e -> e.getKey() < e.getValue())
        .forEach(...);
// (1,3) (1,4) (2,3) (2,4)

skip and limit implement cursor-like navigation — skip the first three elements, take the next four. They’re often used with stream() on a collection to process data in chunks.

Collectors: the aggregation language

When you want to produce something more complex than just a filtered list — grouping by a property, computing averages, joining strings — you reach for collectors. Collectors is a utility class full of predefined collectors that work as terminal operations on a stream.

The most common pattern is groupingBy, which partitions a stream into sub-groups based on a classifier function and returns a Map. The real power comes from downstream collectors — you can nest another collector to produce summary statistics per group:

collect(Collectors.groupingBy(
    Employee::dept,
    Collectors.averagingDouble(Employee::salary)));

The example above produces average salary per department from a flat list of employees — all without an explicit loop. partitioningBy is the special case where the classifier returns boolean, giving you a Map<Boolean, List<T>> that cleanly separates elements into “yes” and “no” groups.

Other useful collectors worth knowing:

  • joining() — concatenate strings with an optional delimiter
  • summarizingDouble() — produces a DoubleSummaryStatistics object with count, min, max, sum, average in one pass
  • collectingAndThen() — wrap a collector with a post-processing step. In the example above it sorts department member lists alphabetically before returning them
  • mapping() — transforms elements within a downstream collector context, avoiding an extra map call on the outer stream

Parallel streams: when and why they help

Call .parallelStream() instead of .stream() and the Stream API transparently splits the workload across threads from ForkJoinPool.commonPool(). The same chain of operations runs concurrently — filter, map, reduce all work in parallel.

But parallelism isn’t free. There’s overhead for splitting, merging results, and thread coordination. This shows up clearly when you benchmark:

The first test uses isPrime, a genuinely CPU-heavy operation (trial division up to the square root). For 500,000 numbers, parallel execution delivered a 2.86x speedup.

The second test uses sumDigits, a trivially fast per-element computation. Parallelism barely helped (1.10x) because the overhead of thread management dominated the actual work time. With light computations, sequential processing is almost always faster.

Takeaway

Streams let you write data transformations as declarative pipelines — filter what you need, transform it, collect results. Use parallel streams when per-element work is expensive enough to amortize thread-splitting overhead; for most business logic (database queries, API calls, simple transforms), sequential streams are clearer and faster.