Table of Contents

Parallel stream in Java 8

  • Last Updated: June 13, 2024
  • By: javahandson
  • Series
img

Parallel stream in Java 8

A parallel stream in Java 8 lets you process a collection on several CPU cores at once by adding a single method call. In this article, we will learn how parallel streams work, how to create them, how to measure whether they really make your code faster, and the traps that make them slower or even give you wrong answers.

1. Introduction

Your laptop almost certainly has several CPU cores. Yet a normal loop, or a normal stream, uses only one of them. The other cores sit idle while one core does all the work.

Parallel streams fix that. You write the same stream pipeline you already know, add .parallel(), and Java spreads the work across your cores. No threads to create, no locks to manage, no results to stitch together by hand.

That sounds like free speed. It is not always free, though. A parallel stream can run slower than a plain loop, and used carelessly it can even produce the wrong result. By the end of this article, you will know exactly when to reach for it and when to leave it alone.

1.1 What This Article Covers

  • What a parallel stream is and how it splits work into chunks.
  • Three ways to create one: parallelStream(), parallel() and sequential().
  • Real timings that show a parallel stream losing badly, and why.
  • The fix, using LongStream.rangeClosed().
  • Which data sources split well, and the simple N × Q rule.
  • Ordering, shared state, and the rules reduce() must follow.
  • Common mistakes, a full walkthrough, and interview questions.

2. What Is a Parallel Stream?

A parallel stream is a stream whose operations run on multiple threads at the same time. Java 8 added it alongside the Stream API itself. You can learn the stream basics in Introduction to Streams in Java.

A normal, sequential stream processes elements one after another on the calling thread. A parallel stream instead breaks its data into chunks, processes each chunk on a different thread, and then combines the partial results.

2.1 The Splitting Idea

Say we have a large set of numbers and we want to add them all up. Instead of running the sum on one thread, a parallel stream runs it on several threads. Here is a tiny version with ten numbers:

numbers = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }

The parallel stream splits these numbers into chunks automatically. You write no extra code for it. Each thread sums its own chunk, and at the end the stream adds the partial sums to get the final result:

Thread1
chunk1 = { 1, 2, 3, 4, 5 }    sum1 = 15

Thread2
chunk2 = { 6, 7, 8, 9, 10 }   sum2 = 40

result = sum1 + sum2 = 55

Think of a group of friends counting a huge jar of coins. Each friend takes a handful, counts it, and calls out a number. One person adds up the numbers. The jar gets counted far faster than if one person did it alone.

2.2 Life Before Parallel Streams

How would you do this before Java 7? You had to do every step by hand:

  • Split the numbers into parts with your own code.
  • Create a thread for each part and start it.
  • Synchronise the threads correctly to avoid race conditions.
  • Wait for every thread to finish.
  • Combine the partial results into one final answer.

Each step is easy to get wrong. Miss one synchronisation detail and your sum comes out different on every run. The code is long, hard to read, and hard to test.

2.3 The Fork/Join Engine Underneath

Java 7 introduced the fork/join framework to make this kind of work consistent and less error-prone. Java 8 parallel streams use that framework internally. The splitting, scheduling, and combining all happen inside the JDK.

Under the hood, a parallel stream asks its data source to split itself into halves, again and again, until the pieces are small. It then runs those pieces as fork/join tasks. If you want to see how that engine works, including work stealing, read ForkJoinPool in Java.

3. Creating a Parallel Stream

There are two common ways to get a parallel stream, plus one way to switch back. All of them are one method call.

3.1 parallelStream() on a Collection

Every Collection has a parallelStream() method. It returns a stream that may run in parallel from the very first operation.

List<Integer> list = List.of(1, 2, 3, 4, 5, 6, 7, 8);

System.out.println(list.stream().isParallel());         // Output: false
System.out.println(list.parallelStream().isParallel()); // Output: true

3.2 parallel() on Any Stream

Not every stream comes from a collection. Streams from Stream.iterate(), IntStream.range() or Arrays.stream() have no parallelStream() method. For those, call .parallel() anywhere in the pipeline.

long total = LongStream.rangeClosed(1, 1_000)
        .parallel()
        .sum();
System.out.println(total); // Output: 500500

3.3 sequential() and the Last Call Wins

The sequential() method turns a parallel stream back into a sequential one. What happens if you mix them in one pipeline? You might expect the first part to run in parallel and the rest to run sequentially. That is not how it works.

boolean parallel = list.stream()
        .parallel()
        .filter(x -> x > 2)
        .sequential()
        .isParallel();
System.out.println(parallel); // Output: false

A stream pipeline runs in one mode only. Whichever of parallel() or sequential() you call last decides the mode for the whole pipeline. Here, the final sequential() wins, so nothing runs in parallel.

3.4 Which Threads Do the Work?

Who actually runs the chunks? Let us print the thread names and find out.

Set<String> names = ConcurrentHashMap.newKeySet();

IntStream.rangeClosed(1, 1000)
        .parallel()
        .forEach(i -> names.add(Thread.currentThread().getName()));

System.out.println(names);
// Output (order and worker numbers vary):
// [main, ForkJoinPool.commonPool-worker-1, ForkJoinPool.commonPool-worker-2, ...]

Two kinds of threads show up. The workers come from ForkJoinPool.commonPool(), a pool the whole JVM shares. The main thread, which started the stream, also pitches in instead of just waiting.

By default, the common pool has one thread fewer than your CPU count. On a 12-core machine that means 11 workers, plus the calling thread, for 12 in total. We will see how to change that in section 11.

4. Summing Numbers Three Ways

Let us now add up the first n numbers in three styles: a plain loop, a sequential stream, and a parallel stream. All three give the same answer. The difference lies in how much work you do and how Java runs it.

4.1 The Iterative Approach

Write a program to find the sum of the first n numbers using the iterative approach.

package com.javahandson.parallel.data.processing;

public class SumIterative {
    public static void main(String[] args) {

        long n = 100;
        long sum = 0;
        for (long i = 1L; i <= n; i++) {
            sum += i;
        }
        System.out.println("Sum of first " + n + " numbers is : " + sum);
    }
}
// Output: Sum of first 100 numbers is : 5050

4.2 The Sequential Stream Approach

Write a program to find the sum of the first n numbers using a sequential stream.

package com.javahandson.parallel.data.processing;

import java.util.stream.Stream;

public class SumSequential {
    public static void main(String[] args) {

        long n = 100;
        long sum = Stream.iterate(1L, i -> i + 1)
                .limit(n)
                .reduce(0L, Long::sum);
        System.out.println("Sum of first " + n + " numbers is : " + sum);
    }
}
// Output: Sum of first 100 numbers is : 5050

Stream.iterate(1L, i -> i + 1) produces an endless stream of 1, 2, 3, and so on. limit(n) cuts it off after n elements, and reduce() adds them up. For a full tour of reduce(), see Reduce Method in Java 8 Streams.

4.3 The Parallel Stream Approach

Write a program to find the sum of the first n numbers using a parallel stream.

package com.javahandson.parallel.data.processing;

import java.util.stream.Stream;

public class SumParallel {
    public static void main(String[] args) {

        long n = 100;
        long sum = Stream.iterate(1L, i -> i + 1)
                .limit(n)
                .parallel()
                .reduce(0L, Long::sum);
        System.out.println("Sum of first " + n + " numbers is : " + sum);
    }
}
// Output: Sum of first 100 numbers is : 5050

The only change is one extra line: .parallel(). Notice everything we skipped. Splitting the stream? Java did it for us. Synchronisation code? There is none. The thread count never even came up. That is what makes parallel streams so easy.

But should you use a parallel stream every time? No. The next section shows why.

5. Measuring Performance

It seems obvious that a parallel stream should beat a sequential one. One task becomes many, and many threads run at once. So surely it finishes sooner?

Not always. A parallel stream is not automatically better than a sequential one. Let us time all three approaches with a much bigger n, 100 million, and see what happens.

5.1 Timing the Iterative Approach

Write a program to calculate the time taken to sum the first 100,000,000 numbers using an iterative approach.

package com.javahandson.parallel.data.processing;

public class IterativePerformance {
    public static void main(String[] args) {

        long startTime = System.nanoTime();
        long sum = iterativeSum(100_000_000L);
        long endTime = System.nanoTime();

        // Calculate duration in milliseconds
        long duration = (endTime - startTime) / 1_000_000;

        // Output the result and the time taken
        System.out.println("Sum: " + sum);
        System.out.println("Duration: " + duration + " ms");
    }

    public static long iterativeSum(long n) {
        long sum = 0;
        for (long i = 1L; i <= n; i++) {
            sum += i;
        }
        return sum;
    }
}
// Output (timings vary by machine):
// Sum: 5000000050000000
// Duration: 37 ms

5.2 Timing the Sequential Stream

Next, let us time the sequential stream version with the same 100,000,000 numbers.

package com.javahandson.parallel.data.processing;

import java.util.stream.Stream;

public class SequentialPerformance {
    public static void main(String[] args) {

        long startTime = System.nanoTime();
        long sum = sequentialSum(100_000_000L);
        long endTime = System.nanoTime();

        // Calculate duration in milliseconds
        long duration = (endTime - startTime) / 1_000_000;

        // Output the result and the time taken
        System.out.println("Sum: " + sum);
        System.out.println("Duration: " + duration + " ms");
    }

    public static long sequentialSum(long n) {
        return Stream.iterate(1L, i -> i + 1)
                .limit(n)
                .reduce(0L, Long::sum);
    }
}
// Output (timings vary by machine):
// Sum: 5000000050000000
// Duration: 1361 ms

5.3 Timing the Parallel Stream

Now for the parallel stream. This program times the same sum with .parallel() added to the pipeline.

package com.javahandson.parallel.data.processing;

import java.util.stream.Stream;

public class ParallelPerformance {
    public static void main(String[] args) {

        long startTime = System.nanoTime();
        long sum = parallelSum(100_000_000L);
        long endTime = System.nanoTime();

        // Calculate duration in milliseconds
        long duration = (endTime - startTime) / 1_000_000;

        // Output the result and the time taken
        System.out.println("Sum: " + sum);
        System.out.println("Duration: " + duration + " ms");
    }

    public static long parallelSum(long n) {
        return Stream.iterate(1L, i -> i + 1)
                .limit(n)
                .parallel()
                .reduce(0L, Long::sum);
    }
}
// Output (timings vary by machine):
// Sum: 5000000050000000
// Duration: 3460 ms

We ran these on a 12-core machine with JDK 25. Your exact numbers will differ, but the pattern stays the same.

5.4 So What Went Wrong?

The results are quite shocking, right? The parallel version runs about 2.5 times slower than the sequential stream, and nearly 100 times slower than the plain loop. There are two real problems here.

  • Boxing. Stream.iterate() produces a Stream<Long>, not a stream of primitive long values. Every number becomes a Long object. Java has to create a hundred million objects and unbox each one before it can add it.
  • Splitting. Stream.iterate() builds each element from the previous one. You cannot know the 50-millionth number without first producing the 49,999,999 before it. So the stream cannot cut the data into independent halves.

The parallel version suffers from both problems and then adds its own costs on top. It buffers elements into batches so that it can hand them to threads, and limit() has to coordinate across threads to stop at exactly n. Moving data between threads and cores costs time too.

The lesson is simple: a parallel stream is not a magic speed switch. Misused, it makes your program slower.

6. Using Parallel Streams Correctly

What if we could remove both the boxing problem and the splitting problem? Then the parallel stream would finally get a fair chance.

6.1 Switching to LongStream.rangeClosed()

We can fix both issues by using LongStream.rangeClosed() instead of Stream.iterate():

  • LongStream works on primitive long values, so there is no boxing or unboxing.
  • rangeClosed(1, n) knows its start and end up front. It can split easily into independent ranges. For example, 1–40 splits into 1–10, 11–20, 21–30 and 31–40.

Write a program to calculate the time taken to sum the first 100,000,000 numbers using LongStream.rangeClosed and a parallel stream.

package com.javahandson.parallel.data.processing;

import java.util.stream.LongStream;

public class RangeParallelPerformance {
    public static void main(String[] args) {

        long startTime = System.nanoTime();
        long sum = parallelSum(100_000_000L);
        long endTime = System.nanoTime();

        // Calculate duration in milliseconds
        long duration = (endTime - startTime) / 1_000_000;

        // Output the result and the time taken
        System.out.println("Sum: " + sum);
        System.out.println("Duration: " + duration + " ms");
    }

    public static long parallelSum(long n) {
        return LongStream.rangeClosed(1, n)
                .parallel()
                .reduce(0L, Long::sum);
    }
}
// Output (timings vary by machine):
// Sum: 5000000050000000
// Duration: 29 ms

6.2 What the Numbers Tell Us

Here are all four timings side by side, from the same machine:

ApproachBoxing?Splits well?Time
Plain for loopNoNot applicable37 ms
Stream.iterate(), sequentialYesNot applicable1,361 ms
Stream.iterate(), parallelYesNo3,460 ms
LongStream.rangeClosed(), parallelNoYes29 ms

The parallel rangeClosed() version runs over 100 times faster than the parallel iterate() version. Same machine, same answer, same number of threads. The only difference is the data source.

Notice something else, though. The plain loop is almost as fast. Adding numbers is so cheap that even 100 million of them take a fraction of a second on one core. The parallel stream barely wins here. It pulls far ahead when each element needs real work, as you will see in the walkthrough.

The takeaway: choose a source that splits well and avoids boxing first. Only then does going parallel have a chance to help.

6.3 Measuring Fairly

A single System.nanoTime() run is a rough guide, not a proper benchmark. Several things can skew it:

  • JIT warm-up. The JVM compiles hot code to machine code only after it has run for a while, so the first run is often the slowest.
  • Thread start-up. The common pool creates its threads the first time you use it, and the first parallel run pays for that.
  • Garbage collection. Boxed streams create millions of objects, and a GC pause can land in the middle of your timing.
  • Background load. Other programs on your machine compete for the same cores.

For quick experiments, run each version a few times and ignore the first result. For serious decisions, use JMH, the Java Microbenchmark Harness. It handles warm-up, repeated runs, and statistics for you.

7. When Does a Parallel Stream Pay Off?

So how do you know in advance whether parallel will help? Three questions cover most cases: how well does the source split, how much work happens per element, and which operations does the pipeline use?

7.1 How Well Does the Source Split?

A parallel stream can only be as parallel as its source allows. Some sources cut cleanly in half. Others have to be walked one element at a time.

SourceSplitsWhy
Arrays, ArrayListExcellentJump straight to the middle index
IntStream.range(), LongStream.rangeClosed()ExcellentStart and end are known; just compute the midpoint
HashSet, HashMap keysGoodSplit by internal buckets, though not always evenly
TreeSet, TreeMap keysFairSplit by tree branches, which may be uneven
LinkedListPoorMust walk node by node to find the middle
Stream.iterate(), BufferedReader.lines()PoorEach element depends on reading the one before

7.2 The N × Q Rule

A handy rule of thumb compares two numbers. N is the number of elements. Q is the cost of processing each element, roughly how many basic steps it takes.

  • When N × Q is small, the overhead of splitting and merging outweighs any gain. Stay sequential.
  • When N × Q is large, say above 10,000, parallel has room to pay off.
  • A tiny Q, like adding two numbers, needs a huge N before parallel helps.
  • A large Q, like checking whether a number is prime, pays off with a much smaller N.

That explains section 6.2. Addition has a tiny Q, so even 100 million elements barely beat a plain loop. The prime check in section 13 has a far bigger Q.

7.3 Which Operations Are in the Pipeline?

Operations like map(), filter() and sum() treat each element on its own. They run in parallel with no fuss. Others need to see many elements together, and those cost more in parallel:

  • sorted() must collect every element before it can output the first one.
  • distinct() must remember every element it has seen across all threads.
  • limit() and skip() must agree across threads on exactly which elements count as “first”.
  • findFirst() and forEachOrdered() must respect the original order, so threads end up waiting for each other.

7.4 Sequential vs Parallel at a Glance

AspectSequential streamParallel stream
Threads usedThe calling thread onlyCalling thread plus common pool workers
Element orderAlways encounter orderUnpredictable unless you ask for order
OverheadVery lowSplitting, scheduling, merging
Best forSmall data, cheap operations, I/OLarge data, CPU-heavy operations
Shared mutable stateWorks, though still poor styleGives wrong results
DebuggingEasy to followHarder, output interleaves

8. Order in Parallel Streams

A sequential stream always processes elements in their encounter order, the order the source gives them. A parallel stream processes chunks at the same time, so the order in which work happens becomes unpredictable. Does that mean your results come out scrambled? It depends on the operation.

8.1 forEach() vs forEachOrdered()

List<Integer> nums = List.of(1, 2, 3, 4, 5, 6, 7, 8);

nums.parallelStream().forEach(x -> System.out.print(x + " "));
// Output (varies on every run): 5 6 8 4 3 1 7 2

nums.parallelStream().forEachOrdered(x -> System.out.print(x + " "));
// Output: 1 2 3 4 5 6 7 8

forEach() runs the action whenever a thread gets to an element, so the order changes from run to run. forEachOrdered() guarantees encounter order, but threads must wait their turn. That waiting eats into your speed-up.

8.2 collect() Keeps the Order

Here is the good news. Collecting into a list keeps the original order, even in parallel:

List<Integer> tens = nums.parallelStream()
        .map(x -> x * 10)
        .collect(Collectors.toList());
System.out.println(tens); // Output: [10, 20, 30, 40, 50, 60, 70, 80]

Each thread builds a small list for its own chunk. The stream then joins those lists in chunk order, so the final list matches the source. The work runs in any order, but the result is still in order.

8.3 findFirst() vs findAny()

System.out.println(nums.parallelStream().filter(x -> x > 3).findFirst().get());
// Output: 4

System.out.println(nums.parallelStream().filter(x -> x > 3).findAny().get());
// Output (varies): 6

findFirst() always returns the first match in encounter order, which forces some coordination. findAny() returns whichever match a thread finds first, so it is cheaper in parallel. If any match will do, prefer findAny(). The article Finding and Matching in Java 8 covers both in depth.

Similarly, if order truly does not matter to you, call .unordered() on the stream. That frees operations like distinct() and limit() from tracking order, which can make them much cheaper in parallel.

9. Avoid Shared State Mutation

This is the most dangerous mistake with parallel streams. Never use a parallel stream with code that changes shared state. The program may run faster, but a fast wrong answer is worthless.

9.1 The Broken Addition Class

Let us demonstrate it. We create an Addition class whose add() method adds a number to an instance variable called total. Then we call add() from a parallel stream, so several threads update total at the same time.

package com.javahandson.parallel.data.processing;

import java.util.stream.LongStream;

class Addition {
    long total = 0;
    public void add(long number) {
        total += number;
    }
}

public class ParallelStateMutation {
    public static void main(String[] args) {

        Addition addition = new Addition();
        LongStream.rangeClosed(1, 100_000_000)
                .parallel()
                .forEach(addition::add);
        System.out.println(addition.total);
    }
}
// Output when run 1st time: 310761134675930
// Output when run 2nd time: 562160517319096
// Output when run 3rd time: 564123557887080

We get a different answer every time, and every answer is wrong. The correct sum is 5,000,000,050,000,000.

9.2 Why the Total Goes Wrong

The line total += number looks like one step, but the CPU performs three: read total, add the number, write total back. Now picture two threads doing this at once:

  • Thread A reads total as 100.
  • At the same moment, Thread B reads it as 100 too.
  • Next, Thread A adds 5 and writes 105.
  • Finally, Thread B adds 7 and writes 107, wiping out the update from Thread A.

Millions of these lost updates pile up, so the final total ends up far too low. This is a classic race condition. To read more about it, see Thread Safety in Java.

9.3 The Sequential Version Works

If we remove the parallel() call, the result is correct and the same every time:

package com.javahandson.parallel.data.processing;

import java.util.stream.LongStream;

class Addition {
    long total = 0;
    public void add(long number) {
        total += number;
    }
}

public class ParallelStateMutation {
    public static void main(String[] args) {

        Addition addition = new Addition();
        LongStream.rangeClosed(1, 100_000_000)
                .forEach(addition::add);
        System.out.println(addition.total);
    }
}
// Output when run 1st time: 5000000050000000
// Output when run 2nd time: 5000000050000000
// Output when run 3rd time: 5000000050000000

Only one thread updates total now, so no updates get lost. But we have also given up all the parallel speed.

9.4 The Right Fix: Let the Stream Combine

Can we have both speed and correctness? Yes. Stop sharing state, and let the stream combine the partial results for you:

long total = LongStream.rangeClosed(1, 100_000_000)
        .parallel()
        .sum();
System.out.println(total); // Output: 5000000050000000

Each thread sums its own chunk in a private variable. The stream adds the chunk results at the end. No two threads ever touch the same variable, so the answer is correct every time.

9.5 The Same Trap with Collections

Adding to a shared ArrayList from forEach() breaks in the same way. ArrayList is not thread-safe.

// Wrong: several threads call add() on one ArrayList
List<Integer> out = new ArrayList<>();
IntStream.range(0, 10_000).parallel().forEach(out::add);
System.out.println(out.size());
// Output (varies): 2523, or sometimes an ArrayIndexOutOfBoundsException

// Right: let collect() build and merge the lists
List<Integer> safe = IntStream.range(0, 10_000)
        .parallel()
        .boxed()
        .collect(Collectors.toList());
System.out.println(safe.size()); // Output: 10000

We expected 10,000 elements and got around 2,500. Threads overwrote each other’s slots inside the list’s internal array. collect() avoids this by giving each thread its own list and merging them at the end. More on that in Collect to List, Set and Map.

10. The Rules for reduce() in Parallel

A sequential reduce() forgives a lot. A parallel reduce() does not. It runs your function on each chunk and then again to merge the chunks, so the function must follow two rules.

10.1 The Identity Must Be a True Identity

The first argument to reduce() is the identity value. Combining it with any element must leave that element unchanged. For addition that value is 0, and for multiplication it is 1.

List<Integer> nums = List.of(1, 2, 3, 4, 5, 6, 7, 8);

System.out.println(nums.stream().reduce(10, Integer::sum));         // Output: 46
System.out.println(nums.parallelStream().reduce(10, Integer::sum)); // Output (varies): 116

// Fix: use the real identity, then add the starting value once
System.out.println(10 + nums.parallelStream().reduce(0, Integer::sum)); // Output: 46

Why 116? The parallel stream gave each of its chunks its own starting value of 10. Eight elements in eight chunks means 10 got added eight times instead of once. The sequential stream happened to work only because it used a single chunk.

10.2 The Function Must Be Associative

Associative means the grouping does not matter: (a + b) + c equals a + (b + c). Addition, multiplication, Math.max and string concatenation all qualify. Subtraction and division do not.

System.out.println(nums.stream().reduce(0, (a, b) -> a - b));         // Output: -36
System.out.println(nums.parallelStream().reduce(0, (a, b) -> a - b)); // Output (varies): 0

The parallel stream grouped the subtractions differently, so it got a different answer. Java does not warn you about any of this. It simply trusts that your identity and function are correct.

11. Controlling the Threads

By default, every parallel stream in your JVM shares the same common pool. Most of the time that is exactly what you want. Occasionally you need more control.

11.1 Sizing the Common Pool

You can change the common pool’s size with a system property when the JVM starts:

java -Djava.util.concurrent.ForkJoinPool.common.parallelism=4 MyApp

Keep in mind that this setting affects every parallel stream in the application, plus CompletableFuture async calls that use the common pool. Raising it rarely helps CPU-bound work, because you cannot run more threads at once than you have cores.

11.2 Running a Stream in Your Own Pool

A widely used trick runs the stream from inside your own ForkJoinPool. The stream’s tasks then run in that pool instead of the common one:

ForkJoinPool pool = new ForkJoinPool(4);

long sum = pool.submit(() ->
        LongStream.rangeClosed(1, 1_000_000).parallel().sum()
).get();

System.out.println(sum); // Output: 500000500000
pool.shutdown();

This keeps a heavy stream from hogging the shared pool. Be aware that it relies on how the JDK happens to work, not on anything the Stream specification promises. It works in current JDKs, but treat it as a pragmatic workaround, and always shut the pool down afterwards.

11.3 Keep Blocking Work Out

Parallel streams suit CPU-bound work. If each element makes a database call or an HTTP request, the worker threads mostly sit and wait. Worse, they block the shared pool, so every other parallel stream in the application slows down too. For I/O-heavy work, use an ExecutorService or virtual threads instead. See ExecutorService in Java.

12. Common Mistakes and Pitfalls

Let us collect the traps we have seen, plus a few more, into one checklist.

12.1 Going Parallel by Default

Adding .parallel() everywhere “just in case” is a common beginner habit. For small collections or cheap operations, the overhead makes your code slower. Start sequential, measure, and switch only when the numbers say so.

12.2 Using a Source That Splits Badly

Stream.iterate() and LinkedList are the usual culprits. Prefer arrays, ArrayList, or IntStream.range() and LongStream.rangeClosed() when you plan to go parallel.

12.3 Paying for Boxing

A Stream<Long> or Stream<Integer> creates an object for every number. Use the primitive streams IntStream, LongStream and DoubleStream, along with mapToInt() and mapToLong(), to avoid that cost.

12.4 Mutating Shared State

Updating a shared field, counter or ArrayList from inside the pipeline causes race conditions and wrong results. Use sum(), reduce() or collect() instead. If you truly need a shared counter, reach for a LongAdder or an AtomicLong, covered in Atomic Variables in Java.

12.5 Breaking the reduce() Rules

A wrong identity value or a non-associative function quietly gives wrong answers in parallel, even if it works sequentially. Test your reductions with both stream modes.

12.6 Relying on forEach() Order

If you need output in order, use forEachOrdered() or collect into a list. Plain forEach() runs in whatever order the threads happen to reach the elements.

12.7 Blocking in the Common Pool

Network calls, file reads, and Thread.sleep() inside a parallel stream tie up shared threads. That one slow pipeline then drags down every other parallel stream in your application.

13. Practical Walkthrough: Counting Primes

Let us put the rules into practice with a job that suits parallel streams well. We will count the prime numbers up to five million. The source is a range, which splits perfectly, and each element needs real work to check. That is a large N and a large Q.

13.1 The Complete Program

import java.util.stream.IntStream;

public class PrimeCounter {

    static boolean isPrime(int number) {
        if (number < 2) {
            return false;
        }
        for (int d = 2; (long) d * d <= number; d++) {
            if (number % d == 0) {
                return false;
            }
        }
        return true;
    }

    static long countSequential(int limit) {
        return IntStream.rangeClosed(1, limit)
                .filter(PrimeCounter::isPrime)
                .count();
    }

    static long countParallel(int limit) {
        return IntStream.rangeClosed(1, limit)
                .parallel()
                .filter(PrimeCounter::isPrime)
                .count();
    }

    static long timeMs(Runnable work) {
        long start = System.nanoTime();
        work.run();
        return (System.nanoTime() - start) / 1_000_000;
    }

    public static void main(String[] args) {
        int limit = 5_000_000;

        // Warm up both versions so the JIT compiler has done its work
        for (int i = 0; i < 3; i++) {
            countSequential(limit);
            countParallel(limit);
        }

        long[] result = new long[2];
        long seqMs = timeMs(() -> result[0] = countSequential(limit));
        long parMs = timeMs(() -> result[1] = countParallel(limit));

        System.out.println("Primes up to " + limit + ": " + result[0]);
        System.out.println("Sequential: " + seqMs + " ms");
        System.out.println("Parallel:   " + parMs + " ms");
        System.out.println("Same answer: " + (result[0] == result[1]));
    }
}
// Output (timings vary by machine):
// Primes up to 5000000: 348513
// Sequential: 1936 ms
// Parallel:   345 ms
// Same answer: true

13.2 How the Program Works

  • isPrime() tries every divisor up to the square root of the number. The (long) d * d cast stops the multiplication from overflowing near the top of the int range.
  • countSequential() and countParallel() differ by exactly one line, the .parallel() call.
  • The warm-up loop runs both versions three times first, so the JIT compiler has optimised them before we start timing.
  • The final check confirms that both versions found the same 348,513 primes.

13.3 Why This One Wins

On our 12-core machine, the parallel version ran about 5.6 times faster. Compare that with section 6, where adding numbers in parallel barely beat a plain loop. What changed?

Every rule from section 7 now points the same way. IntStream.rangeClosed() splits cleanly, and it uses primitive ints, so there is no boxing. Each prime check takes many steps, so Q is large. filter() and count() treat each element on its own, and nothing touches shared state.

Why not 12 times faster with 12 cores? Splitting and merging cost a little. Large numbers take longer to check than small ones, so some chunks finish later. Cores also share memory bandwidth and power limits. A 5 to 6 times speed-up is a very good real-world result.

14. Interview Questions

Q: What is a parallel stream in Java 8?

A: A parallel stream splits its data into chunks and processes them on several threads at once, then combines the partial results. You create one with parallelStream() on a collection or parallel() on any stream.

Q: Which thread pool do parallel streams use?

A: They use ForkJoinPool.commonPool(), which the whole JVM shares. Its default size is the number of CPU cores minus one. The thread that starts the stream also helps with the work.

Q: Is a parallel stream always faster than a sequential stream?

A: No. Splitting, scheduling and merging all add overhead. For small data, cheap operations, boxed values, or sources that split badly, such as Stream.iterate() or LinkedList, a parallel stream is often slower.

Q: Why is Stream.iterate() a poor choice for parallel streams?

A: Each element depends on the previous one, so the stream cannot split the data into independent chunks. It also produces boxed objects. LongStream.rangeClosed() avoids both problems.

Q: What happens if you call both parallel() and sequential() on one stream?

A: The last call wins for the whole pipeline. A stream runs entirely in one mode, so you cannot make only part of a pipeline parallel.

Q: What is the difference between forEach() and forEachOrdered()?

A: In a parallel stream, forEach() runs the action in whatever order threads reach the elements. forEachOrdered() keeps encounter order, but threads must wait for each other, which reduces the speed-up.

Q: Why should you avoid modifying shared state inside a parallel stream?

A: Several threads would read and write the same variable at once, so updates get lost. The result changes on every run and is usually wrong. Use sum(), reduce() or collect() so that each thread works on private data.

Q: What rules must a reduce() function follow in parallel?

A: The identity value must leave every element unchanged, like 0 for addition. The function must be associative, so grouping does not change the result. Breaking either rule gives wrong answers in parallel.

Q: What is the difference between findFirst() and findAny() in parallel?

A: findFirst() returns the first match in encounter order, which needs coordination between threads. findAny() returns any match a thread finds, so it is cheaper. Use findAny() when any match will do.

Q: How can you change the number of threads a parallel stream uses?

A: Set the java.util.concurrent.ForkJoinPool.common.parallelism system property at JVM start-up. Another option is to run the stream from inside your own ForkJoinPool, though that relies on JDK behaviour rather than the Stream specification.

Q: When should you use a parallel stream?

A: Use it for large data sets with CPU-heavy, independent work per element, a source that splits well, and no shared mutable state. Always measure before and after to confirm that it actually helps.

15. Conclusion

Let us wrap up what we covered. A parallel stream in Java 8 runs your pipeline on several cores by adding one method call. It uses the fork/join framework and the common pool underneath, so you never manage threads yourself.

  • Create one with parallelStream() or parallel(). The last of parallel() or sequential() wins.
  • Stream.iterate() in parallel ran about 100 times slower than a plain loop, because of boxing and poor splitting.
  • LongStream.rangeClosed() fixed both problems and turned the parallel stream into the fastest option.
  • Parallel pays off when N × Q is large and the source splits well, as the prime counter showed.
  • Never mutate shared state. Let sum(), reduce() and collect() combine results for you.
  • Use a true identity and an associative function in reduce().
  • Measure fairly, with warm-up, before deciding.

Next time you reach for .parallel(), ask the three questions from section 7 first. If the answers line up, you get real speed for one line of code.

16. Further Reading

Leave a Comment