Java Streams: From Concept to Parallel Execution
Part 12 of 16 in Functional Java Unleashed
The Stream Lifecycle
Java’s Stream API (introduced in Java 8) lets you process sequences of elements declaratively — think SQL-like queries on collections, but inside your Java code. Instead of writing for loops with manual accumulator variables, you chain operations: filter out unwanted items, transform what remains, then collect the results.
A stream has three phases:
Source — a collection, array, or generator that feeds elements into the pipeline.
Intermediate operations — transformations like filter(), map(), distinct() that are lazy: they set up a pipeline but do nothing until forced.
Terminal operation — an eager action like collect(), forEach(), reduce() that triggers execution and produces a result or side effect. The stream is consumed after this and can’t be reused.
The key insight is laziness: between source and terminal, operations are composed into a pipeline graph with no iteration happening yet. Only when the terminal operation fires does the engine walk the entire chain over each element.
Here’s what that looks like in code — a transaction list filtered by category, mapped to amounts, then summed:
record Transaction(String id, String category, double amount) {}
static final List<Transaction> TRANSACTIONS = List.of(
new Transaction("t1", "groceries", 45.0),
new Transaction("t2", "electronics", 999.0),
new Transaction("t3", "groceries", 12.5),
new Transaction("t4", "electronics", 250.0),
new Transaction("t5", "clothing", 89.0),
new Transaction("t6", "groceries", 33.0),
new Transaction("t7", "books", 15.0),
new Transaction("t8", "electronics", 420.0)
);
// Filter electronics, count them
long count = TRANSACTIONS.stream()
.filter(t -> t.category().equals("electronics"))
.count();
// → 3
// Chain: filter groceries, map to amounts
groceryAmounts = TRANSACTIONS.stream()
.filter(t -> t.category().equals("groceries"))
.map(Transaction::amount)
.toList();
// → [45.0, 12.5, 33.0]
// Terminal: sum all amounts
double total = TRANSACTIONS.stream()
.map(Transaction::amount)
.reduce(0.0, Double::sum);
// → 1863.5
Each of these calls creates a fresh stream — the STREAM source is just data, not a consumed pipeline. That’s why you can call .stream() on the same list over and over.
The output confirms three things: filtering works as a predicate gate (3 electronics from 8 total), chained filter+map produces exactly the amounts for matching groceries ([45.0, 12.5, 33.0]), and reduce(0.0, Double::sum) folds the entire stream into a single value.
Try reusing a consumed stream — it throws IllegalStateException. The stream model is intentionally one-shot: intermediate operations build a DAG of lazy transformations, terminal operation walks that DAG, then tears it down.
Collectors and Grouping
The real power of streams shows up when you need to aggregate data — group records by category, compute statistics per group, or pivot a collection into a Map. Java’s Collectors class (in java.util.stream.Collectors) provides factory methods for every common aggregation pattern.
Here are the five most useful patterns:
1. groupingBy() — partitions elements into buckets keyed by a classifier function. Under the hood it builds a Map<K, List<T>>, but you rarely interact with that directly because of downstream collectors.
2. groupingBy() + downstream collector — instead of just bucketing, collect each bucket with a summary: total price per category, average, min/max, or even a nested grouped result (downstream can be another groupingBy()).
3. partitioningBy() — a special case of groupingBy for boolean predicates. Always produces exactly two keys: true and false. Useful for “above threshold” vs “below threshold” splits.
4. toMap() — transforms elements into key-value pairs in a new Map. Must handle key collisions with a merge function.
5. toList() / toSet() / toCollection() — materialize the stream as a concrete collection.
The code below exercises all five on the same transaction dataset:
// groupingBy + summarizing statistics per category
Map<String, LongSummaryStatistics> stats = TRANSACTIONS.stream()
.collect(Collectors.groupingBy(
Transaction::category,
Collectors.summarizingDouble(Transaction::amount)
));
// → books: {count=1, sum=15.00, min=15.00, max=15.00, avg=15.00}
// clothing: {count=1, sum=89.00, ...}
// electronics: {count=3, sum=1669.00, min=250.00, max=999.00}
// groceries: {count=3, sum=90.50, min=12.50, max=45.00}
// partitioningBy — boolean split
Map<Boolean, List<Transaction>> parts = TRANSACTIONS.stream()
.collect(Collectors.partitioningBy(t -> t.amount() >= 100));
// → true: 3 expensive items (all electronics)
// false: 5 cheap items
// toMap with merge function for duplicate keys
Map<String, Double> amounts = TRANSACTIONS.stream()
.collect(Collectors.toMap(
Transaction::id,
Transaction::amount,
Double::max // merge: keep the larger amount
));
The output shows groupingBy partitioning 8 transactions across 4 categories (books, clothing, electronics, groceries), with subtotals and averages computed per group. The partitioningBy splits correctly at the $100 threshold — only electronics crossed it in this dataset.
Note that groupingBy preserves the encounter order of elements within each bucket and uses LinkedHashMap as the default map type, so iteration over groups matches insertion order.
Parallel Execution Strategies
Java streams can be executed sequentially or in parallel. The difference is a single method call: .parallel() or .parallelStream(). Under the hood, Java splits the source into chunks and processes each chunk with a thread from the ForkJoinPool.commonPool() — which by default uses one thread per available CPU core.
The critical constraint: parallel streams only help if your operations are pure and independent. If intermediate ops have side effects, depend on shared mutable state, or produce different results when order changes (like non-commutative operations), parallel execution will give wrong answers or race conditions.
Here’s a benchmark comparing sequential vs parallel on 1 million transactions:
// Sequential baseline
long start = System.nanoTime();
double total = TRANSACTIONS.parallelStream()
.mapToDouble(Transaction::amount)
.sum();
System.out.println("Time: " + elapsedMs + " ms");
On this machine (10 available processors), the results show an important caveat:
Sequential sum took 11 ms. Parallel stream with unordered() dropped to 12 ms — essentially the same, because addition is already fast and linear scan dominates. The real overhead of parallel streams is task splitting and merging, which matters only when each element’s processing time outweighs the parallelism cost.
Where parallel does help:
- grouping on large datasets with
ConcurrentHashMapas the downstream map (avoids serial merge at the end) - CPU-heavy computations per element (parsing, serialization, cryptographic hashes)
- filter + sum/count on very large sources where predicate evaluation is the bottleneck
Where parallel hurts:
- sorted/collect operations on huge data — serializing parallel results back into order can be slower than sequential processing
- limit()/findFirst() — these short-circuit but force serial work in the final phase, creating a bottleneck
- tiny datasets — ForkJoinPool overhead dominates; sequential is always faster
Takeaway
A stream is a lazy query pipeline: build it with composable intermediate operations (filter, map, distinct), fire it with a terminal operation (collect, reduce, forEach). Intermediate ops set up the DAG; terminal ops walk it. Use collectors like groupingBy() for aggregation patterns that would otherwise require manual HashMap boilerplate. Parallel execution works best when each element requires heavy computation and the dataset is large enough to amortize ForkJoinPool overhead — not for micro-benchmarks or short-circuit queries.