Java Fork/Join Framework

In the last lesson you learned about Java CountDownLatch, CyclicBarrier, and Semaphore. Those help threads wait for each other and take turns. But for a different problem, one big job you want many cores to chew through at once, Java has the Fork/Join framework. It splits a big task into smaller ones, runs them in parallel, and joins the answers back together.

🤔 The problem: one big job, many idle cores

Say you want the sum of ten million numbers. The simple way is a for loop that adds them one by one.

long sum = 0;
for (long n : numbers) {
sum += n; // one core does all the work
}

Here is what this loop costs you.

  • Your laptop has many cores. This loop uses exactly one.
  • The other cores sit idle while one core grinds through every addition.
  • Splitting the work by hand with raw threads gets messy fast.
  • You have to decide how far to split and how to avoid making thousands of threads.

Fork/Join solves all of that for you.

🧩 What divide-and-conquer means

The whole idea rests on a simple pattern called divide-and-conquer.

Think of counting a huge pile of coins with friends. You don’t make one person count all of them. You split the pile in half and give each half to a friend. They split their half again and pass it on. Soon everyone has a tiny stack they can count in seconds. Then you add up everyone’s totals.

The recipe is always the same.

  • Small task? Solve it now.
  • Big task? Split it into parts.
  • Run the parts in parallel.
  • Merge their results into the full answer.

🛠️ ForkJoinPool, RecursiveTask, and RecursiveAction

Fork/Join has three core pieces.

  • ForkJoinPool is the thread pool that runs your tasks. It spreads work across its threads and uses work-stealing (more on that soon).
  • RecursiveTask is for work that returns a result, like a sum.
  • RecursiveAction is for work that returns nothing, like sorting in place.
  • Both ask you to fill in one method, compute, where you write the “split or solve” logic.

Here is the shape of a task that returns a value. It extends RecursiveTask with the result type in angle brackets.

import java.util.concurrent.RecursiveTask;
class SumTask extends RecursiveTask<Long> {
@Override
protected Long compute() {
// either split into smaller tasks, or add directly
return 0L;
}
}

For a task with no result, extend RecursiveAction instead and let compute return void.

🔀 The fork and join pattern

Inside compute, you split work using two methods. Their names give the framework its name.

  • fork() schedules a subtask to run on the pool. It does not wait. It just says “start this when a thread is free”.
  • join() waits for a forked subtask to finish and gives you its result.

This snippet shows the standard order, which matters more than it looks.

left.fork(); // ✅ start the left half in the background
long rightResult = right.compute(); // do the right half on this thread
long leftResult = left.join(); // ✅ now wait for the left half
return leftResult + rightResult; // combine the two answers

The order is deliberate.

  • Fork first, so the left half runs in the background.
  • Compute the right half yourself, so you stay busy.
  • Join last, to collect the left half’s answer.
  • Join before forking the second piece and nothing runs in parallel. You just wait.

✂️ The threshold: when to stop splitting

Every Fork/Join task needs a threshold: a size small enough that you just solve it directly instead of splitting again.

  • Split forever and you get millions of tiny tasks that each add one number.
  • Creating and tracking that many tasks costs more than the additions.
  • So above the threshold, split. At or below it, run a plain loop.
  • A few thousand elements is a sensible starting point.

This compute method checks the size first, then either solves directly or splits.

private static final int THRESHOLD = 5_000;
@Override
protected Long compute() {
int length = end - start;
if (length <= THRESHOLD) { // ✅ small enough, just add directly
long sum = 0;
for (int i = start; i < end; i++) sum += array[i];
return sum;
}
// too big, split into two halves
int mid = start + length / 2;
SumTask left = new SumTask(array, start, mid);
SumTask right = new SumTask(array, mid, end);
left.fork();
long rightResult = right.compute();
long leftResult = left.join();
return leftResult + rightResult;
}

The threshold is the dial that controls everything.

  • Too high and you barely split, so you waste your cores.
  • Too low and you drown in tiny tasks.

📊 A full worked example: parallel sum

Let’s put it all together. This complete program fills an array, sums it with Fork/Join, and prints the total.

import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveTask;
public class Main {
static final int THRESHOLD = 5_000;
static class SumTask extends RecursiveTask<Long> {
final long[] array;
final int start, end;
SumTask(long[] array, int start, int end) {
this.array = array;
this.start = start;
this.end = end;
}
@Override
protected Long compute() {
int length = end - start;
if (length <= THRESHOLD) {
long sum = 0;
for (int i = start; i < end; i++) sum += array[i];
return sum;
}
int mid = start + length / 2;
SumTask left = new SumTask(array, start, mid);
SumTask right = new SumTask(array, mid, end);
left.fork(); // start left in background
long rightResult = right.compute(); // compute right here
long leftResult = left.join(); // wait for left
return leftResult + rightResult;
}
}
public static void main(String[] args) {
long[] numbers = new long[10_000_000];
for (int i = 0; i < numbers.length; i++) numbers[i] = i + 1;
ForkJoinPool pool = new ForkJoinPool();
long total = pool.invoke(new SumTask(numbers, 0, numbers.length));
System.out.println("Total: " + total);
pool.shutdown();
}
}

Output

Total: 50000005000000

The key call is pool.invoke(task).

  • It submits your top task.
  • It waits for the whole tree of subtasks to finish.
  • It returns the final answer.
  • Behind the scenes the pool split the work across all your cores, and each core summed its own slice.

🪄 A RecursiveAction example: no result needed

Sometimes you just want to do work on each element, like doubling every number in place. For that you use RecursiveAction, where compute returns nothing.

This task doubles every value in a slice, splitting until each slice is small.

import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveAction;
import java.util.Arrays;
public class Main {
static final int THRESHOLD = 4;
static class DoubleTask extends RecursiveAction {
final int[] array;
final int start, end;
DoubleTask(int[] array, int start, int end) {
this.array = array;
this.start = start;
this.end = end;
}
@Override
protected void compute() {
if (end - start <= THRESHOLD) {
for (int i = start; i < end; i++) array[i] *= 2;
return;
}
int mid = start + (end - start) / 2;
DoubleTask left = new DoubleTask(array, start, mid);
DoubleTask right = new DoubleTask(array, mid, end);
invokeAll(left, right); // ✅ fork both and join both for you
}
}
public static void main(String[] args) {
int[] data = {1, 2, 3, 4, 5, 6, 7, 8};
new ForkJoinPool().invoke(new DoubleTask(data, 0, data.length));
System.out.println(Arrays.toString(data));
}
}

Output

[2, 4, 6, 8, 10, 12, 14, 16]

Notice the helper invokeAll(left, right). It forks all the tasks you pass and joins them all, so you don’t write fork and join by hand. It is the cleanest way to split when you don’t need a returned value.

🦾 Work-stealing: why the pool stays busy

Here is what makes ForkJoinPool special.

  • Each worker thread keeps its own private queue of tasks.
  • When a thread runs out of work, it does not sit idle. It steals a task from the back of another thread’s queue.
  • In real work some slices finish faster than others. Stealing keeps the fast threads busy with the slow threads’ leftover work.
  • So all your cores stay busy until the very end, and the whole job finishes sooner.

You don’t write any of this. It is built into the pool. Just split your task small enough that there is plenty of work to steal.

♻️ The common pool and parallel streams

You don’t always have to create your own pool. The JVM keeps one shared pool ready for everyone, called the common pool. You reach it with ForkJoinPool.commonPool().

long total = ForkJoinPool.commonPool()
.invoke(new SumTask(numbers, 0, numbers.length));

The big thing to know is that parallel streams use this exact pool. The same array sums in one line, and Fork/Join is doing all the heavy lifting.

long total = java.util.stream.LongStream
.rangeClosed(1, 10_000_000)
.parallel()
.sum();

So in everyday code you often use Fork/Join without naming it.

  • A parallel stream quietly splits work into Fork/Join tasks on the common pool.
  • It is the friendly front door to the same engine.
  • Drop to the raw RecursiveTask API only when you need full control over the splitting.

⚖️ When Fork/Join helps and when it does not

Fork/Join is powerful, but it is not free, and it is not always the right tool.

It shines when the work is CPU-bound and splittable.

  • CPU-bound means pure calculation: math over a big array, image processing, sorting.
  • Splittable means you can cut the data into independent pieces.
  • When both are true, more cores really do mean a faster answer.

It hurts in a few cases.

  • Small data: splitting and merging costs more than the work, so a plain loop wins.
  • Blocking input or output: the threads sit waiting and tie up the whole pool for nothing.
  • Pieces that depend on each other: you cannot split them cleanly.

So ask two questions first. Is the work heavy calculation? Can I cut it into independent pieces? Both yes, it fits. Otherwise a simple loop or a regular thread pool is better.

⚠️ Common Mistakes

A few Fork/Join slip-ups catch almost everyone at first.

  • No threshold, so you create millions of tiny tasks. Splitting down to single elements costs far more than the work.
@Override
protected Long compute() {
if (start + 1 == end) return array[start]; // ❌ splits to one element each
int mid = (start + end) / 2;
SumTask left = new SumTask(array, start, mid);
SumTask right = new SumTask(array, mid, end);
left.fork();
return right.compute() + left.join();
}
@Override
protected Long compute() {
if (end - start <= THRESHOLD) { // ✅ stop splitting when small
long sum = 0;
for (int i = start; i < end; i++) sum += array[i];
return sum;
}
int mid = (start + end) / 2;
SumTask left = new SumTask(array, start, mid);
SumTask right = new SumTask(array, mid, end);
left.fork();
return right.compute() + left.join();
}
  • Calling join() before fork(), so nothing runs in parallel. Join blocks, so if you join the left half before starting the right half, the work happens one piece at a time.
left.fork();
long leftResult = left.join(); // ❌ waits here before right even starts
long rightResult = right.compute();
return leftResult + rightResult;
left.fork(); // start left
long rightResult = right.compute(); // ✅ keep busy with right
long leftResult = left.join(); // ✅ then collect left
return leftResult + rightResult;
  • Doing blocking input or output inside a task. A task that waits on a file or network freezes a pool thread, and the work-stealing engine cannot help.
@Override
protected void compute() {
String page = downloadFromNetwork(url); // ❌ blocks a precious pool thread
}
// ✅ keep Fork/Join for pure calculation; use a regular executor for I/O
ExecutorService io = Executors.newFixedThreadPool(16);
io.submit(() -> downloadFromNetwork(url));

✅ Best Practices

Habits that keep your Fork/Join code fast and correct.

  • Always set a threshold. Stop splitting when a slice is small, usually a few thousand elements, so you don’t drown in tiny tasks.
  • Fork first, then compute, then join. Start one half, do the other half yourself, then join. That keeps both halves running at the same time.
  • Use invokeAll for clean splits. It forks and joins your subtasks for you, which is tidier than writing each fork and join by hand.
  • Keep tasks pure calculation. Reserve Fork/Join for CPU-bound, splittable work. Send blocking input and output to a normal executor instead.
  • Reach for parallel streams first. For simple array math, a parallel stream uses the same engine with far less code. Drop to raw RecursiveTask only when you need control.

🧩 What You’ve Learned

Nice work. Let’s recap the Fork/Join framework.

  • Fork/Join is built for divide-and-conquer: split a big job into pieces, run them in parallel, and merge the results.
  • ✅ Run tasks on a ForkJoinPool, and describe work as RecursiveTask (returns a result) or RecursiveAction (returns nothing).
  • ✅ Use the fork then compute then join pattern, and stop splitting at a threshold so you don’t make millions of tiny tasks.
  • ✅ The pool uses work-stealing, so idle threads grab work from busy ones and every core stays busy.
  • ✅ The common pool powers parallel streams under the hood, and Fork/Join shines for CPU-bound, splittable work, not for blocking input or output.

Check Your Knowledge

Test what you learned. Pick an answer for each question, then click Check.

  1. 1

    Which task type do you extend when your task returns a result?

    Why: RecursiveTask returns a value from compute; RecursiveAction returns nothing.

  2. 2

    Why does a Fork/Join task need a threshold?

    Why: Without a threshold you split down to tiny tasks, and creating them costs more than the work itself.

  3. 3

    What does work-stealing do in a ForkJoinPool?

    Why: Idle workers steal pending tasks from busy workers, so all cores stay busy until the job is done.

  4. 4

    When is Fork/Join the wrong tool?

    Why: Blocking tasks freeze pool threads and waste them; Fork/Join is meant for CPU-bound, splittable work.

🚀 What’s Next?

You can now split heavy CPU work across all your cores with Fork/Join. Next we look at giving each thread its own private copy of a variable, so threads never step on each other’s data.

Java ThreadLocal

Share & Connect