← Back to Multithreading Course

Class 41: Parallel Streams and the Fork/Join Framework

Part 7 — Asynchronous Programming and Modern Java

Imagine that we have a collection containing one million numbers and we need to process every number.

We could process them one by one using a single thread. But what if we could divide this work among multiple threads and process different portions at the same time?

This is the basic idea behind parallel processing.

Java provides a convenient way to process collections in parallel using Parallel Streams.

Behind the scenes, parallel streams commonly use the Fork/Join Framework to divide work into smaller tasks and execute them using multiple threads.

Let's understand these concepts step by step.

1. First, Understand a Normal Stream

Before understanding parallel streams, let's quickly revise normal streams.

Suppose we have a list of numbers and want to print each number.

import java.util.Arrays;
import java.util.List;

public class NormalStreamExample {

    public static void main(String[] args) {

        List<Integer> numbers =
                Arrays.asList(1, 2, 3, 4, 5);

        numbers.stream()
               .forEach(number -> {
                   System.out.println(number);
               });
    }
}

Here, stream() creates a normal sequential stream.

The elements are processed one after another by the thread executing the stream pipeline.

We can visualize it like this:

Thread
  |
  v
Process 1
  |
  v
Process 2
  |
  v
Process 3
  |
  v
Process 4
  |
  v
Process 5

Each element is processed as part of the same sequential stream pipeline.

2. What Is a Parallel Stream?

A parallel stream allows Java to process different portions of a collection concurrently using multiple threads.

Instead of processing every element through one sequential pipeline, Java can divide the collection into smaller portions.

Different threads can then process those portions.

For example:

Original Collection
        |
        v
  Divide into parts
        |
   +----+----+
   |    |    |
   v    v    v
Thread Thread Thread
   1    2    3
   |    |    |
   v    v    v
Process different portions
        |
        v
    Combine Results

The actual number of threads and how the work is divided depend on the execution environment and the framework.

The important point is that multiple threads can participate in processing the collection.

3. Creating a Parallel Stream

Creating a parallel stream is very simple.

Instead of calling stream(), we can call parallelStream().

import java.util.Arrays;
import java.util.List;

public class ParallelStreamExample {

    public static void main(String[] args) {

        List<Integer> numbers =
                Arrays.asList(1, 2, 3, 4, 5);

        numbers.parallelStream()
               .forEach(number -> {

                   System.out.println(
                       "Number: " + number
                       + " | Thread: "
                       + Thread.currentThread().getName()
                   );

               });
    }
}

Let's understand the important part:

numbers.parallelStream()

This creates a parallel stream from the collection.

We also print the current thread name:

Thread.currentThread().getName()

This helps us observe which threads are processing the elements.

You may see different thread names in the output. The exact output and processing order can vary between executions.

Remember: parallel processing does not guarantee that every element will be processed by a different thread.

4. stream() vs. parallelStream()

Let's compare the two methods.

stream() parallelStream()
Creates a sequential stream. Creates a parallel stream.
Processes the stream pipeline sequentially. Can process different portions concurrently.
Uses the thread executing the pipeline. Uses multiple threads for parallel processing.
Does not divide the work for parallel execution. Can divide the work into smaller portions.

Both methods allow us to use familiar stream operations such as filter(), map(), and reduce().

5. A Practical Example: Calculating a Sum

Let's take a simple example.

Suppose we have a list of numbers and want to calculate their total.

We can use a parallel stream with reduce().

import java.util.Arrays;
import java.util.List;

public class ParallelSumExample {

    public static void main(String[] args) {

        List<Integer> numbers =
                Arrays.asList(10, 20, 30, 40, 50);

        int total = numbers.parallelStream()
                           .reduce(0, Integer::sum);

        System.out.println("Total: " + total);
    }
}

The output will be:

Total: 150

Let's understand reduce().

.reduce(0, Integer::sum)

In parallel processing, different portions of the collection can be processed separately.

Their partial results are then combined.

This is why reduce() is useful for operations such as calculating a total.

6. What Is the Fork/Join Framework?

Now let's understand the framework that helps perform this kind of parallel processing.

Java provides the Fork/Join Framework in the java.util.concurrent package.

Its main idea is simple:

  1. Divide a large task into smaller tasks.
  2. Execute those smaller tasks.
  3. Combine their results to get the final answer.

Let's understand the name.

Fork: Divide a large task into smaller tasks.

Join: Wait for smaller tasks to finish and combine their results.

Imagine that you have a large pile of documents to count.

Instead of asking one person to count every document, you divide the pile among four people.

Each person counts their portion. Once everyone finishes, you add their counts together.

This is the basic idea of the Fork/Join Framework.

7. Understanding ForkJoinPool

The Fork/Join Framework provides a thread pool called ForkJoinPool.

This pool manages worker threads that execute Fork/Join tasks.

One interesting feature is called work stealing.

Let's understand it simply.

Imagine that three worker threads have tasks assigned to them.

One thread finishes its tasks earlier than the others. Instead of remaining idle, it can take an available task from another worker's queue.

This helps keep the worker threads busy and improves the use of available processing resources.

This mechanism is called work stealing.

A simplified view:

Worker 1: Task A, Task B, Task C
Worker 2: Task D
Worker 3: Task E, Task F

Worker 2 finishes Task D.

Worker 2 can steal an available task
from another worker's queue.

The actual scheduling and task distribution are managed by ForkJoinPool.

8. How Do Parallel Streams Use ForkJoinPool?

Here is an important connection between the two concepts.

Parallel streams commonly use the common ForkJoinPool to execute their parallel operations.

This means we do not normally need to create threads ourselves when using parallelStream().

Java handles the splitting of work and execution through its parallel processing infrastructure.

For example:

List<Integer> numbers =
        Arrays.asList(1, 2, 3, 4, 5);

numbers.parallelStream()
       .forEach(System.out::println);

We simply request a parallel stream. Java manages the parallel execution.

9. Important: Parallel Streams Do Not Always Run Faster

This is one of the most important things to understand.

Just because we use parallelStream() does not mean our application will automatically become faster.

Parallel processing also has some overhead:

For a very small collection, this overhead may be greater than the benefit of parallel execution.

Parallel streams are more useful when:

We should measure performance rather than assume that parallel execution is always better.

10. Be Careful With Shared Mutable Data

Now let's discuss an important problem.

Imagine that multiple threads are processing elements in parallel and all of them are updating the same shared variable.

This can create a race condition.

Consider this example:

import java.util.Arrays;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

public class UnsafeParallelExample {

    public static void main(String[] args) {

        List<Integer> numbers =
                Arrays.asList(1, 2, 3, 4, 5);

        int[] total = {0};

        numbers.parallelStream()
               .forEach(number -> {
                   total[0] += number;
               });

        System.out.println("Total: " + total[0]);
    }
}

This example is unsafe.

Multiple threads may try to update total[0] at the same time.

The operation:

total[0] += number;

involves reading the current value, adding a number, and writing the new value.

These steps are not automatically performed as one atomic operation.

As a result, some updates may be lost.

One way to solve this particular problem is to use reduce(), which is designed to combine partial results.

int total = numbers.parallelStream()
                   .reduce(0, Integer::sum);

System.out.println("Total: " + total);

This is a much better approach for calculating a sum.

Remember: avoid modifying shared mutable data from multiple threads unless you have a proper thread-safety strategy.

11. When Should We Use Parallel Streams?

Let's summarize when parallel streams may be useful.

Consider a reporting application that needs to calculate totals for a large collection of independent records.

Each record can be processed separately, and the individual results can be combined.

This is a good candidate for parallel processing.

On the other hand, if every operation depends on the result of the previous operation, parallel processing may not provide much benefit.

Also, be careful when using parallel streams for blocking operations such as waiting for network or database responses. The common ForkJoinPool is a shared resource, and blocking tasks can affect other work using it.

Choose parallel streams based on the nature of the work, and test their performance in realistic conditions.

12. Quick Revision

Test Your Understanding

Let's see how well you understood this class!

Question 1: Which method creates a parallel stream from a collection?

Question 2: What does the Fork operation mean in the Fork/Join Framework?

Question 3: What is work stealing in ForkJoinPool?

Question 4: Do parallel streams always make an application faster?

Question 5: What is a possible problem when multiple threads update the same ordinary shared variable?

Class Summary

Let's quickly revise what we learned today.

  • Normal streams process their pipeline sequentially.
  • Parallel streams can divide collection processing across multiple threads.
  • parallelStream() creates a parallel stream.
  • The Fork/Join Framework divides large tasks into smaller tasks and combines their results.
  • ForkJoinPool manages worker threads.
  • Work stealing helps keep worker threads busy.
  • Parallel streams commonly use the common ForkJoinPool.
  • Parallel streams are not automatically faster.
  • Shared mutable data must be handled carefully to avoid race conditions.

Remember: Parallel processing is useful when work can be divided into independent portions. Always choose it based on the problem and measure the actual performance.

Next Class: Virtual Threads in Java 21+

We have explored several ways to work with multiple threads, including thread pools, executors, CompletableFuture, and parallel streams.

Java has also introduced an interesting feature called Virtual Threads.

Virtual threads make it easier to write applications that handle a large number of concurrent tasks, especially when those tasks spend time waiting for operations such as network or database responses.

In the next class, we will understand what virtual threads are, how they differ from traditional platform threads, and how to create them using Java 21 and later.

See you in Class 42!

0% Completed

You have completed 41 out of 46 classes.