Back to ForkJoinPool & Work Stealing
Q4Question 4 of 5

ForkJoinTask, RecursiveTask, RecursiveAction?

Key Concepts
  • ForkJoinTask is the base type for tasks run by a ForkJoinPool using divide-and-conquer with work stealing.
  • RecursiveTask<V> returns a result; RecursiveAction returns nothing (void).
  • In compute(): if small enough, solve directly; otherwise split, fork one side, compute the other, and join.
  • fork() pushes a subtask onto the worker's deque; compute() runs directly; join() waits for the result.
  • The sequential threshold determines when to stop splitting; too small creates task overhead, too large underuses cores.
  • Work stealing: idle workers steal from the other end of busy workers' deques for automatic load balancing.
  • Fork/join is for CPU-bound, splittable problems only; blocking I/O wastes pool threads.

Definition

Fork/join splits a big CPU-bound job into subtasks with fork, compute and join, using a sequential threshold so tiny pieces are solved directly and splitting overhead does not swamp the work.
  • ForkJoinTask – base type for divide-and-conquer tasks in a ForkJoinPool.
  • RecursiveTask – returns a result of type V.
  • RecursiveAction – returns nothing (side-effect only).
  • fork() – schedules a subtask asynchronously onto the worker's deque.
  • compute() – runs the task's body directly on the current thread.
  • join() – waits for a forked subtask and returns its result.
  • Sequential threshold – the size below which a task solves directly instead of splitting further.

National Bank of India must total the interest on a million accounts. One clerk is too slow. The head clerk splits the stack in half, hands one half to a colleague (fork), works the other half himself (compute), and when both are done adds the subtotals (join). Each colleague does the same until a pile is small enough to add by hand (threshold).

Understanding the Concepts

How do you implement a RecursiveTask?

RecursiveTask returns a result. The compute method checks if the work is small enough to solve directly (below the threshold). If not, it splits the work, forks one half, computes the other half directly, and joins the result.

JAVA
package com.bank.parallel;

import java.math.BigDecimal;
import java.util.concurrent.RecursiveTask;

public class InterestSumTask extends RecursiveTask<BigDecimal> {

    private static final int THRESHOLD = 10_000;
    private final BigDecimal[] interests;
    private final int from;
    private final int to;

    public InterestSumTask(BigDecimal[] interests, int from, int to) {
        this.interests = interests;
        this.from = from;
        this.to = to;
    }

    @Override
    protected BigDecimal compute() {
        if (to - from <= THRESHOLD) {
            // Sequential: below threshold, solve directly
            BigDecimal sum = BigDecimal.ZERO;
            for (int i = from; i < to; i++) {
                sum = sum.add(interests[i]);
            }
            return sum;
        }
        int mid = (from + to) / 2;
        InterestSumTask left = new InterestSumTask(interests, from, mid);
        InterestSumTask right = new InterestSumTask(interests, mid, to);
        left.fork();                         // schedule left asynchronously
        BigDecimal rightResult = right.compute();  // compute right directly
        BigDecimal leftResult = left.join();        // wait for left
        return leftResult.add(rightResult);
    }
}
JAVA
package com.bank.parallel;

import java.math.BigDecimal;
import java.util.concurrent.ForkJoinPool;

public class InterestCalculationMain {
    public static void main(String[] args) {
        BigDecimal[] interests = new BigDecimal[1_000_000];
        for (int i = 0; i < interests.length; i++) {
            interests[i] = new BigDecimal("12.50"); // sample daily interest
        }
        ForkJoinPool pool = ForkJoinPool.commonPool();
        BigDecimal total = pool.invoke(new InterestSumTask(interests, 0, interests.length));
        System.out.println("Total interest: " + total);
    }
}

What is the fork-compute-join idiom?

Always fork one side and compute the other yourself. This keeps the current thread busy instead of idle. If you fork both sides and join both, the current thread sits idle waiting for the first join, wasting a core.

JAVA
// WRONG: fork both, current thread idles during left.join()
left.fork();
right.fork();
return left.join().add(right.join());

// CORRECT: fork one, compute the other yourself
left.fork();
BigDecimal rightResult = right.compute();  // current thread stays busy
return left.join().add(rightResult);

How does work stealing work?

Each worker has a double-ended queue (deque). It pushes and pops its own subtasks from one end (LIFO). When idle, it steals from the other end (FIFO) of a busy worker's queue. Stealing from the opposite end minimizes contention between the owner and the thief.

What is the right threshold?

Too small: millions of tiny tasks, and the overhead of creating and scheduling tasks dwarfs the actual work. Too large: not enough parallelism to keep all cores busy. A good threshold keeps each leaf task doing a meaningful chunk of CPU work. For array operations, 1000 to 10000 elements per leaf is typical.

How do you implement a RecursiveAction?

RecursiveAction returns nothing (void). It is used for side-effect-only work like parallel logging or parallel file processing.

JAVA
package com.bank.parallel;

import java.math.BigDecimal;
import java.util.concurrent.RecursiveAction;

public class InterestApplyAction extends RecursiveAction {

    private static final int THRESHOLD = 10_000;
    private final BigDecimal[] balances;
    private final BigDecimal rate;
    private final int from;
    private final int to;

    public InterestApplyAction(BigDecimal[] balances, BigDecimal rate, int from, int to) {
        this.balances = balances;
        this.rate = rate;
        this.from = from;
        this.to = to;
    }

    @Override
    protected void compute() {
        if (to - from <= THRESHOLD) {
            for (int i = from; i < to; i++) {
                balances[i] = balances[i].add(balances[i].multiply(rate));
            }
            return;
        }
        int mid = (from + to) / 2;
        invokeAll(
            new InterestApplyAction(balances, rate, from, mid),
            new InterestApplyAction(balances, rate, mid, to)
        );
    }
}

When does fork/join not help?

For small inputs, a sequential loop is faster because fork/join has fixed overhead for task creation and scheduling. For blocking IO, the pool threads sit idle and cannot participate in work stealing. Fork/join is strictly for CPU-bound, splittable problems.

What are common interview follow-up questions?

  • What is the default common pool parallelism? Runtime.getRuntime().availableProcessors() - 1.
  • How does ManagedBlocker help with blocking tasks? It tells the pool to compensate for a blocked thread by temporarily adding another worker.
  • How do parallel streams use ForkJoinPool internally? Parallel streams submit Spliterator-based tasks to the common pool, using the same fork/join mechanism.

Visual Overview

DIAGRAM
  Fork/Join Divide-and-Conquer:

  [0 ──────────────────────── 1,000,000]
       │                        │
       ▼                        ▼
  [0 ─────── 500,000]   [500,000 ── 1,000,000]
       │         │            │           │
       ▼         ▼            ▼           ▼
  [0-250K] [250K-500K]  [500K-750K] [750K-1M]
       │         │            │           │
  if <= THRESHOLD: compute sequentially
  else: fork left, compute right, join left

  Work Stealing:
  Worker 1 deque: [task] [task] ←── pushes here
  Worker 2 (idle): steals from ──▶ other end

Key Takeaway

Fork/join uses RecursiveTask or RecursiveAction with fork, compute, and join to divide a CPU-bound problem across cores via work stealing. The sequential threshold determines when to stop splitting; too small creates task overhead, too large underuses cores.

At National Bank of India our end-of-day interest sum across millions of accounts used a RecursiveTask with a threshold of ten thousand elements, giving a clean speedup while matching the sequential total exactly.

Remember one sentence: fork one side, compute the other yourself, join, and set a sequential threshold so splitting overhead never outweighs the benefit.

---

Share
Use ← → arrow keys to navigate