Java's ForkJoinPool Explained: Work-Stealing Concurrency Done Right
ForkJoinPool
Java’s ForkJoinPool is a thread pool specialized for recursive, divide-and-conquer workloads. Instead of handing each task to an idle thread like a regular ThreadPoolExecutor, it uses work-stealing: every worker thread has its own deque of tasks, and when a thread runs out of work in its queue, it steals from another thread’s queue.
This design makes sense because recursive algorithms naturally produce many small subtasks. A normal pool would suffer from lock contention as threads fight over a shared task queue. Work-stealing distributes the load locally — most operations stay within one thread’s deque, and stealing only happens when there’s actual imbalance.
The pool is accessed through two entry points:
ForkJoinPool.commonPool()— a shared pool backed by the JVM (used implicitly by parallel streams)new ForkJoinPool(parallelism)— your own isolated pool with configurable thread count
The common pool defaults to n - 1 worker threads, where n is the number of available processors. The JVM reserves one core for its own work (GC, class loading, and other system tasks), so using all cores would starve the JVM itself.
The code
This example shows a RecursiveTask<Long> that computes the sum of an array by splitting it in half recursively until chunks reach a configurable threshold. Below that threshold, each chunk sums locally without further forking.
class ParallelSum extends RecursiveTask<Long> {
private final long[] data;
private final int from, to;
private final int threshold;
ParallelSum(long[] data, int from, int to, int threshold) {
this.data = data;
this.from = from;
this.to = to;
this.threshold = threshold;
}
@Override
protected Long compute() {
if (to - from <= threshold) {
long sum = 0;
for (int i = from; i < to; i++) sum += data[i];
return sum;
}
int mid = from + (to - from) / 2;
ParallelSum left = new ParallelSum(data, from, mid, threshold);
ParallelSum right = new ParallelSum(data, mid, to, threshold);
left.fork();
right.fork();
return left.join() + right.join();
}
}
The pattern is simple: fork both halves (which schedules them on the pool) then join both (blocking until their results are ready). The recursive tree fan-out maps naturally onto the work-stealing queues.
Two concrete concerns drive the design:
- Threshold: below it, no more forking happens. Too small a threshold creates too many tiny tasks and overhead dominates. Too large and you lose parallelism.
- Pool choice:
commonPool()is convenient but shared with parallel streams across your application. Long-running or CPU-bound work should use its own pool.
Running it
The run sums 10 million integers (values up to 999,999) both sequentially and in parallel using the common pool:
Available processors: 10
Common pool parallelism: 9
Sequential sum: 5000868348918 (5 ms)
ForkJoin sum: 5000868348918 (20 ms)
Correct? true
On this run the parallel version is actually slower than sequential. That may seem wrong at first, but the data array is only 10 million elements and adding two longs in a tight loop is extremely cache-friendly on a single thread. The work-stealing pool introduces overhead — task allocation, deque locking, context switching across nine threads — that this workload can’t amortize fast enough.
The threshold sweep makes this concrete:
Threshold | Parallel time (ms)
-----------+--------------------
1000 | 8
5000 | 1
10000 | 1 <-- used above
50000 | 1
200000 | 1
1000000 | 1
At threshold 1,000 the parallel version takes 8 ms — too much task creation overhead relative to computation. At 5,000 and above it drops to 1 ms and stays flat: there’s enough work per chunk that the parallelism wins.
The custom pool with double the default parallelism (18 threads) pulls down further to under a millisecond:
Custom pool parallelism (18):
ForkJoin sum: 5000868348918 (0 ms)
Takeaway
ForkJoinPool excels when your problem decomposes into independent recursive subtasks that are large enough to amortize the work-stealing overhead — and the sweet spot for chunk size depends on what each chunk actually does. Use a custom pool for long-running CPU-bound work; don’t let it compete with parallel streams in the common pool.