You are currently viewing The Fork/Join Framework in Java

The Fork/Join Framework in Java

This entry is part 7 of 7 in the series Concurrency (Threads)

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

ClassPurpose
ForkJoinPoolThe ExecutorService that runs fork/join tasks with work stealing
RecursiveTask<V>A task that returns a result of type V
RecursiveActionA 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 and RecursiveAction does not; both implement compute().
  • Work stealing keeps workers busy when subtasks vary in size.
  • Use the idiom fork() one half, compute() the other, then join().
  • 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

Concurrency (Threads)

CompletableFuture in Java

Noel Kamphoa

Senior Software Engineer and Tech Lead with 14+ years of professional experience in backend development, system design, and enterprise software. Passionate about clean architecture, scalable Java applications, and helping developers grow through structured learning and real-world practice.