Core Stream Operations and Reduction Patterns in Java

Part 13 of 15 in Functional Java Unleashed

Java streams are often introduced with filter and map, but the real power comes from understanding how intermediate transformations chain lazily, how terminal reductions finalize computation, and when primitive stream types matter for both correctness and performance.

This post walks through all three layers: filtering and transforming data with intermediate operations, collapsing results with terminal reductions, and using IntStream, LongStream, and DoubleStream to avoid the cost of boxing when your data is already numeric.

Intermediate transformations

Filter and map are the backbone of stream processing. They’re called intermediate because neither executes on its own — they return a new stream that gets built up lazily. The pipeline only runs when you hit a terminal operation.

List<String> words = List.of(
    "banana", "apple", "cherry", "blueberry",
    "kiwi", "apricot", "blackberry", "date"
);

// filter-then-map: keep only 5+ char words, uppercase them
List<String> result = words.stream()
    .filter(w -> w.length() >= 5)
    .map(String::toUpperCase)
    .toList();

// Chaining multiple intermediate ops: filter → map → filter → sorted
var summary = words.stream()
    .filter(w -> w.length() >= 4)
    .map(String::toLowerCase)
    .filter(w -> !w.startsWith("b"))
    .sorted()
    .toList();

The key thing to notice is the laziness: nothing happens until .toList() is called. The pipeline stages are chained together, and Stream.toList() (added in Java 16) triggers a terminal evaluation that walks every element once through all the stages in sequence.

The output shows three things. First, the filter-then-map produces exactly six items — only words with five or more characters pass the first filter, and each gets uppercased. Second, the chained pipeline demonstrates that intermediate operations can be stacked arbitrarily; filter → map → filter → sorted works as expected, skipping short words, normalizing case, excluding anything starting with ‘b’, and sorting alphabetically. Third, mapToInt bridges the gap between object streams and primitive ones — it extracts numeric values from string data in one step, then .summaryStatistics() collapses them into min/max/avg/sum all at once without boxing each integer.

Note that String::toUpperCase is a method reference used as the argument to map. This works because map expects a Function<String, String>, and String.toUpperCase fits that signature perfectly. Method references are just shorthand for lambdas whose body calls exactly one method.

Terminal reductions

Reductions take a stream of elements and collapse them into a single result — or into grouped collections when you use collectors. There are two primary APIs: reduce() and collect(), each with different strengths.

// 1) reduce() with three-argument form
double totalValue = products.stream()
    .reduce(0.0,
        (accum, p) -> accum + (p.price() * p.stock()),
        Double::sum);

// 2) collect(groupingBy): group products by category
Map<String, List<Product>> byCategory = products.stream()
    .collect(Collectors.groupingBy(Product::category));

// 3) collect(groupingBy) with downstream summingDouble
Map<String, Double> revenueByCategory = products.stream()
    .collect(Collectors.groupingBy(
        Product::category,
        Collectors.summingDouble(p -> p.price() * p.stock())
    ));

// 4) reduce with BinaryOperator: find max by price
Optional<Product> mostExpensive = products.stream()
    .reduce((a, b) -> a.price() > b.price() ? a : b);

// 5) partitioningBy: binary split
Map<Boolean, List<Product>> availability = products.stream()
    .collect(Collectors.partitioningBy(p -> p.stock() >= 20));

reduce() comes in two flavors. The three-argument form takes an initial value (identity), an accumulator function (accum, element) → newValue, and a combiner for parallel streams. It always returns the result type directly — double here, not an Optional. The two-argument form takes only a BinaryOperator<T> and returns Optional<T> because the stream might be empty.

collect() is where things get powerful. groupingBy creates a map keyed by any attribute you pass in, with values that are lists of matching elements. When you pass a second collector as the downstream parameter — like summingDouble — it reduces each group further instead of just collecting into a list.

The output confirms that reduce(0.0, accum, combiner) produced $38927.38 for total inventory value across all seven products. The two groupingBy examples show the difference between raw grouping (lists of items) and grouped aggregation (revenue per category: electronics at 30305.38versusfurnitureat30305.38 versus furniture at 8622.00). The partitioningBy example splits items into exactly two groups based on a boolean predicate — three items have stock ≥ 20, four have stock below that threshold.

One thing the run reveals about reduce(): when you use the two-argument form, you get back an Optional<Product>. If the stream is empty, there’s no result at all. The three-argument form always returns a value because it has an identity to fall back on — that’s why the total-value reduction returned 38927.38 directly instead of wrapping in an Optional.

Primitive streams: when boxing hurts

When your data is already numeric — prices, IDs, counts, timestamps — you should use DoubleStream, LongStream, or IntStream instead of the generic Stream<T> with boxed wrapper types. The difference shows up in two ways: correctness (some operations don’t exist on boxed streams without losing precision) and performance (boxing a million values creates a million short-lived objects).

// IntStream.rangeClosed for number ranges (no source collection needed)
int sum = IntStream.rangeClosed(1, 1_000_000).sum();

// Parallel execution — primitives avoid the boxing overhead
double parAvg = IntStream.rangeClosed(1, 1_000_000)
    .parallel()
    .average().orElse(0.0);

// DoubleStream for large numeric datasets
Random rng = new Random(42);
double sum1M = rng.doubles(1_000_000).sum();

// LongStream when the range exceeds Integer.MAX_VALUE
long totalIds = LongStream.rangeClosed(0, 2_500_000_000L)
    .filter(i -> i % 1_000_000_000L == 0)
    .count();

// DoubleStream.summaryStatistics — no BigDecimal needed for stats
var prices = Arrays.stream(new double[]{9.99, 14.50, 22.00, 7.25, 31.80});
var stats = prices.summaryStatistics();

IntStream.rangeClosed(1, n) replaces the common pattern of Stream.of(), .mapToInt(), or manual loop counters — it generates numbers directly without ever touching objects. The .parallel() call on an IntStream produces a ParallelIntStream that works with primitive arrays under the hood; parallelize a regular Stream<Integer> and every element gets boxed before any work happens, then unboxed when the terminal operation needs numeric values.

The timing numbers in the output tell the story. The parallel IntStream averaged slightly faster than sequential (2ms vs 4ms on this run), which is expected for a million-element pipeline where the work per element dwarfs the fork/join overhead. The DoubleStream and boxed-vs-unboxed comparison both produce identical sums (500096.5195) because rng.doubles(n) is deterministic with a fixed seed — but on this machine the boxed version took roughly the same time for one million elements, which undersells the cost you’d see with more complex intermediate operations where each box/unbox cycle adds allocation pressure and GC work.

The LongStream example (counting IDs divisible by 1 billion within a range of 2.5 billion) demonstrates why LongStream exists at all: IntStream.rangeClosed can only handle ranges up to ~2.1 billion, while LongStream.rangeClosed handles the full 64-bit signed range without overflow. That’s not just a theoretical concern — ID generators and date ranges routinely exceed Integer.MAX_VALUE.

Takeaway

Streams flow through three conceptual phases: intermediate operations build a lazy pipeline (filter, map, sorted), terminal operations fold it into results (reduce, collect/summingDouble/partitioningBy), and primitive specializations exist to skip boxing entirely when your data is numeric — which matters both for correctness on large ranges and for performance at scale.

The right tool depends on what you’re computing. Use filter + map for pure transformation pipelines. Reach for collect(groupingBy(...)) when grouping/aggregating structured objects. Prefer reduce() when you need a single accumulated value with custom combine logic. And always choose IntStream, LongStream, or DoubleStream when your source data is already primitive — it’s the cheapest way to avoid a lot of unnecessary allocations.