Table of Contents

ForkJoinPool in Java

  • Last Updated: July 25, 2024
  • By: javahandson
  • Series
img

ForkJoinPool in Java

ForkJoinPool in Java is a special thread pool built for one job: splitting a big task into small pieces, running those pieces on all your CPU cores, and joining the results back together. In this article, we will learn how ForkJoinPool works, how work stealing keeps every thread busy, how to write tasks with RecursiveAction and RecursiveTask, and which method to call to submit them.

1. Introduction

Imagine you have to count every word in a 1,000-page book. You could read it alone, page by page. Or you could tear the book into ten parts, hand one part to each friend, and add up their counts at the end. The second way finishes much sooner.

That is the whole idea behind the fork/join framework. Java 7 added it to the java.util.concurrent package, and ForkJoinPool is the engine that runs it. Java 8 then gave us the shared common pool, which parallel streams use behind the scenes.

You do not need to be a concurrency expert to follow along. If you know what a thread is and have seen a thread pool before, you are ready.

1.1 What This Article Covers

  • What ForkJoinPool is and the divide-and-conquer idea behind it.
  • How it differs from a regular ExecutorService, including per-thread deques and work stealing.
  • The four ways to create a pool, including commonPool().
  • Writing tasks with RecursiveAction (no result) and RecursiveTask (with a result).
  • Every submit method: execute(), invoke(), invokeAll() and submit().
  • Exception handling, monitoring, and shutdown.
  • Common mistakes, a full walkthrough, and interview questions.

2. What Is ForkJoinPool in Java?

ForkJoinPool is a class in java.util.concurrent. It implements the ExecutorService interface, so at heart it is a thread pool. You hand it tasks, and its worker threads run them.

So why does Java need a second kind of pool? Because ForkJoinPool is tuned for a very particular shape of work. It shines when one task can break itself into smaller tasks of the same kind.

2.1 A Quick Recap of ExecutorService

A regular ExecutorService keeps a pool of threads and a queue of tasks. You submit a task, and the pool adds it to that queue. Whenever a thread becomes free, it picks the next task from the queue and runs it.

A task can be a Runnable or a Callable. If you submit a Callable, you get back a Future that will hold the result. We cover all of this in detail in ExecutorService in Java.

ForkJoinPool performs the same basic job. You still submit tasks, and threads still run them. The difference lies in how the tasks behave and how the threads share the work.

2.2 The Divide-and-Conquer Idea

Fork means split. Join means wait and combine. A fork/join task looks at its job and asks one question: “Am I small enough to just do this?”

  • If yes, it does the work directly. We call this the base case.
  • If no, it splits itself into two or more smaller tasks and forks them.
  • Each smaller task asks the same question, so the splitting repeats.
  • Finally, each parent joins its children and combines their results.

Merge sort, quicksort, summing a huge array, and walking a file tree all fit this pattern. Any recursive algorithm that splits a problem into independent halves is a good candidate.

2.3 The Fork/Join Steps

Here are the steps every fork/join task follows:

  • Split a task into smaller subtasks.
  • Solve each subtask on its own to produce a partial result.
  • Wait for all the subtasks to complete.
  • Join the partial results into one overall result.
  • Return that result to whoever asked for it.

In pseudocode, it reads like this:

Result solve(Problem problem) {
    if (problem is small enough) {
        return solve it directly;
    }
    split problem into two halves;
    fork both halves;
    join both halves;
    return combined result;
}

3. How ForkJoinPool Differs from ExecutorService

ForkJoinPool differs from a regular ExecutorService in two big ways. Its tasks create more tasks, and every worker thread gets its own queue. Together, these make work stealing possible.

3.1 Tasks Producing Subtasks

In a normal thread pool, a task is a closed box. It runs from start to finish and never adds new work to the pool. A web request handler is a good example.

Fork/join tasks behave differently. One task can produce dozens or even thousands of subtasks while it runs. ForkJoinPool is optimised for exactly this, which makes it a natural fit for recursive, divide-and-conquer algorithms.

Why does that matter? A parent task often has to wait for its children. In a normal pool, a waiting thread just sits there, blocked. ForkJoinPool lets the waiting thread run other pending subtasks instead, so the CPU never sits idle.

3.2 One Deque per Worker Thread

When you submit a task from outside the pool, it lands in a shared submission queue. A free worker thread picks it up. Say Thread1 picks Task1. Task1 then splits into subtasks, and Thread1 pushes those subtasks onto a deque that belongs only to Thread1.

A deque is a double-ended queue. You can add and remove items at both ends. Each worker thread in a ForkJoinPool owns exactly one deque.

This design brings three advantages:

  • Less traffic on shared queues. A thread keeps taking tasks from its own deque. It rarely needs to touch the shared submission queues.
  • Better cache use. One thread often runs a parent and its children, so the data it needs stays warm in that core’s cache.
  • Very little locking. Only the owner works on the busy end of its deque. Threads only compete when one steals from another, and the pool handles that with cheap atomic operations instead of locks.

3.3 Work Stealing

Work stealing lets an idle worker thread take tasks from the deque of a busier thread. It is the trick that keeps every core busy.

Let us walk through an example. We have two threads, Thread1 and Thread2. Thread1 works on Task1, and Thread2 works on Task2. Thread1 splits Task1 into 10 subtasks. Thread2 splits Task2 into only 5.

Thread2 finishes all 5 of its subtasks. Meanwhile, Thread1 has finished only 6, so 4 are still waiting. Thread2 first checks the shared submission queues for new work. When it finds none, it steals a subtask from Thread1’s deque.

Here is the clever part. The two threads work on opposite ends of the deque:

  • Thread1, the owner, pushes and pops at the top. It always takes the newest subtask first (last in, first out).
  • Thread2, the thief, takes from the bottom. It grabs the oldest subtask (first in, first out).
  • Because they work at opposite ends, the two threads rarely get in each other’s way.
  • The oldest subtask is usually the biggest one, since it was split off early. One steal therefore moves a large chunk of work.

Think of two cashiers at a supermarket. When one cashier’s line empties, they wave over the customer waiting at the back of the busy line. Nobody stands idle, and nobody bumps into the person being served at the front.

3.4 ForkJoinPool vs ExecutorService at a Glance

FeatureForkJoinPoolThreadPoolExecutor (regular pool)
Best forCPU-heavy, recursive, divide-and-conquer workIndependent tasks such as requests or jobs
Task queuesOne deque per worker, plus shared submission queuesOne shared queue for all threads
Load balancingWork stealing between workersThreads take the next task from the shared queue
Waiting on subtasksA joining thread helps run other tasksA waiting thread simply blocks
Task typesForkJoinTask, Runnable, CallableRunnable, Callable
Shared JDK instanceYes, ForkJoinPool.commonPool()No
SinceJava 7 (common pool in Java 8)Java 5

If you want to tune a regular pool’s threads, queue, and rejection policy, see ThreadPoolExecutor in Java.

4. Creating a ForkJoinPool

The ForkJoinPool class offers several constructors, plus a static method for the shared pool. Let us look at each one, from the simplest to the most flexible.

4.1 ForkJoinPool() with No Arguments

ForkJoinPool pool = new ForkJoinPool();
System.out.println(pool.getParallelism()); // Output: 12 (on a 12-core machine)

This is the simplest constructor. It takes no arguments and sets the parallelism to the number of processors that Runtime.getRuntime().availableProcessors() reports. On a 12-core laptop, you get a parallelism of 12.

4.2 ForkJoinPool(int parallelism)

ForkJoinPool pool = new ForkJoinPool(4);
System.out.println(pool.getParallelism()); // Output: 4

This constructor sets the parallelism level yourself. Parallelism means the target number of threads that actively run tasks at the same time. It does not count threads that sit waiting on a join.

What if you submit more tasks than there are active threads? The extra tasks wait in a queue until a thread frees up. A value of zero or less throws an IllegalArgumentException.

4.3 Unjoined Tasks and Extra Threads

Parallelism is a target, not a hard ceiling. The real number of threads can briefly go above it. Why would that happen?

Recall that forking splits work into smaller tasks, and joining waits for a forked task to finish. Sometimes a parent task sits waiting on a join while the forked child has not started yet. We call such a waiting child an unjoined task.

Normally, the waiting thread helps out by running pending tasks itself. Helping is not always possible, though. If every thread ends up blocked, the pool can stall. To prevent that, ForkJoinPool may start a spare “compensation” thread so the unjoined tasks still make progress.

Once that work drains, the extra threads go idle and eventually exit. The pool then settles back to its target parallelism, as long as you do not keep feeding it new tasks.

4.4 The Full Constructor

ForkJoinPool(int parallelism,
             ForkJoinPool.ForkJoinWorkerThreadFactory factory,
             Thread.UncaughtExceptionHandler handler,
             boolean asyncMode)

This constructor gives you the most control. Here is what each parameter does:

  • parallelism sets the target number of actively running threads, just like before.
  • factory creates the worker threads. ForkJoinPool ships with a default factory, but you can plug in your own, for example to give threads meaningful names.
  • handler runs when a worker thread is about to die from an uncaught exception. Pass null to keep the default behaviour.
  • asyncMode picks the order in which each worker runs its own local tasks.

The asyncMode flag deserves a closer look. With false, the default, each worker runs its newest task first (LIFO). That suits recursive tasks, because the newest task is the smallest and finishes fastest. With true, each worker runs its oldest task first (FIFO).

So when would you choose true? Pick it for event-style tasks that you fork but never join. FIFO order keeps those tasks flowing fairly. For classic divide-and-conquer code, keep the default.

4.5 The Shared Common Pool

ForkJoinPool common = ForkJoinPool.commonPool();
System.out.println(common.getParallelism()); // Output: 11 (on a 12-core machine)

The static commonPool() method returns one pool that the whole JVM shares. Java 8 introduced it. Notice the output above: 11, not 12. By default, the common pool uses one thread fewer than the number of processors, with a minimum of one.

Why one fewer? The thread that submits work to the common pool usually helps run it. Parallel streams, for instance, use the calling thread as an extra worker. Together that adds up to one thread per core.

Keep these facts about the common pool in mind:

  • Its worker threads are daemon threads, so they never keep the JVM alive.
  • Calls to shutdown() or close() on it have no effect.
  • You can change its size at startup with -Djava.util.concurrent.ForkJoinPool.common.parallelism=8.
  • Parallel streams and many CompletableFuture async methods run on it.

Since everyone shares it, the common pool suits small, short-lived, CPU-bound tasks. A slow or blocking task in it can slow down unrelated parts of your application. For long-running or blocking work, create your own ForkJoinPool instance.

5. Creating a Task

ForkJoinPool runs tasks that can split themselves into smaller tasks. Java gives us two ready-made classes for writing such recursive tasks: RecursiveAction and RecursiveTask.

5.1 ForkJoinTask, the Parent Class

Both classes extend the abstract class ForkJoinTask. A ForkJoinTask is a lightweight cousin of a thread. Thousands of them can run on a handful of real threads.

ForkJoinTask provides the key methods you will use inside a task:

  • fork() schedules the task to run asynchronously in the pool.
  • join() waits for the task to finish and returns its result.
  • invokeAll(t1, t2) forks and joins several tasks in one call.
  • compute() holds your actual logic. You override it in RecursiveAction and RecursiveTask.

Java 8 also added a third subclass, CountedCompleter. It suits trickier completion-driven work, and most code never needs it. We will stick to the two common ones.

5.2 RecursiveAction: Tasks Without a Result

RecursiveAction is an abstract class for tasks that do not return a value. You extend it and put your logic in the compute() method, which returns void. These tasks usually change data in place.

Let us write a RecursiveAction that converts every character in a character array to uppercase.

import java.util.concurrent.RecursiveAction;

class ToUpperCaseAction extends RecursiveAction {
    private final char[] data;
    private final int start;
    private final int end;
    private static final int THRESHOLD = 10;

    ToUpperCaseAction(char[] data, int start, int end) {
        this.data = data;
        this.start = start;
        this.end = end;
    }

    @Override
    protected void compute() {
        if (end - start <= THRESHOLD) {
            for (int i = start; i < end; i++) {
                data[i] = Character.toUpperCase(data[i]);
            }
        } else {
            int mid = (start + end) / 2;
            ToUpperCaseAction left = new ToUpperCaseAction(data, start, mid);
            ToUpperCaseAction right = new ToUpperCaseAction(data, mid, end);
            invokeAll(left, right);
        }
    }
}

Here is what happens, step by step:

  • Each task covers a slice of the array, from start (inclusive) to end (exclusive).
  • If the slice holds 10 characters or fewer, the task converts them directly. That is the base case.
  • Otherwise, it finds the middle and creates two new tasks, one per half.
  • invokeAll(left, right) runs both halves and waits for them to finish.

Notice that the two halves never overlap. Each task writes only to its own slice, so the tasks never step on each other and we need no locks.

5.3 How invokeAll() Works

The static invokeAll() method lives in ForkJoinTask. It runs several ForkJoinTask instances in parallel and returns only when all of them have completed.

For two tasks, it forks one, runs the other right away in the current thread, and then joins the forked one. In our example, invokeAll(left, right) runs the left and right halves at the same time.

Why not fork both halves? Then the current thread would have nothing to do but wait. Running one half yourself puts that thread to work. We will see this pattern spelled out by hand in section 5.6.

5.4 RecursiveTask: Tasks That Return a Result

RecursiveTask is the sibling of RecursiveAction. It is also abstract, but its compute() method returns a value. The generic type RecursiveTask<V> tells Java what type that value has.

Let us write a RecursiveTask that adds up the numbers in a range, such as the first 10,000 numbers.

import java.util.concurrent.RecursiveTask;

class SumTask extends RecursiveTask<Long> {
    private final int start;
    private final int end;
    private static final int THRESHOLD = 500;

    SumTask(int start, int end) {
        this.start = start;
        this.end = end;
    }

    @Override
    protected Long compute() {
        if (end - start <= THRESHOLD) {
            long sum = 0;
            for (int i = start; i < end; i++) {
                sum += i;
            }
            return sum;
        } else {
            int mid = (start + end) / 2;
            SumTask left = new SumTask(start, mid);
            SumTask right = new SumTask(mid, end);
            invokeAll(left, right);
            return left.join() + right.join();
        }
    }
}

SumTask follows the same shape as before. If the range holds 500 numbers or fewer, it adds them in a plain loop. Otherwise, it splits the range in two, runs both halves, and adds their results. Since invokeAll() has already finished both halves, join() simply returns each stored result.

Now let us run it. The main method creates a SumTask for the range 0 to 10,000, runs it in a ForkJoinPool, and prints the result.

import java.util.concurrent.ForkJoinPool;

public class SumDemo {
    public static void main(String[] args) {
        ForkJoinPool pool = new ForkJoinPool();

        long sum = pool.invoke(new SumTask(0, 10000));
        System.out.println("Sum: " + sum); // Output: Sum: 49995000

        long sumOneToTenThousand = pool.invoke(new SumTask(1, 10001));
        System.out.println("Sum: " + sumOneToTenThousand); // Output: Sum: 50005000
    }
}

Watch the range boundaries here. Because end is exclusive, SumTask(0, 10000) adds 0 through 9,999. To add 1 through 10,000, pass SumTask(1, 10001). The second result, 50,005,000, matches the classic formula n(n+1)/2.

5.5 Choosing a Threshold

Every fork/join task has a threshold, the size below which it stops splitting. Picking it well makes a big difference.

  • Too small, and you create millions of tiny tasks. Creating and scheduling them costs more than the work they do.
  • Too large, and you end up with only a few tasks. Some cores sit idle, and work stealing has nothing to steal.
  • About right, and each task does a meaningful chunk of work while the pool still has plenty of tasks to balance.

The ForkJoinTask documentation offers a rough rule of thumb. A task should perform more than 100 and fewer than 10,000 basic computational steps. Our thresholds of 10 and 500 keep the examples easy to follow. For real workloads, measure with your actual data and adjust.

5.6 The fork(), compute(), join() Pattern

You will often see a second way to write the split step. Instead of invokeAll(), the code forks one half, computes the other half directly, and then joins. Here is SumTask rewritten that way:

int mid = (start + end) / 2;
SumTask left = new SumTask(start, mid);
SumTask right = new SumTask(mid, end);

left.fork();                      // 1. push the left half onto our deque
long rightSum = right.compute();  // 2. work on the right half in this thread
long leftSum = left.join();       // 3. wait for (or run) the left half
return leftSum + rightSum;

This does the same thing that invokeAll(left, right) does for us. The order matters, though. Fork first, compute second, join last. If you call left.fork() and then left.join() straight away, the current thread just waits, and you lose the parallelism.

Which style should you choose? Use invokeAll() when you want short, safe code. Reach for fork/compute/join when you want to see each step spelled out. Both give the same result.

6. Submitting and Executing Tasks

Writing a task is only half the job. You also need to hand it to the pool. ForkJoinPool offers several methods for this, and each one behaves a little differently.

6.1 void execute(ForkJoinTask task)

This method submits the task to the pool and starts it running. It returns nothing, not even a Future. A worker thread runs the task asynchronously while your code moves on.

Since execute() returns right away, you need another way to wait. In the example below, we call join() on the task itself.

import java.util.concurrent.ForkJoinPool;

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

        ForkJoinPool pool = new ForkJoinPool();
        char[] data = "Hello Java Hands On".toCharArray();

        ToUpperCaseAction action = new ToUpperCaseAction(data, 0, data.length);
        pool.execute(action);

        action.join(); // Wait for the task to finish

        System.out.println(data); // Output: HELLO JAVA HANDS ON
    }
}

6.2 void execute(Runnable task)

This works like the method above, except it takes a plain Runnable. The pool runs it as one unit of work. A Runnable never splits into subtasks, so it gets none of the fork/join benefits.

This version returns no Future either. You cannot use it to check the status or result of the task. It comes from the ExecutorService interface, and we explain it further in ExecutorService in Java.

6.3 <T> T invoke(ForkJoinTask<T> task)

This method submits the task and waits for the result. In other words, invoke() is a blocking call. The generic signature means it accepts a task that produces any result type.

Unlike submit(), it does not return a Future. It returns the result of the computation directly. For a RecursiveAction, that result is simply null.

import java.util.concurrent.ForkJoinPool;

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

        ForkJoinPool pool = new ForkJoinPool();
        char[] data = "Hello Java Hands On".toCharArray();

        ToUpperCaseAction action = new ToUpperCaseAction(data, 0, data.length);
        pool.invoke(action); // Blocks until the task is done

        System.out.println(data); // Output: HELLO JAVA HANDS ON
    }
}

Most fork/join programs start with invoke(). You have one big task, and you want its answer before going further. That is exactly what this method gives you.

6.4 invokeAll(Collection<? extends Callable<T>> tasks)

<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks)

This method takes a collection of Callable tasks, starts them all, and returns a list of Future objects. Each Future lets you check whether its task finished and fetch its result.

Do not confuse it with the static ForkJoinTask.invokeAll() from section 5.3. This one is an instance method on the pool, and it comes from ExecutorService. It is also a blocking call. It returns the list only after every task has completed.

For more on Callable and Future, read Callable and Future in Java.

6.5 <T> ForkJoinTask<T> submit(ForkJoinTask<T> task)

This method submits a ForkJoinTask to the pool and returns a ForkJoinTask handle. That return value is the big difference from execute(). You can use the handle to wait for the task, check its status, or read its result.

import java.util.concurrent.ExecutionException;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinTask;

public class Demo {
    public static void main(String[] args) throws ExecutionException, InterruptedException {

        ForkJoinPool pool = new ForkJoinPool();
        char[] data = "Hello Java Hands On".toCharArray();

        ToUpperCaseAction action = new ToUpperCaseAction(data, 0, data.length);
        ForkJoinTask<?> future = pool.submit(action);

        future.get(); // This will block until the task is done

        System.out.println(data); // Output: HELLO JAVA HANDS ON
    }
}

ForkJoinTask implements Future, so get() works just as it does on any Future. It throws checked exceptions, which is why main declares ExecutionException and InterruptedException.

6.6 ForkJoinTask<?> submit(Runnable task)

This method accepts a Runnable, submits it to the pool, and returns a ForkJoinTask. A Runnable produces no result, so the handle is mainly useful for checking status, such as whether the task has finished yet.

import java.util.concurrent.ExecutionException;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinTask;

public class Demo {
    public static void main(String[] args) throws ExecutionException, InterruptedException {

        ForkJoinPool pool = new ForkJoinPool();
        char[] data = "Hello Java Hands On".toCharArray();

        Runnable task = () -> {
            for (int i = 0; i < data.length; i++) {
                data[i] = Character.toUpperCase(data[i]);
            }
        };

        ForkJoinTask<?> future = pool.submit(task);
        future.get(); // This will block until the task is done

        System.out.println(data); // Output: HELLO JAVA HANDS ON
    }
}

Here a single worker thread converts the whole array in one loop. Nothing gets split. For a 19-character string that is perfectly fine, but a huge array would gain nothing from the pool.

6.7 <T> ForkJoinTask<T> submit(Runnable task, T result)

This method also submits a Runnable and returns a ForkJoinTask. The extra argument is a value that the handle hands back once the task finishes. Since a Runnable cannot return anything, get() returns the value you passed in.

import java.util.concurrent.ExecutionException;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinTask;

public class Demo {
    public static void main(String[] args) throws ExecutionException, InterruptedException {

        ForkJoinPool pool = new ForkJoinPool();
        char[] data = "Hello Java Hands On".toCharArray();

        Runnable task = () -> {
            for (int i = 0; i < data.length; i++) {
                data[i] = Character.toUpperCase(data[i]);
            }
        };

        String result = "Task is Completed!";
        ForkJoinTask<String> future = pool.submit(task, result);

        String futureResult = future.get(); // This will block until the task is done
        System.out.println("Future Result: " + futureResult);

        System.out.println(data); // Print the modified data
    }
}
// Output:
// Future Result: Task is Completed!
// HELLO JAVA HANDS ON

When is this handy? It lets a Runnable plug into code that expects a Future with a value, such as a status message or a reference to the object the task updated.

6.8 Which Method Should You Use?

MethodBlocks the caller?ReturnsTypical use
execute(ForkJoinTask)NoNothingFire and forget, then join later
execute(Runnable)NoNothingSimple background work
invoke(ForkJoinTask)YesThe result itselfRun one big task and get its answer
invokeAll(Collection)YesList of FuturesRun a batch of Callables together
submit(ForkJoinTask)NoForkJoinTask handleStart now, collect the result later
submit(Runnable)NoForkJoinTask handleTrack when a Runnable finishes
submit(Runnable, T)NoForkJoinTask with your valueRunnable that must yield a value

A simple rule of thumb: call invoke() from outside the pool when you need the answer now. Inside a task, use fork(), join() and invokeAll() instead. Calling pool.invoke() from inside a running task works, but it hides the parent-child link that makes fork/join efficient.

7. Handling Exceptions in Fork/Join Tasks

What happens when a subtask throws an exception deep inside the recursion? The pool catches it and stores it in the task. The exception then travels up to whoever waits on that task.

7.1 join() and invoke() versus get()

How you see the exception depends on how you wait:

  • join() and invoke() rethrow the original unchecked exception, such as an IllegalArgumentException.
  • get() wraps that exception in a checked ExecutionException. Call getCause() to reach the original.
  • isCompletedAbnormally() tells you whether a task ended with an exception or was cancelled.
  • getException() returns the stored exception, or null if there was none.

Because compute() cannot declare checked exceptions, you must wrap any checked exception, such as an IOException, in an unchecked one.

7.2 An Exception Example

This task adds up an array but refuses negative numbers. We run it twice to show both styles of failure.

import java.util.concurrent.ExecutionException;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinTask;
import java.util.concurrent.RecursiveTask;

class PositiveSumTask extends RecursiveTask<Long> {
    private final int[] numbers;
    private final int start;
    private final int end;

    PositiveSumTask(int[] numbers, int start, int end) {
        this.numbers = numbers;
        this.start = start;
        this.end = end;
    }

    @Override
    protected Long compute() {
        if (end - start <= 2) {
            long sum = 0;
            for (int i = start; i < end; i++) {
                if (numbers[i] < 0) {
                    throw new IllegalArgumentException("Negative value at index " + i);
                }
                sum += numbers[i];
            }
            return sum;
        }
        int mid = (start + end) / 2;
        PositiveSumTask left = new PositiveSumTask(numbers, start, mid);
        PositiveSumTask right = new PositiveSumTask(numbers, mid, end);
        invokeAll(left, right);
        return left.join() + right.join();
    }
}

public class ExceptionDemo {
    public static void main(String[] args) throws InterruptedException {
        int[] numbers = {4, 8, 15, -16, 23, 42};
        ForkJoinPool pool = new ForkJoinPool();

        // Style 1: invoke() and join() rethrow the unchecked exception
        try {
            pool.invoke(new PositiveSumTask(numbers, 0, numbers.length));
        } catch (IllegalArgumentException e) {
            System.out.println("invoke() failed: " + e.getClass().getSimpleName());
        }

        // Style 2: get() wraps it in an ExecutionException
        ForkJoinTask<Long> task = pool.submit(new PositiveSumTask(numbers, 0, numbers.length));
        try {
            task.get();
        } catch (ExecutionException e) {
            System.out.println("get() failed, cause: " + e.getCause().getClass().getSimpleName());
        }
        System.out.println("Completed abnormally? " + task.isCompletedAbnormally());

        pool.shutdown();
    }
}
// Output:
// invoke() failed: IllegalArgumentException
// get() failed, cause: IllegalArgumentException
// Completed abnormally? true

One detail may surprise you. If you print the exception message from invoke(), you may see the class name twice. When a task fails on another thread, the pool often rethrows a fresh copy of the exception and attaches the original as its cause. That copy keeps the stack trace of the waiting thread, which helps when you debug.

8. Monitoring and Shutting Down the Pool

ForkJoinPool can tell you a lot about what it is doing. It also needs a clean shutdown, just like any other ExecutorService.

8.1 Useful Monitoring Methods

  • getParallelism() returns the target parallelism level.
  • getPoolSize() returns how many worker threads currently exist, including idle ones.
  • getActiveThreadCount() estimates how many threads are busy right now.
  • getStealCount() estimates how many tasks threads have stolen from each other.
  • getQueuedTaskCount() estimates how many tasks wait in worker deques.

Most of these return estimates, not exact figures. The pool changes too quickly for an exact snapshot. Use them for logging and tuning, not for program logic.

import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.TimeUnit;

public class MonitorDemo {
    public static void main(String[] args) throws InterruptedException {
        ForkJoinPool pool = new ForkJoinPool(4);

        long sum = pool.invoke(new SumTask(0, 1_000_000));
        System.out.println("Sum:         " + sum);
        System.out.println("Parallelism: " + pool.getParallelism());
        System.out.println("Pool size:   " + pool.getPoolSize());
        System.out.println("Steal count: " + pool.getStealCount());

        pool.shutdown();
        boolean done = pool.awaitTermination(5, TimeUnit.SECONDS);
        System.out.println("Terminated:  " + done);
    }
}
// Output (pool size and steal count vary from run to run):
// Sum:         499999500000
// Parallelism: 4
// Pool size:   4
// Steal count: 6
// Terminated:  true

A non-zero steal count proves that work stealing really happened. Idle threads went looking for work and found it in a busier thread’s deque.

8.2 Shutting Down Cleanly

Your own ForkJoinPool instances should always be shut down when you finish with them. Here are your options:

  • shutdown() stops new submissions but lets queued tasks finish.
  • shutdownNow() also tries to cancel tasks that are still running or queued.
  • awaitTermination(timeout, unit) waits until the pool has finished, or the timeout runs out.
  • close(), added in Java 19, shuts down the pool and waits for it. That makes ForkJoinPool usable in try-with-resources.

The common pool is the exception. It ignores all of these calls, because the whole JVM shares it. Its threads are daemon threads, so they never stop your program from exiting.

9. Where the JDK Uses ForkJoinPool

You may already use ForkJoinPool without knowing it. Several JDK features run on top of it.

9.1 Parallel Streams

When you call parallelStream() or .parallel(), the stream splits its data and processes the pieces in the common pool. The calling thread joins in as an extra worker. That is the same fork/join pattern we wrote by hand, hidden behind a friendly API. See Parallel Stream in Java 8 for the details.

9.2 CompletableFuture

Methods such as CompletableFuture.supplyAsync(supplier) need a thread to run on. If you do not pass your own executor, they use the common pool. There is one exception: if the common pool’s parallelism is below 2, CompletableFuture starts a new thread for each task instead.

Always pass your own executor for blocking calls such as database or HTTP requests. Otherwise, those calls can tie up the shared pool that parallel streams also need.

9.3 Virtual Threads

Virtual threads, final since Java 21, also rely on a ForkJoinPool. The JDK schedules them on a dedicated ForkJoinPool that runs in FIFO mode. This is a separate pool, not the common pool, so heavy parallel streams do not starve your virtual threads.

For a modern way to coordinate many concurrent subtasks, read Structured Concurrency in Java 25.

10. Common Mistakes and Pitfalls

ForkJoinPool is easy to get working and easy to get wrong. Here are the mistakes we see most often.

10.1 Joining Right After Forking

// Wrong: runs the halves one after the other
left.fork();
long leftSum = left.join();
right.fork();
long rightSum = right.join();

// Right: fork one, compute the other, then join
left.fork();
long rightSum = right.compute();
long leftSum = left.join();

In the wrong version, the thread forks the left half and then waits for it at once. Only after that does it start the right half. The code looks parallel but runs almost sequentially.

10.2 Blocking Inside a Task

ForkJoinPool expects CPU-bound work. If a task sleeps, waits on a lock, or reads from a network socket, its thread sits idle. The pool has no idea that it should start another thread to make up for it.

Do I/O and other blocking calls in a regular thread pool or on virtual threads instead. If you truly must block inside a fork/join task, look at ForkJoinPool.ManagedBlocker. It tells the pool you are about to block so it can add a spare thread.

10.3 Overloading the Common Pool

Every parallel stream and every default CompletableFuture shares the common pool. One slow task there can delay code in a completely different part of your application. When work is long or slow, give it its own pool.

10.4 A Poor Threshold

Splitting all the way down to one element creates huge overhead. Barely splitting at all leaves cores idle. Start with a threshold that gives each task a few thousand steps of work, then measure and adjust.

10.5 Sharing Mutable State Between Subtasks

Our ToUpperCaseAction is safe because each task writes only to its own slice of the array. Trouble starts when subtasks update the same variable, such as a shared counter. Then you get race conditions.

The fix is simple: have each subtask return its own partial result and combine them in the parent, just as SumTask does. If you truly need shared state, read Thread Safety in Java and Atomic Variables in Java.

10.6 Using ForkJoinPool for Tiny Jobs

Splitting work, scheduling tasks, and combining results all cost time. For a small array, a plain loop wins every time. ForkJoinPool pays off only when the total work is large enough to cover that overhead. Measure before you assume it is faster.

11. Practical Walkthrough: Finding the Highest Reading

Let us tie everything together in one small program. Say a weather station has recorded five million sensor readings, and we want the highest one. We will use a RecursiveTask with the fork/compute/join pattern, run it in our own pool, and check the answer against a plain loop.

11.1 The Complete Program

import java.util.Random;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveTask;

public class MaxReading {

    static class MaxTask extends RecursiveTask<Integer> {
        private static final int THRESHOLD = 10_000;
        private final int[] readings;
        private final int start;
        private final int end;

        MaxTask(int[] readings, int start, int end) {
            this.readings = readings;
            this.start = start;
            this.end = end;
        }

        @Override
        protected Integer compute() {
            if (end - start <= THRESHOLD) {
                int max = Integer.MIN_VALUE;
                for (int i = start; i < end; i++) {
                    max = Math.max(max, readings[i]);
                }
                return max;
            }
            int mid = (start + end) >>> 1;
            MaxTask left = new MaxTask(readings, start, mid);
            MaxTask right = new MaxTask(readings, mid, end);
            left.fork();                        // hand the left half to the pool
            int rightMax = right.compute();     // do the right half ourselves
            int leftMax = left.join();          // collect the left half
            return Math.max(leftMax, rightMax);
        }
    }

    public static void main(String[] args) {
        int[] readings = new int[5_000_000];
        Random random = new Random(42);
        for (int i = 0; i < readings.length; i++) {
            readings[i] = random.nextInt(1_000_000);
        }
        readings[3_141_592] = 1_234_567;   // plant a known maximum

        int sequentialMax = Integer.MIN_VALUE;
        for (int r : readings) {
            sequentialMax = Math.max(sequentialMax, r);
        }

        try (ForkJoinPool pool = new ForkJoinPool()) {
            int parallelMax = pool.invoke(new MaxTask(readings, 0, readings.length));
            System.out.println("Sequential max: " + sequentialMax);
            System.out.println("Parallel max:   " + parallelMax);
            System.out.println("Results match:  " + (sequentialMax == parallelMax));
            System.out.println("Steals so far:  " + pool.getStealCount());
        }
    }
}
// Output (the steal count varies from run to run):
// Sequential max: 1234567
// Parallel max:   1234567
// Results match:  true
// Steals so far:  37

11.2 How the Program Works

Let us walk through it piece by piece:

  • The data. We fill an array with five million random readings. The fixed seed of 42 makes every run produce the same numbers. We also plant a known maximum, 1,234,567, so we can check the answer at a glance.
  • The base case. Any slice of 10,000 readings or fewer gets scanned with a simple loop.
  • The split. Bigger slices split at the midpoint. The >>> 1 shift halves the sum safely, even for very large indexes where (start + end) / 2 could overflow.
  • Fork, compute, join. The left half goes to the pool, the current thread handles the right half, and then it joins the left half.
  • The answer. Each parent returns the larger of its two children’s results, so the true maximum bubbles up to the top.

11.3 What to Notice

The try-with-resources block closes the pool for us, thanks to close() from Java 19. On older versions, call shutdown() in a finally block instead.

The steal count shows that idle workers really did take work from busy ones. Your number will differ, because thread scheduling changes from run to run. The maximum itself never changes, since every task works on its own slice and returns its own result.

Want to experiment? Change the threshold to 100, then to 1,000,000, and time each run. You will see the overhead of tiny tasks at one end and idle cores at the other.

12. Interview Questions

Q: What is ForkJoinPool in Java?

A: ForkJoinPool is an ExecutorService from java.util.concurrent, added in Java 7. It runs tasks that split themselves into smaller subtasks, then combines their results. Each worker thread has its own deque, and idle threads steal work from busy ones.

Q: What is work stealing?

A: Work stealing lets an idle worker take tasks from another worker’s deque. The owner takes its newest task from the top, while the thief takes the oldest task from the bottom. That keeps all cores busy with very little contention.

Q: What is the difference between RecursiveAction and RecursiveTask?

A: Both extend ForkJoinTask, and you override compute() in each. RecursiveAction returns no value, so its compute() returns void. RecursiveTask<V> returns a value of type V, such as the sum of a range.

Q: How is ForkJoinPool different from a regular ExecutorService?

A: A regular pool uses one shared queue, and its tasks rarely create new tasks. ForkJoinPool gives each worker its own deque, expects tasks to fork subtasks, and balances the load through work stealing. A joining thread also helps run other tasks instead of just blocking.

Q: What is the default parallelism of the common pool?

A: It equals the number of available processors minus one, with a minimum of one. The calling thread usually joins in as an extra worker. You can change it with the java.util.concurrent.ForkJoinPool.common.parallelism system property.

Q: What is the difference between invoke(), submit() and execute()?

A: invoke() blocks and returns the result directly. submit() returns a ForkJoinTask handle right away, so you can fetch the result later. execute() returns nothing at all and simply starts the task.

Q: Why should you fork one subtask and compute the other directly?

A: If you fork both halves, the current thread has nothing to do but wait. Computing one half yourself keeps that thread busy and saves a scheduling step. ForkJoinTask.invokeAll() follows this same pattern internally.

Q: What does the asyncMode parameter do?

A: It sets the order in which each worker runs its own local tasks. The default, false, runs the newest task first (LIFO), which suits recursive work. Setting it to true runs the oldest task first (FIFO), which suits event-style tasks that you never join.

Q: Why should you avoid blocking calls inside fork/join tasks?

A: The pool sizes itself for CPU-bound work. A blocked thread does nothing, and the pool cannot tell it needs a replacement. Run blocking work elsewhere, or wrap it in a ForkJoinPool.ManagedBlocker so the pool can add a spare thread.

Q: How do exceptions from subtasks reach the caller?

A: The pool stores the exception in the failed task. join() and invoke() rethrow it as an unchecked exception. get() wraps it in an ExecutionException, and isCompletedAbnormally() returns true.

Q: Which JDK features use ForkJoinPool?

A: Parallel streams run on the common pool. CompletableFuture async methods use it when you pass no executor. Virtual threads run on a separate, dedicated ForkJoinPool in FIFO mode.

Q: Do you need to shut down the common pool?

A: No. The common pool ignores shutdown() and close(), and its threads are daemon threads, so they never keep the JVM alive. You only need to shut down ForkJoinPool instances that you create yourself.

13. Conclusion

Let us wrap up what we covered. ForkJoinPool in Java is a thread pool built for divide-and-conquer work. A task splits itself until the pieces are small, the pool runs those pieces on every core, and the results join back into one answer.

  • Each worker owns a deque, and idle workers steal the oldest tasks from busy ones.
  • You can create a pool with default or custom parallelism, or use the shared commonPool().
  • RecursiveAction suits tasks with no result, and RecursiveTask suits tasks that return one.
  • invoke() blocks for the answer, submit() returns a handle, and execute() just starts the task.
  • A sensible threshold and the fork/compute/join order make a real difference to speed.
  • Keep blocking work out of the pool, and give slow work its own pool instead of the common one.

Next time you face a big, splittable, CPU-heavy problem, try a RecursiveTask. Start with the walkthrough program, swap in your own logic, and measure the result.

14. Further Reading

Leave a Comment