Introduction
An ExecutorService runs independent tasks well, but it gives you no help when one big task should split itself into smaller ones and combine their results. If a pool thread blocks waiting for subtasks, it sits idle while other work is queued, and a fixed pool can even deadlock.
The Fork/Join framework, added in Java 7 in java.util.concurrent, is built for this divide-and-conquer pattern. This article covers ForkJoinPool, RecursiveTask and RecursiveAction, the fork-compute-join idiom, and when the framework is the wrong choice.
It builds on ExecutorService in Java and Future in Java.
1. The Core Idea
1.1 Divide and Conquer
A Fork/Join task follows one pattern: if the work is small enough, solve it directly. Otherwise, split it into smaller subtasks, run them in parallel (fork), and combine their results (join).
1.2 Work Stealing
ForkJoinPool gives each worker thread its own double-ended queue of tasks. A worker takes tasks from its own queue, and a worker that runs out of work steals a task from the other end of another worker’s queue. This keeps all threads busy even when subtasks have uneven sizes, and it is why waiting on a subtask does not leave a thread idle: the worker can run other queued tasks while it waits.
2. The Building Blocks
| Class | Purpose |
|---|---|
ForkJoinPool | The ExecutorService that runs fork/join tasks with work stealing |
RecursiveTask<V> | A task that returns a result of type V |
RecursiveAction | A task that returns nothing |
Both task classes extend ForkJoinTask and require you to implement compute(). You create a pool with new ForkJoinPool(parallelism), or use the shared common pool returned by ForkJoinPool.commonPool(). Since Java 19, ForkJoinPool implements AutoCloseable, so a dedicated pool can be used in try-with-resources.
3. Returning a Result With RecursiveTask
The following task sums a range of a long[]. Ranges of 1,000 elements or fewer are summed directly:
class SumTask extends RecursiveTask<Long> {
private static final int THRESHOLD = 1_000;
static final AtomicInteger LEAVES = new AtomicInteger();
private final long[] numbers;
private final int from;
private final int to;
SumTask(long[] numbers, int from, int to) {
this.numbers = numbers;
this.from = from;
this.to = to;
}
@Override
protected Long compute() {
if (to - from <= THRESHOLD) {
LEAVES.incrementAndGet();
long sum = 0;
for (int i = from; i < to; i++) {
sum += numbers[i];
}
return sum;
}
int mid = (from + to) >>> 1;
SumTask left = new SumTask(numbers, from, mid);
SumTask right = new SumTask(numbers, mid, to);
left.fork(); // run the left half asynchronously
long rightResult = right.compute(); // compute the right half in this thread
long leftResult = left.join(); // wait for the left half
return leftResult + rightResult;
}
}
Fork one half and compute the other in the current thread. Forking both halves and then joining both would work, but it creates an extra task and leaves the current thread with nothing to do. Always call fork() before join(), and join in the right order so the result is combined correctly.
Submit the task to a pool with invoke(), which blocks until the result is ready:
long[] numbers = LongStream.rangeClosed(1, 10_000).toArray();
try (ForkJoinPool pool = new ForkJoinPool(4)) {
long sum = pool.invoke(new SumTask(numbers, 0, numbers.length));
System.out.println("Sum of 1..10000 = " + sum);
System.out.println("Leaf tasks = " + SumTask.LEAVES.get());
}
Sum of 1..10000 = 50005000
Leaf tasks = 16
Splitting 10,000 elements in half until a range is at most 1,000 gives 16 ranges of 625 elements each, so the result is the same on every run.
4. Side Effects With RecursiveAction
When the task works in place and has nothing to return, extend RecursiveAction. The following task doubles every element of the same long[] in place, using the same 1,000-element threshold. Instead of calling fork() and join() itself, it splits with invokeAll(task1, task2), which forks one task, runs the other in the current thread, and waits for both:
class DoubleTask extends RecursiveAction {
// fields and constructor as in SumTask
@Override
protected void compute() {
if (to - from <= THRESHOLD) {
for (int i = from; i < to; i++) {
numbers[i] *= 2;
}
return;
}
int mid = (from + to) >>> 1;
invokeAll(new DoubleTask(numbers, from, mid), new DoubleTask(numbers, mid, to));
}
}
Each subtask writes a separate range of the array, so no synchronization is needed. The next snippet runs it on the common pool:
ForkJoinPool.commonPool().invoke(new DoubleTask(numbers, 0, numbers.length));
System.out.println("First three doubled values = "
+ numbers[0] + ", " + numbers[1] + ", " + numbers[2]);
System.out.println("Sum of doubled values = " + LongStream.of(numbers).sum());
First three doubled values = 2, 4, 6
Sum of doubled values = 100010000
5. The Common Pool
ForkJoinPool.commonPool() is a shared pool that you never shut down. Its default parallelism is based on the number of available processors, so the thread count differs between machines. Parallel streams and CompletableFuture async methods without an explicit executor all use it.
You could pass your own executor everywhere, so why have a common pool at all? There are two reasons:
- A ready-made default. Parallel streams have no parameter for an executor, and many libraries run CPU work without asking you for one. The common pool gives them all somewhere to run with no setup. It is created on first use, sized to the machine, and never needs to be shut down.
- No oversubscription. If every library created its own pool sized to the number of cores, five libraries on a 4-core machine would compete with 20 threads for 4 CPUs, and context switching would slow everything down. One shared pool keeps CPU-bound work across the whole application close to the number of cores.
The trade-off is that the pool is shared, so a long-running or blocking task there slows down unrelated code. To see what that means, the following snippet occupies every common-pool worker with a slow remote call, simulated by a 2-second Thread.sleep(). It then submits a quick, unrelated task:
int workers = ForkJoinPool.commonPool().getParallelism();
for (int i = 0; i < workers; i++) {
CompletableFuture.runAsync(() -> callSlowService()); // blocks for 2 seconds
}
long start = System.nanoTime();
CompletableFuture.supplyAsync(() -> 21 * 2).join(); // unrelated quick task
System.out.println("Quick task waited " + secondsSince(start) + " s (blocking work on the common pool)");
Quick task waited 2 s (blocking work on the common pool)
The quick task needs almost no CPU, but every worker is stuck waiting on the slow calls, so it sits in the queue until one of them returns. In a real application, the slow calls and the delayed task can live in completely different parts of the code, which makes the slowdown hard to trace. The fix is to give blocking work its own executor:
try (ExecutorService ioPool = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < workers; i++) {
CompletableFuture.runAsync(() -> callSlowService(), ioPool);
}
start = System.nanoTime();
CompletableFuture.supplyAsync(() -> 21 * 2).join();
System.out.println("Quick task waited " + secondsSince(start) + " s (blocking work on its own executor)");
}
Quick task waited 0 s (blocking work on its own executor)
6. Best Practices
6.1 Choose a Sensible Threshold
A threshold that is too low creates more task objects than the work justifies. One that is too high leaves cores idle. Measure with your own data rather than copying a number.
6.2 Keep Tasks Free of Blocking and Shared State
Fork/Join is designed for CPU-bound, independent subtasks. Avoid blocking I/O, locks, and writes to shared mutable state inside compute(). For I/O-bound work, use a regular ExecutorService or virtual threads.
6.3 Avoid Overusing the Common Pool
Keep the common pool for short, CPU-bound tasks. Run heavy CPU-bound fork/join work in your own ForkJoinPool, and blocking work such as I/O in a regular ExecutorService or a virtual-thread executor, as shown in section 5. That way neither can starve parallel streams or async tasks elsewhere in the application.
6.4 Do Not Fork for Small Jobs
For small inputs, a plain loop beats the framework. Splitting has a cost, and it only pays off when the work per task is large enough.
Conclusion
- The Fork/Join framework splits a task recursively, runs the parts in parallel, and combines the results.
RecursiveTask<V>returns a value andRecursiveActiondoes not; both implementcompute().- Work stealing keeps workers busy when subtasks vary in size.
- Use the idiom
fork()one half,compute()the other, thenjoin(). - It suits CPU-bound work; keep blocking calls and shared mutable state out of tasks.
You can find the complete code of this article on GitHub
