Java Streams
Purpose
Use streams where they express a transformation better than a loop, and keep them honest:
pure stages, mutable state only inside a collector, and no pipeline that quietly holds a
database cursor or hijacks the process-wide common pool. Two failure modes: the forEach that
is a for loop with worse debuggability and hidden shared-state mutation; and
parallelStream() applied to blocking work, where every replica's requests contend on one
shared ForkJoinPool.commonPool whose effective parallelism depends on runtime configuration
and the processors visible to the JVM.
Workflow
Inspect the project JDK, source ownership, null/order/mutability contract and workload before rewriting. Core examples target Java 21; Gatherers require Java 24+, and structured-concurrency alternatives need their exact preview policy checked. Do not upgrade or enable preview for a pipeline cleanup. Examples are partial snippets with application types and imports omitted.
- Ask what the code is doing. Transform-filter-aggregate over a collection → stream. Loop with early exit on complex conditions, index arithmetic, two collections in lockstep, mutation of local state, or a checked exception per element → loop.
- Keep every intermediate stage pure.
map,filter,sorted,flatMapcompute; they do not write to anything outside themselves. Accumulation happens incollectorreduce. - Pick the collector deliberately, not the first one that compiles:
toListwhen order matters,toMapwith deliberate duplicate rejection or merge policy,groupingBywith an explicit downstream,teeingwhen two aggregates are needed in one pass. - Decide the return type at the API boundary. A
Collectionfor anything already in memory; aStreamonly when laziness or size genuinely demands it — and then say in the Javadoc whether it must be closed. - Only consider parallel with a measurement. Blocking work needs explicit concurrency, cancellation and executor ownership; default parallel streams commonly use the shared common pool.
- Verify the pipeline is single-pass and side-effect free by reading it aloud: source, what each stage computes, what the terminal operation produces.
Rules
A stream is not a better loop; it is a different expression of one. Prefer a stream when the pipeline reads as a description of the result. Prefer a loop when the code needs an early
returnmid-iteration,breakwith several conditions, index or neighbour access, mutation of local variables, or atry/catchper element.Require non-interference and statelessness for behavioral parameters. A
maporfilterthat adds to an external list, increments a counter, writes a log per element, or calls a mutating service is not a reliable place for required effects: even an explicitly sequential pipeline may elide a stage or short-circuit. Put required effects in an explicit loop or suitable terminal action.forEachbelongs at the end and, ideally, only for output — printing, publishing, writing. Accumulating into a collection withforEach(list::add)is a mutable reduction written the unsafe way: usecollect, which is correct sequentially and in parallel.Collectors.toMapwithout a merge function deliberately rejects duplicate keys; use it when uniqueness is an invariant and test the failure. Supply a keep/merge policy only when duplicates are valid. Current JDK implementations also reject null mapped values through merge mechanics; do not depend on implementation-specific null tolerance—normalize, use a suitable custom collector/map, or write an explicit loop.Give
groupingByan explicit downstream collector whenever the group is not a plain list —counting(),summingLong(...),mapping(..., toList()),reducing(...). Deep nesting is a readability/shape signal; a record key or explicit result model may be clearer, without a fixed threshold.reduceis for associative, side-effect-free combination into an immutable result. Anything that accumulates into a mutable container iscollect. Mutating a reduction's identity can appear to work sequentially but violates the contract and can corrupt parallel results.Return a
Collection, not aStream, from a method whose result is already materialised. A stream is single-use—a second terminal traversal is invalid—has no collection-style size/index API even though its spliterator may know an exact size. Return aStreamwhen the result is lazily produced, is large enough that materialising it is a real cost, or is backed by a resource.A stream backed by a resource is a resource.
Files.lines,Files.walk,Files.list,Files.findhold open resources; JDBC/JPA result streams may hold a cursor/connection depending on driver/provider and execution mode. Resource-backed streams belong intry-with-resources and their Javadoc must say so — see java-resource-management. If a repository stream depends on a transaction-bound cursor, consumption must finish inside that transaction; verify the provider contract rather than assuming every repository stream does.Streams are lazy: traversal work starts at a terminal operation, and short-circuiting operations (
findFirst,anyMatch,limit) may stop early.peekis an intermediate side-effect hook, not a guaranteed per-source-element callback; optimization and short-circuiting may skip it.Parallel streams commonly execute in
ForkJoinPool.commonPool()when initiated normally; pool selection from custom ForkJoin tasks is implementation-sensitive, and common parallelism is configurable/container-aware rather than always processors-minus-one. Blocking can starve or distort other common-pool workloads. Parallel streams are primarily for CPU-bound work over a splittable source, with a measurement to show it helps.Virtual threads do not change a parallel stream's execution policy. For concurrent I/O per element, prefer explicit structured fan-out or
Gatherers.mapConcurrent, notparallel().Prefer
IntStream/LongStream/DoubleStreamwhen primitive representation matters; aStream<Integer>carries boxed references, though traversal does not necessarily allocate new boxes when the source is already boxed.mapToInt(...).sum()andsummaryStatistics()exist for exactly this.Use
Gatherers(final since Java 24) for intermediate operations the JDK does not ship — fixed and sliding windows,scan,fold, andmapConcurrent, which runs a mapper on virtual threads with a concurrency limit and preserves encounter order. It is the supported extension point; writing a customSpliteratorfor the same job rarely is.Parallel correctness requires more than “no shared list”: reduction/collector operations need associative combination, a true identity, compatible accumulator/combiner behavior, and honest
Collector.Characteristics. Encounter order (findFirst,forEachOrdered) can limit parallelism; choosefindAny/unordered processing only when semantics permit.
Report the preserved null, duplicate, order, mutability and resource-lifetime contracts, the smallest justified rewrite (or decision to keep the loop), and tests/measurements actually run. Do not infer a performance improvement from shorter syntax.
References
- Collectors and purity — read when choosing or
composing collectors, when a pipeline accumulates state, when
toMap/groupingBymisbehave on real data, or when deciding betweenreduceandcollect. - Parallel streams and gatherers — read before adding
parallel(), when a parallel pipeline is slower or is starving the common pool, or when a pipeline needs windowing, running state or bounded concurrency per element.