Definition
- Sequential stream vs loop: approximately equal. Streams have pipeline setup overhead; JIT optimises both aggressively.
- For-loop wins: primitive operations (no autoboxing), index-based access, simple single-step iterations.
- Stream wins: readability for multi-step pipelines, built-in parallelism, composability.
- parallelStream wins: large datasets (10K+), CPU-bound per-element work, well-splittable source.
- parallelStream loses: small datasets (overhead > work), I/O-bound, LinkedList, shared state.
- Use IntStream/LongStream: avoids autoboxing for primitive operations.
- Benchmark with JMH: System.nanoTime is unreliable due to JIT warmup, GC, and dead-code elimination.
A for-loop is a clerk with a simple desk calculator at National Bank of India: fast for basic math. A stream is a multi-function automated system: has setup time but handles complex multi-step processing efficiently. For adding 10 numbers, the desk calculator wins. For sorting 100,000 transactions by three criteria, filtering, grouping, and producing a report, the automated system matches or wins. parallelStream is hiring 8 clerks instead of 1: only helps if there is enough work to keep all 8 busy.
Understanding the Concepts
Are streams faster than for-loops?
No, streams are not inherently faster. The JIT compiler optimises both aggressively. The choice should be based on readability and correctness first, performance second.
When does each win?
| Scenario | Winner | Reason |
|----------|--------|--------|
| Sum 10 integers | for-loop | Zero overhead vs pipeline setup |
| Sum 1M integers | Comparable | JIT optimises both |
| filter+map+reduce 1M | Comparable | Fusion compensates overhead |
| Parallel filter+reduce 1M | parallelStream | Multi-core distribution |
| LinkedList parallel | for-loop | LinkedList splits in O(n) |
| Primitive array sum | for-loop | No autoboxing |
How do you avoid autoboxing?
package com.bank.core;
import java.util.stream.IntStream;
public class PrimitiveStreamDemo {
public static void main(String[] args) {
int[] amounts = {1000, 2000, 3000, 4000, 5000};
// WRONG: autoboxing with Stream<Integer>
// int sum = Arrays.stream(amounts).boxed().reduce(0, Integer::sum); // boxing!
// CORRECT: IntStream avoids boxing
int sum = IntStream.of(amounts).sum(); // pure primitives
System.out.println("Sum: " + sum);
}
}
Sum: 15000
When does parallelStream help?
package com.bank.service;
import java.math.BigDecimal;
import java.util.List;
import java.util.stream.IntStream;
public class ParallelBenchmark {
// Simulate CPU-bound work
static BigDecimal monteCarlo(int seed) {
BigDecimal result = BigDecimal.ONE;
for (int i = 0; i < 1000; i++) {
result = result.add(BigDecimal.valueOf(Math.sin(seed + i)));
}
return result;
}
public static void main(String[] args) {
// Sequential: processes one at a time
long seqStart = System.nanoTime();
IntStream.range(0, 10_000).mapToObj(ParallelBenchmark::monteCarlo).toList();
long seqTime = (System.nanoTime() - seqStart) / 1_000_000;
// Parallel: distributes across cores
long parStart = System.nanoTime();
IntStream.range(0, 10_000).parallel().mapToObj(ParallelBenchmark::monteCarlo).toList();
long parTime = (System.nanoTime() - parStart) / 1_000_000;
System.out.println("Sequential: " + seqTime + "ms");
System.out.println("Parallel: " + parTime + "ms");
}
}
When does parallelStream hurt?
parallelStream hurts for small datasets, I/O-bound work, and poorly splittable sources.
| Source | Splittability | Parallel benefit |
|--------|--------------|------------------|
| ArrayList, array | Excellent (O(1) split) | High |
| HashSet | Good | Medium |
| TreeSet | Moderate | Medium |
| LinkedList | Poor (O(n) split) | Low or negative |
| Stream.iterate | Poor (sequential by nature) | Negative |
// WRONG: parallelStream on a small dataset
List.of(1, 2, 3).parallelStream().sum(); // overhead > benefit
// WRONG: parallelStream on I/O-bound work
accounts.parallelStream()
.forEach(a -> httpClient.post(a)); // contention on connection pool
// WRONG: parallelStream on LinkedList
LinkedList<Integer> ll = new LinkedList<>();
ll.parallelStream().filter(n -> n > 0).toList(); // O(n) per split
How do you benchmark correctly with JMH?
import org.openjdk.jmh.annotations.*;
@BenchmarkMode(Mode.AverageTime)
@State(Scope.Thread)
public class StreamBenchmark {
private List<Integer> data;
@Setup
public void setup() { data = IntStream.range(0, 100_000).boxed().toList(); }
@Benchmark
public int forLoop() {
int sum = 0;
for (int n : data) sum += n;
return sum;
}
@Benchmark
public int streamSum() { return data.stream().mapToInt(i -> i).sum(); }
@Benchmark
public int parallelSum() { return data.parallelStream().mapToInt(i -> i).sum(); }
}
System.nanoTime is unreliable due to JIT warmup, GC, and dead-code elimination. JMH handles all of these.
What are common mistakes?
// WRONG: using parallelStream by default "because it's faster"
accounts.parallelStream().filter(Account::active).toList(); // unnecessary overhead for small data
// CORRECT: default to sequential, parallel only after profiling
accounts.stream().filter(Account::active).toList();
// WRONG: benchmarking with System.nanoTime without JIT warmup
long start = System.nanoTime();
result = accounts.stream().filter(f).toList();
System.out.println(System.nanoTime() - start); // unreliable!
What should a senior engineer know?
- Default to streams for multi-step pipelines because readability is more valuable than nanoseconds in 99% of code.
- Use for-loops when you need primitive performance, index access, or mutation.
- Use parallelStream only after profiling confirms it helps.
- The real optimisation tool is JMH, not System.nanoTime.
- The common ForkJoinPool is shared across the JVM; a slow parallel stream can starve other tasks.
Visual Overview
Performance comparison: ───────────────────────── Small data (< 1K): loop ≈ stream >> parallelStream (overhead!) Medium data (1K-10K): loop ≈ stream ≈ parallelStream Large data (> 10K): loop ≈ stream < parallelStream (CPU-bound) Decision: ───────── Simple primitive loop? → for-loop (no autoboxing) Multi-step pipeline? → stream (readability) Large CPU-bound dataset? → parallelStream (speedup) Need to benchmark? → JMH (not System.nanoTime)
Key Takeaway
Streams are not inherently faster or slower than for-loops. The JIT compiler optimises both aggressively. Default to streams for readability in multi-step pipelines. Use for-loops for primitive performance and index access. Use parallelStream only after profiling confirms benefit.
The crossover point where parallel starts winning is typically around 10,000 elements for CPU-bound work. Below that, ForkJoinPool setup cost dwarfs computation. Use JMH for reliable benchmarking.
Remember one sentence: streams equal loops in speed, win in readability, and parallelStream only helps for large CPU-bound datasets.
---