ForkJoinPool in Java
-
Last Updated: July 25, 2024
-
By: javahandson
-
Series

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.
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.
commonPool().execute(), invoke(), invokeAll() and submit().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.
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.
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?”
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.
Here are the steps every fork/join task follows:
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;
}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.
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.
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:
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:
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.
| Feature | ForkJoinPool | ThreadPoolExecutor (regular pool) |
|---|---|---|
| Best for | CPU-heavy, recursive, divide-and-conquer work | Independent tasks such as requests or jobs |
| Task queues | One deque per worker, plus shared submission queues | One shared queue for all threads |
| Load balancing | Work stealing between workers | Threads take the next task from the shared queue |
| Waiting on subtasks | A joining thread helps run other tasks | A waiting thread simply blocks |
| Task types | ForkJoinTask, Runnable, Callable | Runnable, Callable |
| Shared JDK instance | Yes, ForkJoinPool.commonPool() | No |
| Since | Java 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.
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.
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.
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.
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.
ForkJoinPool(int parallelism,
ForkJoinPool.ForkJoinWorkerThreadFactory factory,
Thread.UncaughtExceptionHandler handler,
boolean asyncMode)This constructor gives you the most control. Here is what each parameter does:
null to keep the default behaviour.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.
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:
shutdown() or close() on it have no effect.-Djava.util.concurrent.ForkJoinPool.common.parallelism=8.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.
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.
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.
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:
start (inclusive) to end (exclusive).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.
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.
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.
Every fork/join task has a threshold, the size below which it stops splitting. Picking it well makes a big difference.
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.
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.
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.
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
}
}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.
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.
<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.
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.
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.
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 ONWhen 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.
| Method | Blocks the caller? | Returns | Typical use |
|---|---|---|---|
execute(ForkJoinTask) | No | Nothing | Fire and forget, then join later |
execute(Runnable) | No | Nothing | Simple background work |
invoke(ForkJoinTask) | Yes | The result itself | Run one big task and get its answer |
invokeAll(Collection) | Yes | List of Futures | Run a batch of Callables together |
submit(ForkJoinTask) | No | ForkJoinTask handle | Start now, collect the result later |
submit(Runnable) | No | ForkJoinTask handle | Track when a Runnable finishes |
submit(Runnable, T) | No | ForkJoinTask with your value | Runnable 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.
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.
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.
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? trueOne 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.
ForkJoinPool can tell you a lot about what it is doing. It also needs a clean shutdown, just like any other ExecutorService.
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: trueA 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.
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.
You may already use ForkJoinPool without knowing it. Several JDK features run on top of it.
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.
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.
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.
ForkJoinPool is easy to get working and easy to get wrong. Here are the mistakes we see most often.
// 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.
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.
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.
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.
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.
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.
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.
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: 37Let us walk through it piece by piece:
>>> 1 shift halves the sum safely, even for very large indexes where (start + end) / 2 could overflow.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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
commonPool().invoke() blocks for the answer, submit() returns a handle, and execute() just starts the task.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.