Java Fork/Join Framework and ForkJoinPool with Examples

Java Fork/Join framework with ForkJoinPool examples. RecursiveTask, RecursiveAction, work stealing, thresholds, errors and when to use executors instead.

ForkJoinPool worker deques with a task split into subtasks and an idle worker stealing from another deque

The Fork/Join framework splits a large task into smaller subtasks, runs them in parallel on the worker threads of a ForkJoinPool, and joins their results into one answer, while idle workers steal queued subtasks from busy ones. It has been part of java.util.concurrent since Java 7.

We use it for CPU-heavy work on large in-memory data that divides cleanly, such as summing or transforming big arrays, sorting, and walking trees. Parallel streams and Arrays.parallelSort() run on it too, so most Java developers use the framework without writing a task.

The following example totals one million daily step counts with a RecursiveTask that we define in section 3.

int[] steps = new int[1_000_000];
Arrays.setAll(steps, user -> user % 10_000);
long total = ForkJoinPool.commonPool().invoke(new StepTotal(steps, 0, steps.length));   // 4999500000
long check = Arrays.stream(steps).asLongStream().sum();                                 // 4999500000

We hand one task to the pool and get one number back, and the splitting happens inside the task. We cover the idea of fork and join, the core classes, a RecursiveTask and a RecursiveAction example, a folder scan from a real app, error handling, and when an ExecutorService or virtual threads fit better.

1. Fork/Join Framework

Fork/Join is the divide-and-conquer approach applied to threads. A task checks whether its piece of work is small enough to finish in one go. If it is not, the task splits the work, forks (that is, schedules) the parts as new subtasks, and joins (that is, waits for and collects) their results.

Result solve(Problem problem) {
    if (problem is small)
        directly solve problem
    else {
        split problem into independent parts
        fork new subtasks to solve each part
        join all subtasks
        compose result from subresults
    }
}

The framework makes this fast through work stealing. Each worker thread has its own double-ended queue (deque) of tasks. A worker pushes the subtasks it forks onto its own deque and takes the newest one back first, while an idle worker steals the oldest task from the other end of a busy worker’s deque.

ForkJoinPool worker deques with a task split into subtasks and an idle worker stealing from another deque
Each worker takes its newest subtask, and an idle worker steals the oldest, largest one from another deque

Stolen tasks are the old, large ones, so a thief gets a big piece of work and comes back rarely. A worker that waits in join() does not sit idle either, because it runs other queued tasks until the result it waits for is ready.

2. Classes of the Fork/Join Framework

The pool runs the tasks, and a subclass of ForkJoinTask describes the work and decides whether the task returns a result.

ClassRoleMethod to implement
ForkJoinPoolRuns tasks on worker threads with work stealingNone
RecursiveTask<V>A task that returns a result of type VV compute()
RecursiveActionA task without a result, for example one that changes an array in placevoid compute()
CountedCompleter<T>A task that triggers completion code when all its subtasks finish (Java 8)void compute()

Most code needs only RecursiveTask or RecursiveAction. The CountedCompleter class is used inside the JDK for parallel streams, and it suits tasks that should not block while waiting for children.

2.1. ForkJoinPool

The ForkJoinPool is an ExecutorService that implements work stealing. Every JVM has one shared common pool, returned by ForkJoinPool.commonPool(), whose parallelism is the number of available processors minus one, because the calling thread helps too. We create our own pool when the work must not compete with parallel streams and other users of the common pool.

ForkJoinPool pool = new ForkJoinPool(4);
int parallelism = pool.getParallelism();                                             // 4
boolean oneLess = ForkJoinPool.getCommonPoolParallelism() == Runtime.getRuntime().availableProcessors() - 1;   // true
boolean daemon = pool.submit(() -> Thread.currentThread().isDaemon()).get();         // true
pool.close();

Worker threads are daemon threads, so a running pool does not keep the JVM alive. A pool we create still needs close() (Java 19 and later) or shutdown() when we are done with it, whereas shutdown() and close() on the common pool have no effect. A task enters the pool in one of several ways, and the right one depends on whether the caller is inside a task.

MethodCalled fromWhat it does
pool.invoke(task)Outside the poolRuns the task and waits for its result
pool.submit(task)Outside the poolSchedules the task and returns it as a Future
pool.execute(task)Outside the poolSchedules the task and returns nothing
task.fork()Inside a taskPushes the subtask onto the current worker’s deque
task.join()Inside a taskWaits for the subtask’s result, running other tasks meanwhile
invokeAll(a, b)Inside a taskForks and joins several subtasks in one call

Since Java 25, ForkJoinPool also implements ScheduledExecutorService, so it can run delayed tasks with schedule(), and submitWithTimeout() cancels a task that runs too long. Delayed tasks are useful for light timers, but the core use of the pool is still divide and conquer.

ForkJoinPool timerPool = new ForkJoinPool(2);
ScheduledFuture<String> reminder = timerPool.schedule(() -> "time to stretch", 100, TimeUnit.MILLISECONDS);
String message = reminder.get();                                                     // "time to stretch"
timerPool.close();

3. RecursiveTask Example

A fitness app stores the step count of every user for one day, and a nightly report needs the total. The data is one large int array, and the sum of any part of the array is independent of the other parts, which makes it a clean fit for RecursiveTask<Long>.

The following example is the StepTotal task. It sums its range in a plain loop when the range has at most 10,000 elements, otherwise it splits the range in half.

class StepTotal extends RecursiveTask<Long> {
    private static final int THRESHOLD = 10_000;
    private final int[] steps;
    private final int from;
    private final int to;

    StepTotal(int[] steps, int from, int to) {
        this.steps = steps;
        this.from = from;
        this.to = to;
    }

    @Override
    protected Long compute() {
        if (to - from <= THRESHOLD) {
            long sum = 0;
            for (int i = from; i < to; i++) {
                sum += steps[i];
            }
            return sum;
        }
        int mid = (from + to) >>> 1;
        StepTotal left = new StepTotal(steps, from, mid);
        StepTotal right = new StepTotal(steps, mid, to);
        left.fork();                          // 1. queue the left half
        long rightSum = right.compute();      // 2. work on the right half in this thread
        return left.join() + rightSum;        // 3. wait for the left half
    }
}

The order in the last three lines matters. The task forks one half, computes the other half itself and joins last, so the current thread never waits while it still has work. Calling left.join() before right.compute() would make the two halves run one after the other.

int[] dailySteps = new int[1_000_000];
Arrays.setAll(dailySteps, user -> user % 10_000);
ForkJoinPool reportPool = new ForkJoinPool(4);
long allSteps = reportPool.invoke(new StepTotal(dailySteps, 0, dailySteps.length));   // 4999500000
long firstHalf = reportPool.invoke(new StepTotal(dailySteps, 0, 500_000));           // 2499750000
reportPool.close();

The threshold decides whether Fork/Join pays off, because every task has a scheduling cost. A threshold that is too small creates millions of tiny tasks, and one that is too large leaves cores idle. The ForkJoinTask Javadoc suggests that a task performs between 100 and 10,000 basic computational steps, and we measure with JMH before and after a change instead of trusting a single run.

4. RecursiveAction Example

A RecursiveAction fits work that changes data in place and returns nothing. Say a weather station service receives 200,000 temperature readings in Celsius and must convert them to Fahrenheit before storing them. Each element is independent, and every subtask writes only to its own range of the array, so no locking is needed.

class ToFahrenheit extends RecursiveAction {
    private static final int THRESHOLD = 10_000;
    private final double[] readings;
    private final int from;
    private final int to;

    ToFahrenheit(double[] readings, int from, int to) {
        this.readings = readings;
        this.from = from;
        this.to = to;
    }

    @Override
    protected void compute() {
        if (to - from <= THRESHOLD) {
            for (int i = from; i < to; i++) {
                readings[i] = readings[i] * 9 / 5 + 32;
            }
            return;
        }
        int mid = (from + to) >>> 1;
        invokeAll(new ToFahrenheit(readings, from, mid), new ToFahrenheit(readings, mid, to));
    }
}
double[] readings = new double[200_000];
Arrays.fill(readings, 25.0);
ForkJoinPool.commonPool().invoke(new ToFahrenheit(readings, 0, readings.length));
double first = readings[0];                                // 77.0
double last = readings[readings.length - 1];               // 77.0

Here invokeAll() replaces the fork, compute and join steps, and it returns when both halves are done. Because a RecursiveAction has no result, the caller reads the converted values from the array after invoke() returns.

5. Scanning a Folder Tree in Parallel

Recursive data structures split naturally, and a folder tree is the most common one in real apps. A log housekeeping job needs the total size of all .log files under a folder, and each subfolder becomes its own subtask. The following example forks one task per subfolder and adds the file sizes of its own folder while the subtasks run.

class LogFolderSize extends RecursiveTask<Long> {
    private final Path folder;

    LogFolderSize(Path folder) {
        this.folder = folder;
    }

    @Override
    protected Long compute() {
        List<LogFolderSize> subtasks = new ArrayList<>();
        long bytes = 0;
        try (DirectoryStream<Path> entries = Files.newDirectoryStream(folder)) {
            for (Path entry : entries) {
                if (Files.isDirectory(entry)) {
                    LogFolderSize task = new LogFolderSize(entry);
                    task.fork();
                    subtasks.add(task);
                } else if (entry.toString().endsWith(".log")) {
                    bytes += Files.size(entry);
                }
            }
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
        for (LogFolderSize task : subtasks) {
            bytes += task.join();
        }
        return bytes;
    }
}
Path root = Files.createTempDirectory("logs");
Files.createDirectories(root.resolve("orders/2026"));
Files.writeString(root.resolve("app.log"), "a".repeat(100));
Files.writeString(root.resolve("orders/orders.log"), "b".repeat(200));
Files.writeString(root.resolve("orders/2026/january.log"), "c".repeat(300));
Files.writeString(root.resolve("orders/readme.txt"), "not a log");
long logBytes = ForkJoinPool.commonPool().invoke(new LogFolderSize(root));      // 600

The task wraps the checked IOException in an UncheckedIOException, because compute() cannot throw checked exceptions. The scan is CPU-light and waits on the disk, which is fine for a local SSD. For folders on a network share, where each call can block for milliseconds, virtual threads are a better fit than the common pool.

6. Exceptions and Task Status

An unchecked exception thrown inside compute() does not get lost. The framework stores it in the task and rethrows the same exception type from invoke(), join() or get() in the caller, so a failing subtask fails the whole computation.

ForkJoinTask<Integer> parse = ForkJoinTask.adapt(() -> Integer.parseInt("abc"));
int parsed = ForkJoinPool.commonPool().invoke(parse);      // NumberFormatException
boolean failed = parse.isCompletedAbnormally();           // true
boolean ok = parse.isCompletedNormally();                 // false
boolean cancelled = parse.isCancelled();                  // false

The status methods help when we submit tasks and check them later. The method isDone() returns true for every finished task, isCompletedNormally() only for tasks without exception or cancellation, and getException() returns the stored exception or null. A checked exception thrown by a Callable passed to ForkJoinTask.adapt() arrives wrapped in a RuntimeException.

7. ForkJoinPool vs ExecutorService

Work stealing is the main difference between the Fork/Join framework and a classic thread pool. In a ThreadPoolExecutor, all threads take tasks from one shared queue, and a thread that waits for another task’s result is blocked. In a ForkJoinPool, each worker has its own deque, and a waiting worker runs other tasks.

AspectForkJoinPoolThreadPoolExecutorVirtual threads
Best forCPU-bound divide and conquerIndependent tasks of any kindBlocking I/O, many requests
QueuesOne deque per workerOne shared queueOne virtual thread per task
Waiting for a subtaskWorker runs other tasksThread is blockedCarrier thread is freed
Thread countAbout one per coreFixed or boundedMillions are fine
Used byParallel streams, Arrays.parallelSort()Most server code via ExecutorServiceExecutors.newVirtualThreadPerTaskExecutor()

The JDK itself builds on the framework in several places. Arrays.parallelSort() sorts large arrays with Fork/Join since Java 8, parallel streams split their source into ForkJoinTasks on the common pool, and the default scheduler of virtual threads is a separate ForkJoinPool in FIFO mode.

Blocking calls such as JDBC queries or HTTP requests do not belong in the common pool, because each blocked worker is one core less for every parallel stream in the app. If a task must block inside a ForkJoinPool, ForkJoinPool.managedBlock() lets the pool add a spare thread while it waits. The concurrency vs parallelism article explains why the two kinds of work need different tools.

8. Fork/Join Framework FAQs

Blocking work, shutdown and the size of the common pool separate Fork/Join code from code written for a plain executor.

8.1. When should we not use the Fork/Join framework?

We skip it when the data is small, when the work blocks on I/O, or when the parts depend on each other. For a few thousand elements, a plain loop is faster than any task setup, and for I/O-bound work, an ExecutorService or virtual threads fit better.

8.2. What is the difference between fork() and invokeAll()?

The fork() method schedules one subtask and returns at once, so we call join() later. The static invokeAll() forks all given tasks, runs one of them in the current thread and waits until all are done, which is the shortest correct form for two halves.

8.3. Do we need to shut down a ForkJoinPool?

A pool we create should be closed with close() or shutdown(), so its threads end and it rejects new work. The JVM can still exit without it because the workers are daemon threads, but an unclosed pool in a long-running server keeps its threads until they time out. The common pool cannot be shut down.

8.4. How many threads does the common pool use?

The common pool uses the number of available processors minus one as its parallelism, and at least one. The system property java.util.concurrent.ForkJoinPool.common.parallelism overrides it, and Java 19 added setParallelism() to change a pool at runtime.

8.5. Is ForkJoinPool faster than a parallel stream?

Not by itself, because a parallel stream runs on the same common pool. A hand-written task helps when the data is not a collection, such as a folder tree, or when we need our own pool, threshold or splitting rule.

9. Conclusion

The Fork/Join framework runs divide-and-conquer tasks in parallel. A RecursiveTask returns a result, a RecursiveAction changes data in place, and the ForkJoinPool keeps every core busy through work stealing. The fork-compute-join order and a sensible threshold decide whether a task is faster than a plain loop.

We use our own pool for isolated workloads, close it when done, and keep blocking I/O away from the common pool, where virtual threads or an ExecutorService fit better. The Java concurrency tutorial covers locks, executors and the other java.util.concurrent tools.

10. References

Happy Learning !!

Source Code on Github

Leave a Comment

  1. I didn’t like your active waiting, that simply check the status of all task. First, it’s active waiting. Second, in many cases, the number of tasks is arbitrary.

    I assume it’s just for the purpose of this example, but most answers found online for this problem are absolutely awful…

    So I created a wrapper around the ExecutorService for this purpose, and I’m sharing it here. Hopefully that will help someone else. It’s a basic wrapper, feel free to complete it for your needs.


    public class ExecutorServiceWrapper<T extends Task, E> {
    private ExecutorService pool;

    // Keep track of running tasks
    private ConcurrentLinkedQueue runningTasks;

    public ExecutorServiceWrapper() {
    this(Runtime.getRuntime().availableProcessors());
    }
    public ExecutorServiceWrapper(int nbThreads) {
    this.runningTasks = new ConcurrentLinkedQueue();
    this.pool = Executors.newFixedThreadPool(nbThreads);
    }

    public void onIdle(List results) {}

    public void execute(T task) {
    boolean wasIdle = this.runningTasks.isEmpty();
    this.runningTasks.add(task);

    // "Although ExecutorService defines several methods which take a Runnable,
    // you should generally limit yourself to using the execute method inherited
    // from Executor.
    // https://docs.oracle.com/javafx/2/api/javafx/concurrent/Task.html
    this.pool.execute(task);

    if (wasIdle) {
    Task waitForTerminationTask = new Task() {
    public Object call() {
    T task;
    List results = new ArrayList(ExecutorServiceWrapper.this.runningTasks.size());
    while ((task = ExecutorServiceWrapper.this.runningTasks.poll()) != null) {
    try {
    results.add(task.get()); // Wait for the task to complete
    } catch (InterruptedException e) {
    e.printStackTrace();
    } catch (ExecutionException e) {
    e.printStackTrace();
    }
    }
    ExecutorServiceWrapper.this.onIdle(results);
    return null;
    }
    };
    new Thread(waitForTerminationTask).start();
    }
    }

    // See: https://docs.oracle.com/javase/7/docs/api/java/util/concurrent/ExecutorService.html
    public void shutdown() {
    this.pool.shutdown(); // Disable new tasks from being submitted
    try {
    // Wait a while for existing tasks to terminate
    if (!this.pool.awaitTermination(60, TimeUnit.SECONDS)) {
    this.pool.shutdownNow(); // Cancel currently executing tasks
    // Wait a while for tasks to respond to being cancelled
    if (!this.pool.awaitTermination(60, TimeUnit.SECONDS)) {
    throw new IllegalStateException("Pool did not terminate");
    }
    }
    } catch (InterruptedException ie) {
    // (Re-)Cancel if current thread also interrupted
    this.pool.shutdownNow();
    // Preserve interrupt status
    Thread.currentThread().interrupt();
    }
    }
    }

    Usage:

    ExecutorServiceWrapper poolWrapper = new ExecutorServiceWrapper() {
    @Override
    public void onIdle(List results) {
    int total = 0;
    for (Integer result : results) {
    total += result;
    }
    System.out.println("Result: " + total);
    }
    };

    for (...) {
    poolWrapper.execute(new UrlCrawlerTask(...));
    }

    NOTE: UrlCrawlerTask is simply a class that extends “Task”

    Don’t forget to call poolWrapper.shutdown() when you are done with it.

  2. Hi Lokesh,

    “Where numberOfProcessors = Runtime.getRunTime().availableProcessors();”

    no of processors can be either multiple core inside a single chip processor or single core multiple chip processor

    • If hyper threading is enabled, then 4 cores will be capable of executing 8 threads at a time. So Runtime.getRuntime().availableProcessors will return 8. So number of processors is the ability of your processor architecture to execute the “number of threads at a time”.

Comments are closed.

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.