🚀AdvancedAdvanced Concurrency

ForkJoin Framework

Divide and conquer large tasks in parallel using ForkJoinPool, RecursiveTask, and parallel streams.

Overview

The ForkJoin framework is designed for divide-and-conquer parallelism: split a large problem into smaller subproblems, solve them in parallel, and merge the results. It uses work-stealing so idle threads steal tasks from busy threads, maximizing CPU utilization. You write tasks by extending RecursiveTask<V> (returns a value) or RecursiveAction (no return value). Inside compute(), you check if the task is small enough to solve directly; if not, you fork subtasks and join their results. The common ForkJoinPool (ForkJoinPool.commonPool()) is also the pool behind parallel streams and CompletableFuture.supplyAsync(), so understanding it helps you reason about all Java parallelism.

Key Points

  • •ForkJoinPool.commonPool() is the shared pool used by parallel streams and default async tasks
  • •RecursiveTask<V>: override compute() to return a value
  • •RecursiveAction: override compute() for side-effect tasks with no return value
  • •fork() schedules a subtask asynchronously on the pool
  • •join() waits for the subtask and retrieves its result
  • •invoke(task) submits a task and blocks until complete (entry point)
  • •invokeAll(task1, task2) forks both and waits for both
  • •Threshold controls when to stop splitting; too small wastes overhead, too large reduces parallelism
  • •Do not block inside ForkJoin tasks; use ManagedBlocker for blocking I/O

Examples

Parallel sum with RecursiveTask

import java.util.concurrent.*;

public class ParallelSum extends RecursiveTask<Long> {
    private static final int THRESHOLD = 10_000;
    private final int[] arr;
    private final int lo, hi;

    ParallelSum(int[] arr, int lo, int hi) {
        this.arr = arr; this.lo = lo; this.hi = hi;
    }

    @Override
    protected Long compute() {
        if (hi - lo <= THRESHOLD) {
            long sum = 0;
            for (int i = lo; i < hi; i++) sum += arr[i];
            return sum;
        }
        int mid = (lo + hi) / 2;
        ParallelSum left  = new ParallelSum(arr, lo, mid);
        ParallelSum right = new ParallelSum(arr, mid, hi);
        left.fork();                   // schedule left asynchronously
        long rightResult = right.compute(); // compute right on this thread
        long leftResult  = left.join();    // wait for left
        return leftResult + rightResult;
    }

    public static void main(String[] args) {
        int[] arr = new int[1_000_000];
        for (int i = 0; i < arr.length; i++) arr[i] = i + 1;

        ForkJoinPool pool = ForkJoinPool.commonPool();
        long sum = pool.invoke(new ParallelSum(arr, 0, arr.length));
        System.out.println("Sum: " + sum);
    }
}

The pattern is: fork the left half, compute the right half on the current thread (saves thread-switch overhead), then join the left. This is the standard fork-compute-join idiom for binary splits.

Parallel array sorting action with RecursiveAction

import java.util.Arrays;
import java.util.concurrent.*;

public class ParallelSort extends RecursiveAction {
    private static final int THRESHOLD = 5_000;
    private final int[] arr;
    private final int lo, hi;

    ParallelSort(int[] arr, int lo, int hi) {
        this.arr = arr; this.lo = lo; this.hi = hi;
    }

    @Override
    protected void compute() {
        if (hi - lo <= THRESHOLD) {
            Arrays.sort(arr, lo, hi);
            return;
        }
        int mid = (lo + hi) / 2;
        invokeAll(
            new ParallelSort(arr, lo, mid),
            new ParallelSort(arr, mid, hi)
        );
        // merge two sorted halves
        int[] tmp = Arrays.copyOfRange(arr, lo, hi);
        int i = 0, j = mid - lo, k = lo;
        while (i < mid - lo && j < hi - lo)
            arr[k++] = (tmp[i] <= tmp[j]) ? tmp[i++] : tmp[j++];
        while (i < mid - lo) arr[k++] = tmp[i++];
        while (j < hi - lo)  arr[k++] = tmp[j++];
    }

    public static void main(String[] args) {
        int[] arr = {5, 3, 8, 1, 9, 2, 7, 4, 6};
        ForkJoinPool.commonPool().invoke(new ParallelSort(arr, 0, arr.length));
        System.out.println(Arrays.toString(arr));
    }
}

invokeAll forks both subtasks and waits for both. RecursiveAction is for in-place modifications where no value is returned.

Common Mistakes

  • Calling fork() on both subtasks before joining either. The pattern should be: fork one, compute the other on the current thread, then join the forked one. Forking both wastes the current thread by sitting idle during join.
  • Setting the threshold too low. Each fork creates overhead for task creation and scheduling. If the threshold is 1, most time is spent managing tasks, not doing real work. Benchmark to find the sweet spot (typically thousands of elements).
  • Performing blocking I/O inside a ForkJoin task. ForkJoin threads are precious; blocking them starves the pool. Use ManagedBlocker or move I/O outside the task.

Note

For most use cases, parallel streams (list.parallelStream()) are simpler than writing RecursiveTask manually. They use ForkJoinPool.commonPool() internally. Write RecursiveTask directly only when you need custom splitting logic or a non-stream computation.

Exercises

Write Java code and click Run to execute it instantly. When your output looks correct, mark as done.

Loading editor…

Completed: 0 / 2 exercises

Knowledge Check

Question 1 of 4

What is the recommended fork-join pattern to avoid wasting the current thread?