AlgoMaster Logo

Parallel Streams

Medium Priority18 min readUpdated September 21, 2026
Listen to this chapter
Unlock Audio

A parallel stream splits its source into chunks, runs the pipeline on multiple threads, and combines the results. The API looks almost identical to a sequential stream, but the execution model is different enough that the wrong call can make code slower, wrong, or both. This lesson covers how parallel streams actually run, when they help, when they hurt, and the pitfalls that turn correct sequential code into broken parallel code.

Getting a Parallel Stream

A parallel stream can be created from a collection in two ways. Both produce a stream that runs its terminal operation in parallel.

Collection.parallelStream() is the direct route. stream().parallel() works on any stream and is useful when a stream already exists from another source (a file, a generator, another pipeline). Both produce the same execution behavior.

A pipeline can also flip back to sequential mid-pipeline with .sequential(). The mode that matters is the one set at the time the terminal operation runs. Calling .parallel() then .sequential() leaves the stream sequential.

The terminal operation here ran sequentially. Parallel mode is a property of the entire pipeline, set once before the terminal call, not toggled per stage.

What "Parallel" Actually Means

A parallel stream is not a stream that automatically runs faster. It's a stream whose source is recursively split into chunks, each chunk processed on a worker thread, and the partial results combined. The mental model is a tree, not a left-to-right iteration.

The source is split until each chunk is small enough to process directly. Workers run the pipeline (map, filter, anything in the middle) on their chunk. The combine step folds the partial results back into one answer. For sum, combine is addition; for toList, combine is list concatenation; for reduce, combine is the combiner passed in.

Three pieces make this work. The Spliterator knows how to cut a source into smaller spliterators. The ForkJoinPool provides the worker threads. The pipeline's terminal operation knows how to combine partial results.

The default pool is ForkJoinPool.commonPool(). Its size defaults to the number of available processors minus one (so on an 8-core machine, 7 workers, plus the calling thread that also participates).

Numbers will vary by machine. The pool isn't infinite. Ten parallel streams launched at the same time all share the same workers.

Splittability Matters

A stream is only as parallel as its source is splittable. Some sources cut cleanly down the middle and stay balanced after every cut. Others split poorly, leaving most of the work on one worker while the others sit idle.

The well-behaved sources are arrays, ArrayList-backed streams, and ranges like IntStream.range. They know their size up front and can return the middle index in constant time. The poorly behaved sources are LinkedList, Stream.iterate, Stream.generate, and streams built from sources that have to be walked to know their size.

Numbers will differ across runs and machines, but the pattern is consistent. The ArrayList finishes faster because ArrayList.spliterator() can split the underlying array in half with index math. The LinkedList has to walk the chain to find a split point, and once it's split, the chunks aren't backed by efficient index lookups either.

A microbenchmark like the one above gives a rough signal, not a real measurement. JIT warm-up, GC, and CPU caches make single-shot timings noisy. For decisions that matter, use JMH or run a hot loop many times before taking the timing.

Stream.iterate(seed, next) is sequential by construction. Each value depends on the previous one, so the stream can't be cut into independent chunks. Calling .parallel() on it doesn't fail, but it doesn't help either.

The .parallel() call is a no-op for throughput here. The pipeline has to compute values in order before it can split anything, and limit on an ordered stream adds further overhead in parallel mode.

For a long range, use LongStream.rangeClosed instead. Ranges split perfectly.

When Parallel Helps and When It Doesn't

Parallel streams have real overhead: thread coordination, splitting, combining, and the cost of moving work between threads. That overhead has to be earned back by the speedup. For small datasets or cheap per-element work, parallel is slower than sequential.

Use parallel whenAvoid parallel when
Dataset is large (think 10K+ elements)Dataset is small (a few hundred items)
Per-element work is real CPU work (math, hashing, parsing)Per-element work is trivial (just a field access)
Source splits cheaply (array, ArrayList, range)Source splits poorly (LinkedList, iterate)
Elements are independent (no shared state)Pipeline touches shared mutable state
Combining partial results is cheap (sum, count, max)Combining is expensive or order-sensitive
The work is CPU-boundThe work is I/O-bound (database, network, disk)

A concrete example: computing total revenue across millions of orders is a good fit. Each order's contribution is independent, the source is an array-backed list, and the combine step is addition.

A poor example would be summing a list of 50 cart subtotals. The dataset is tiny, the per-element cost is one field read, and the overhead of splitting and joining dwarfs any gain. The sequential version wins by a wide margin.

A rough rule of thumb: parallel becomes worth considering when N x cost-per-element clears roughly 100 microseconds of real CPU work. Below that, the overhead dominates. Above that, it's worth measuring. Measurement, not guessing, is the practical guide. Numbers change with the machine, the JIT, and the surrounding workload.

I/O-bound operations (reading from a database, calling an HTTP service, hitting disk) don't benefit from parallel streams the way CPU work does. Parallel streams use a small pool sized for CPU work. Blocking those threads on I/O starves every other parallel stream sharing the same pool.

Stateful Operations Cost More in Parallel

Operations like sorted, distinct, and limit are stateful. They need to see more than one element at a time to do their job. In a sequential stream, that is fine because there's only one thread walking the data. In a parallel stream, the operation has to coordinate across workers, which adds real cost.

sorted in parallel has to gather elements from all workers, sort the merged result, and then hand the sorted sequence to the downstream stages. The sort itself can be parallelized internally, but it still requires a global view.

distinct has to deduplicate across workers. On a stream with a well-defined encounter order, distinct also has to preserve the first occurrence, which forces coordination.

limit(n) has to count elements in order. On an ordered parallel stream, taking the first n elements means workers can't just contribute whatever they've finished; the pipeline has to identify which n come first in encounter order.

The two calls produce the same count, but the work to get there is different. unordered() tells the pipeline that encounter order doesn't matter, which lets limit accept whatever the first million elements happen to be, in any order. The ordered version does extra bookkeeping.

The same applies to findFirst vs findAny. findFirst requires encounter order: in parallel, workers have to coordinate to identify which match came earliest. findAny says "any match is fine," which lets the first worker that finds one return immediately and short-circuit the rest.

For this tiny list the results match, but with millions of elements findAny can finish in a fraction of the time. Use it when the first match in encounter order isn't required. For a "give me one product under $20" search, findAny is appropriate.

sorted and distinct on a parallel stream still do useful work, but they shift cost from per-element to coordination. A pipeline that ends in sorted().limit(n) is often faster as toList() followed by a single sort, or by storing the data sorted to begin with.

forEach in Parallel Doesn't Preserve Order

forEach is unordered by contract. On a sequential stream it happens to run in encounter order, but the API doesn't promise it. On a parallel stream, elements run on whatever worker grabs them, so output order is not guaranteed.

The first line will differ across runs. forEachOrdered walks elements in encounter order, which costs coordination but guarantees the sequence. Use it when ordering matters for printing or for writing to an ordered sink. Use forEach when order is irrelevant, since it lets workers proceed independently.

Thread Safety and Side Effects

The biggest source of bugs in parallel streams is using lambdas that mutate shared state. The API provides no protection here: the code compiles, it runs, and it produces wrong answers.

What's wrong with this code?

The intent is to apply a discount and collect the results. The problem is that ArrayList is not thread-safe. Many workers call add on the same list concurrently. The result is one of three outcomes: a count less than 100,000 because writes were lost, an ArrayIndexOutOfBoundsException thrown from inside ArrayList.add, or, occasionally, the right answer if the timing happens to work. None of these are acceptable.

Fix: let the stream collect the results with a proper collector. The collector knows how to merge partial results from each worker safely.

The rule is straightforward: any lambda passed into a stream operation must be stateless. It can read from the element it's given, use local variables, and call pure functions. It cannot mutate anything that another thread might also touch.

Using a synchronized list like Collections.synchronizedList removes the visible bug but eliminates the speedup. Every add call serializes on a single lock, so all the workers end up waiting in line. Use a Collector that gives each worker its own bucket and merges them at the end.

reduce Needs an Associative Combiner

reduce with an identity and a binary operator combines elements pairwise. In a sequential stream, the order is left-to-right: ((((id op a) op b) op c) op d). In a parallel stream, workers reduce their chunks independently, and the partial results are then combined. For the result to match the sequential answer, two conditions must hold.

First, the operator must be associative: (a op b) op c equals a op (b op c). Addition is associative; subtraction is not.

Second, the identity must actually be an identity for the operator: id op a equals a. Zero is an identity for addition; one is not.

When either condition is violated, the parallel reduce produces different results from the sequential reduce, and the result varies between runs as the split structure varies.

The parallel result is wrong (relative to the sequential one) and depends on how the stream was split. Subtraction is the classic counterexample: (10 - 5) - 3 = 2, but 10 - (5 - 3) = 8. Different groupings give different answers.

Stick to associative operations: addition, multiplication, max, min, string concatenation (associative, even if slow). When unsure, check both conditions before parallelizing.

IntStream and LongStream Avoid Autoboxing

Primitive streams (IntStream, LongStream, DoubleStream) matter even more in parallel than in sequential pipelines. Stream<Integer> boxes every element into an Integer object, and parallel work amplifies the allocation pressure because multiple threads allocate at once.

The first version pays for two things: boxing each int into an Integer, and a poorly splittable Stream.iterate source. The second version uses rangeClosed, which splits perfectly, and never allocates wrapper objects. The gap is large enough that it's hard to attribute to noise.

For aggregates over many numbers, prefer IntStream/LongStream/DoubleStream. The methods mapToInt, mapToLong, and mapToDouble drop into a primitive stream from an object stream.

Boxing isn't just allocation. Boxed integers also defeat the JIT's ability to vectorize and inline aggressively. For tight numeric loops, primitive streams can be an order of magnitude faster than their boxed equivalents.

The Common Pool Is Shared

ForkJoinPool.commonPool() is shared across the entire JVM. Every parallel stream in the application, every parallel stream in every library it uses, and the default executor for some other JDK APIs all draw from the same set of workers.

A slow parallel stream can starve other parallel work. Launching a parallel stream that runs a blocking call inside its lambda (a database query, an HTTP request, a Thread.sleep) parks the worker until that call returns. Other parallel streams waiting for a worker have to queue behind it.

The calling thread (main) participates in the parallel work. The common pool isn't a thread pool that does all the work while the caller idles. The calling thread joins in, which is part of how Fork/Join's work-stealing scheduler keeps every thread busy.

The advanced workaround for avoiding the common pool is to submit the terminal call to a dedicated ForkJoinPool.

The integer overflow in the output is a separate matter (int can't hold the sum; use long for real code). The structural point is that when the terminal operation runs inside dedicated.submit(...), the workers used are those of dedicated, not the common pool. This is rarely needed in application code and not officially documented as a guarantee, but it's a known idiom for isolating expensive parallel work.

For most code, the approach is not "use a custom pool" but "don't put blocking work in a parallel stream in the first place."

A Decision Checklist

Before using .parallel(), run through these questions:

  1. Is the dataset big enough? Tens of thousands of elements at least, ideally more.
  2. Is the per-element work real CPU work? Math, parsing, hashing, encoding, comparing.
  3. Does the source split well? Arrays, ArrayList, ranges do; LinkedList, iterate, generate do not.
  4. Are the lambdas stateless? No shared mutable state, no side effects.
  5. Is the work CPU-bound? Calls out to disk, network, or a database make parallel streams unsuitable.
  6. Is the reduce associative? Is the identity actually an identity?
  7. Is encounter order required? If yes, findFirst/forEachOrdered/limit will cost more. If no, use findAny/forEach/unordered.

If every answer is "yes" (or "no" where it should be), parallel is worth measuring. If two or more are wrong, sequential is likely faster, simpler, and harder to break.

Quiz

Parallel Streams Quiz

10 quizzes