Skip to content

Executors Factory Pools and ForkJoinPool

Why You Need to Know This

Until now you have probably been creating thread pools by hand. You set the core pool size, the maximum pool size, the keep alive time, the queue type, and the thread factory yourself. That approach gives you complete control and is called a custom thread pool executor. It is powerful, but it is also a lot of boilerplate for common everyday scenarios.

Java ships with a utility class called Executors in the java.util.concurrent package. This class provides factory methods that give you ready made thread pool configurations for four classic situations. Instead of writing ten lines of constructor arguments, you call one method and you are done. These factory pools are not a replacement for the custom approach. They are a shortcut when the defaults fit your requirements.

After covering the four factory pools, you will learn about something far more exciting: ForkJoinPool. This is one of the most important and interview relevant threading concepts in Java. ForkJoinPool introduces a completely different mental model for parallelism, the divide and conquer approach powered by work stealing. By the end of this article you will understand why ForkJoinPool exists, how it works internally, how to write code that uses it, and when to reach for it instead of a regular pool.


The Four Executors Factory Methods

Fixed Thread Pool

java
ExecutorService pool = Executors.newFixedThreadPool(4);
pool.submit(() -> System.out.println("task running"));

A fixed thread pool keeps a constant number of threads alive permanently. You decide the size at creation time and the pool never goes above or below that number.

Think of it like a restaurant with exactly four chefs. If all four chefs are busy and a new order comes in, the order waits in a queue. The queue in a fixed thread pool is an unbounded LinkedBlockingQueue, meaning it can hold as many waiting tasks as memory allows. When a chef finishes, the next order comes out of the queue.

Key properties:

  • Core pool size equals maximum pool size. Both are the number you provide.
  • Threads never die from being idle. They wait permanently for new work.
  • Queue is unbounded, so you will never see task rejection unless you explicitly shut down the pool.

When to use it: when you have a known, stable workload and you want predictable resource usage. A web server that always handles exactly eight parallel requests is a good fit. You know the ceiling. You want that ceiling enforced.

What to watch out for: the unbounded queue can fill up memory if tasks arrive faster than they get processed. Also, if your tasks are long running and blocking (waiting on network IO for example), your threads sit idle during that wait and throughput drops. Fixed thread pool works best for CPU bound work where threads stay busy.

Cached Thread Pool

java
ExecutorService pool = Executors.newCachedThreadPool();
pool.submit(() -> System.out.println("task running"));

A cached thread pool creates a new thread every time you submit a task and no idle thread is available. When a thread finishes its task and sits idle for 60 seconds, it is terminated and removed from the pool. This pool has no upper limit on thread count.

Think of it like a taxi company that calls in freelance drivers on demand. You need a driver, one shows up. If there are already drivers sitting idle at the depot, they get used first. Drivers who have waited 60 minutes with no fare go home.

Key properties:

  • Core pool size is zero.
  • Maximum pool size is Integer.MAX_VALUE, meaning effectively unlimited.
  • Threads live while idle for only 60 seconds, then they terminate.
  • Uses a SynchronousQueue internally, which holds zero elements. Every submitted task either finds an idle thread immediately or a new thread is created.

When to use it: when you have bursts of many short lived tasks. If tasks arrive in a spike, new threads spin up to handle them. Once the spike passes, those threads die off. The pool shrinks back to zero threads.

What to watch out for: if you submit many long running tasks quickly, you can create an enormous number of threads. Each thread consumes memory and operating system resources. This can lead to resource exhaustion or OutOfMemoryError. Never use a cached thread pool for long running or blocking work.

Single Thread Executor

java
ExecutorService pool = Executors.newSingleThreadExecutor();
pool.submit(() -> System.out.println("task running"));

A single thread executor is the simplest pool. It maintains exactly one thread and an unbounded queue. Tasks run one at a time in the order they are submitted.

Think of it like a single teller at a bank. Customers join a line, and the teller serves them one by one. Nobody gets skipped. No two customers are served at the same time.

Key properties:

  • One thread, permanently alive even when the queue is empty.
  • Queue is unbounded.
  • Tasks execute strictly in submission order.

When to use it: when you need sequential processing with no parallel execution. For example, writing log entries to a file. You want them in order. You do not want two threads writing at the same time. This pool guarantees both.

Work Stealing Pool

java
ExecutorService pool = Executors.newWorkStealingPool();
pool.submit(() -> System.out.println("task running"));

This is where things get interesting. A work stealing pool creates a pool where the number of threads matches your available CPU cores by default. You can also provide a custom parallelism level.

java
// Matches available processors
ExecutorService pool = Executors.newWorkStealingPool();

// Custom parallelism level
ExecutorService pool = Executors.newWorkStealingPool(8);

Under the hood, newWorkStealingPool creates a ForkJoinPool. This is not just a regular thread pool with a different name. It uses a fundamentally different task scheduling algorithm called work stealing. To understand work stealing properly you need to first understand what problem it solves, which means you need to understand ForkJoinPool.


The Problem That ForkJoinPool Solves

Imagine you have a giant task. Maybe you need to sum every number in an array of 10 million elements. You have eight CPU cores available. How do you parallelize this?

With a regular thread pool you could split the array into eight chunks, submit eight tasks, and wait for results. That works, but it is manual. You have to figure out the split size yourself, submit eight tasks yourself, collect eight results yourself, and combine them yourself.

ForkJoinPool is designed to automate exactly this pattern. The idea comes from computer science: divide and conquer. You take a big problem, split it into smaller subproblems, solve each subproblem (possibly splitting again), and then combine the results as you return up the call stack.

The name "fork join" describes the two operations:

  • Fork: split the current task into subtasks and let them run in parallel.
  • Join: wait for the subtasks to finish and collect their results.

This mirrors the way fork() works in Unix operating systems. You split off child processes (fork), do work in parallel, and then reunite the results (join).


Work Stealing: The Secret Sauce

A regular thread pool has one shared queue. All threads pull tasks from that single queue. This works, but there is a hidden inefficiency.

In ForkJoinPool every thread has its own private work queue, called a work stealing deque (double ended queue). When a thread forks a subtask, that subtask goes into the thread's own private deque, not the shared submission queue. The thread then continues processing the left subtask directly.

Here is where the magic happens. When a thread finishes everything in its own deque, rather than just sitting idle, it looks around at other threads' deques and steals tasks from the back. The owner thread picks from the front (LIFO order for local tasks). The stealing thread picks from the back (FIFO order for stolen tasks). This design minimizes contention because both ends of the deque operate independently.

Think of it like a group of workers on a factory floor. Each worker has their own stack of jobs. When a worker finishes all their jobs, instead of going on break, they walk over to the busiest worker and take a job off their stack. Everyone stays productive. No one sits idle while others are overloaded.

The algorithm works in this order when a thread becomes free:

  1. Check the thread's own work stealing deque. Is there anything there? If yes, work on it.
  2. Check the shared submission queue. Are there any new submitted tasks? If yes, pick one.
  3. Scan all other threads' work stealing deques. Is any thread busy with a non empty deque? If yes, steal a task from the back of that deque.

Stealing only happens from work stealing deques, never from the shared submission queue. The submission queue has one producer: you, when you call submit. The work stealing deques have one primary consumer (the owning thread from the front) and potentially many stealers (other threads from the back).

Visual Walkthrough

Imagine two threads, Thread 1 and Thread 2, and a submission queue.

You submit a simple task (Task 1). Thread 1 picks it. Thread 1 is busy. You submit a recursive task (Task 2). Thread 2 picks it. Thread 2 is busy. You submit Task 3. Both threads are busy. Task 3 goes into the submission queue.

Now Task 2 is a recursive task. Thread 2 divides Task 2 into Subtask 2a and Subtask 2b. Thread 2 starts working on Subtask 2a directly. Subtask 2b goes into Thread 2's private work stealing deque.

Meanwhile Thread 1 finishes Task 1. Thread 1 checks its own deque: empty. Thread 1 checks the submission queue: Task 3 is there. Thread 1 picks up Task 3 and works on it.

Now Thread 1 finishes Task 3. Thread 1 checks its own deque: empty. Thread 1 checks the submission queue: empty. Thread 1 scans all other deques. It sees Thread 2's deque has Subtask 2b sitting there. Thread 1 steals Subtask 2b and starts working on it. Thread 2 eventually finishes Subtask 2a and then joins: it waits for Subtask 2b to complete (Thread 1 is handling that). When both subtasks complete, Thread 2 combines the results and the recursive task is done.

This is work stealing. The CPU stays busy. Threads do not idle while there is parallelizable work available anywhere in the pool.


RecursiveTask and RecursiveAction

To use ForkJoinPool you write your divide and conquer logic in a special class. There are two base classes to choose from.

RecursiveTask: When You Return a Result

Use RecursiveTask<T> when your computation produces a value. The T is the return type.

java
import java.util.concurrent.RecursiveTask;

public class SumTask extends RecursiveTask<Integer> {
    private int start;
    private int end;
    private int[] array;
    private static final int THRESHOLD = 1000;

    public SumTask(int[] array, int start, int end) {
        this.array = array;
        this.start = start;
        this.end = end;
    }

    @Override
    protected Integer compute() {
        // Base case: problem is small enough to solve directly
        if (end - start <= THRESHOLD) {
            int sum = 0;
            for (int i = start; i < end; i++) {
                sum += array[i];
            }
            return sum;
        }

        // Recursive case: divide into two halves
        int mid = (start + end) / 2;

        // Create subtasks
        SumTask leftTask = new SumTask(array, start, mid);
        SumTask rightTask = new SumTask(array, mid, end);

        // Fork the right task: puts it in the work stealing deque
        rightTask.fork();

        // Compute the left task directly in this thread
        int leftResult = leftTask.compute();

        // Join the right task: wait for it to finish
        int rightResult = rightTask.join();

        // Combine and return
        return leftResult + rightResult;
    }
}

The compute() method is where you implement the divide and conquer logic. Notice the pattern:

  1. Check if the problem is small enough (the base case). If yes, solve it directly.
  2. If not, split into left and right subtasks.
  3. Call fork() on one subtask. This schedules it for parallel execution by putting it in the work stealing deque.
  4. Call compute() directly on the other subtask. This reuses the current thread rather than creating overhead.
  5. Call join() on the forked subtask. This waits for it to finish and returns its result.
  6. Combine both results and return.

A critical pattern to note: you fork the right task but directly compute the left task. This is intentional. If you fork both and then join both, you create unnecessary overhead. The current thread is available, so use it for one of the halves directly.

RecursiveAction: When You Return Nothing

Use RecursiveAction when your computation has no return value, for example sorting an array in place or applying a transformation to all elements.

java
import java.util.concurrent.RecursiveAction;

public class ArrayFillTask extends RecursiveAction {
    private int[] array;
    private int start;
    private int end;
    private static final int THRESHOLD = 1000;

    public ArrayFillTask(int[] array, int start, int end) {
        this.array = array;
        this.start = start;
        this.end = end;
    }

    @Override
    protected void compute() {
        if (end - start <= THRESHOLD) {
            // Base case: fill directly
            for (int i = start; i < end; i++) {
                array[i] = i * 2;
            }
            return;
        }

        int mid = (start + end) / 2;
        ArrayFillTask left = new ArrayFillTask(array, start, mid);
        ArrayFillTask right = new ArrayFillTask(array, mid, end);

        // Fork both and join both (no return value to combine)
        left.fork();
        right.fork();
        left.join();
        right.join();
    }
}

The structure is the same, but compute() returns void and there is no result to combine.


Creating a ForkJoinPool and Submitting Work

There are three ways to run ForkJoinPool tasks.

Option 1: Use Executors.newWorkStealingPool

java
ExecutorService pool = Executors.newWorkStealingPool();

int[] numbers = new int[10_000_000];
// fill array...

SumTask task = new SumTask(numbers, 0, numbers.length);
Future<Integer> future = pool.submit(task);
int result = future.get();
System.out.println("Sum: " + result);

Option 2: Create ForkJoinPool Directly

java
ForkJoinPool pool = new ForkJoinPool(4); // 4 threads

SumTask task = new SumTask(numbers, 0, numbers.length);
int result = pool.invoke(task); // invoke blocks until done
System.out.println("Sum: " + result);

invoke is convenient because it blocks the calling thread until the task completes and returns the result directly, without needing a Future.

Option 3: Use the Common Pool

java
ForkJoinPool commonPool = ForkJoinPool.commonPool();
int result = commonPool.invoke(task);

The common pool is a shared, JVM wide ForkJoinPool that Java maintains automatically. Its parallelism level defaults to Runtime.getRuntime().availableProcessors() - 1, reserving one processor for the thread that calls invoke.

When you use parallel streams (like list.parallelStream()) in Java, they use the common pool internally. This is important to know for interviews: parallel streams and ForkJoinPool are connected.


The Common Pool and Parallel Streams

The common pool is global. That means if your application uses parallel streams and you also use ForkJoinPool directly through the common pool, they share the same threads. Heavy use in one area can starve the other.

java
// This uses the common pool internally
List<Integer> result = numbers.parallelStream()
    .map(n -> n * 2)
    .collect(Collectors.toList());

When you use newWorkStealingPool() via Executors, Java creates a separate ForkJoinPool, not the common pool. This gives you isolation.

For most production scenarios where you are doing heavy parallel computation, create your own ForkJoinPool with an explicit parallelism level rather than using the common pool. This prevents your heavy computation from interfering with other parts of the system that depend on parallel streams.


What Happens When You Call fork()

When you call rightTask.fork():

  1. The current thread places rightTask into its own work stealing deque.
  2. The current thread continues executing (it calls leftTask.compute() directly).
  3. If another thread is idle and looking for work, it may steal rightTask from the back of the deque.
  4. If no other thread steals it, the current thread (after finishing the left task and joining the right) will process it itself.

Calling fork() does not mean a new thread is spawned. It means the task is made available for another thread to steal. If no thread steals it, the original thread handles it anyway. This is very different from creating new Thread objects.

When you call rightTask.join():

  • If the task is already done (because another thread stole and finished it), the result is returned immediately.
  • If the task is not done yet, the current thread does not just block and sit idle. Instead it looks for other work to do in the pool while waiting. This is called helping, and it is why ForkJoinPool does not suffer from the deadlock risks that affect regular blocking thread pools when tasks wait for other tasks.

The Threshold: Choosing When to Stop Dividing

The threshold is the crossover point between recursion and direct computation. It is one of the most important tuning decisions when using ForkJoinPool.

If your threshold is too small (like 1), you fork a task for every single element. The overhead of creating task objects, forking, and joining completely overwhelms the actual computation. You end up slower than a single thread.

If your threshold is too large (like the entire array), you never split at all. No parallelism happens.

A common rule of thumb is to set the threshold so that the base case takes roughly a few hundred microseconds of CPU time. For simple operations on arrays, thresholds in the range of 1000 to 10000 elements are typical starting points. Always benchmark your specific workload.


ForkJoinPool vs Regular Thread Pools: When to Use Which

This question comes up frequently in interviews. Here is the clear decision guide:

Use a regular thread pool (Fixed, Cached, Single) when:

  • Tasks are independent of each other
  • Tasks do not need to wait for other tasks submitted to the same pool
  • You are doing IO bound work (database calls, network requests, file reads)
  • You want simple, predictable behavior
  • Tasks are of roughly uniform size

Use ForkJoinPool when:

  • Your task can naturally be broken into smaller subtasks recursively
  • The problem follows divide and conquer: each subtask is a smaller version of the same problem
  • Work is CPU bound and you want to maximize CPU utilization across all cores
  • Tasks have variable sizes and work stealing can help balance load across threads
  • You are working with parallel streams

The critical difference: in a regular thread pool, if a task inside the pool tries to wait for another task in the same pool, you risk deadlock. All threads block waiting for each other and no thread is free to execute the awaited tasks. ForkJoinPool avoids this because its join operation does not truly block. The thread helps with other work instead of sitting idle.


Interview Questions and Pitfalls

Q: What is ForkJoinPool and how does it differ from a regular thread pool?

ForkJoinPool is a specialized thread pool designed for divide and conquer parallelism. The key difference is work stealing. Each thread in ForkJoinPool maintains its own deque of tasks. Idle threads steal work from busy threads' deques. Regular thread pools share one global queue. ForkJoinPool is suited for recursive parallel tasks. Regular pools are suited for independent tasks, especially IO bound ones.

Q: What is the difference between RecursiveTask and RecursiveAction?

RecursiveTask returns a value. You extend RecursiveTask&lt;T&gt; and implement compute() to return type T. RecursiveAction has no return value. You extend RecursiveAction and implement void compute(). Use RecursiveTask for computations like summing an array. Use RecursiveAction for in place operations like sorting.

Q: What happens when you call fork()?

Calling fork() on a task places that task into the current thread's work stealing deque for potential parallel execution. The current thread does not wait. It continues to the next statement. Another idle thread may steal the forked task. Importantly, fork does not create a new OS thread.

Q: What happens when you call join()?

Calling join() waits for the forked task to complete and returns its result. Unlike blocking with a regular lock, when a ForkJoinPool thread calls join it does not block idly. Instead it looks for other tasks to execute in the pool while waiting. This prevents the pool from stalling.

Q: Why should you compute one subtask directly instead of forking both?

If you fork both subtasks and then join both, the current thread sits and waits for both. It wastes the current thread's capacity. The common pattern is to fork one subtask (putting it in the deque for potential stealing) and directly call compute on the other (using the current thread productively). This maximizes CPU use.

Q: What is the common pool and when is it used?

ForkJoinPool.commonPool() returns a JVM wide shared ForkJoinPool. Java uses it internally for parallel streams. The common pool's parallelism level is availableProcessors - 1. You can submit your own ForkJoinTasks to it, but be aware that it is shared with parallel streams throughout your application. For heavy parallel computation, create a dedicated pool instead.

Q: What is the difference between submit() and invoke() on ForkJoinPool?

submit() is asynchronous. It returns a Future immediately and you call get() to wait for the result. invoke() is synchronous. It blocks the calling thread until the task completes and returns the result directly. When calling from within a ForkJoinPool task, use fork() and join() instead. When calling from outside the pool, invoke() is the most straightforward option.

Q: When would you choose newWorkStealingPool over directly creating a ForkJoinPool?

Executors.newWorkStealingPool() returns an ExecutorService interface, which is useful when you want to program against the standard interface and possibly swap implementations. It creates a ForkJoinPool internally with parallelism equal to availableProcessors. Directly creating new ForkJoinPool(n) gives you access to ForkJoinPool specific methods like invoke, invokeAll, and getStealCount. Use the direct approach when you need full ForkJoinPool capabilities.

Q: Can you use regular Runnable or Callable tasks with ForkJoinPool?

Yes. ForkJoinPool implements ExecutorService, so it accepts Runnable and Callable tasks via submit(). However, you only get work stealing benefits with ForkJoinTask subclasses (RecursiveTask or RecursiveAction). Regular Runnable tasks go into the submission queue and are handled like a normal thread pool task.

Q: What is the threshold and why does it matter?

The threshold is the point at which you stop dividing and compute directly. Choosing a threshold too small creates excessive task overhead (too many fork and join operations). Choosing it too large means insufficient parallelism. The right threshold depends on the computation and should be determined by benchmarking. A common starting point for simple array operations is 1000 elements.

Q: Can ForkJoinPool tasks deadlock?

ForkJoinPool is more deadlock resistant than regular thread pools for recursive tasks because joining does not truly block. The joining thread helps execute other tasks. However, if you use blocking operations inside ForkJoinPool tasks (like Thread.sleep, Lock.lock, or blocking IO), you can still prevent threads from making progress. In that case consider using ManagedBlocker to signal the pool that a thread is about to block, giving the pool permission to create a compensating thread.


Putting It All Together

Here is a complete working example using ForkJoinPool to compute the sum of a large array:

java
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveTask;

public class ParallelSum {

    static class SumTask extends RecursiveTask<Long> {
        private final long[] array;
        private final int start;
        private final int end;
        private static final int THRESHOLD = 10_000;

        SumTask(long[] array, int start, int end) {
            this.array = array;
            this.start = start;
            this.end = end;
        }

        @Override
        protected Long compute() {
            int size = end - start;

            // Base case: small enough to sum directly
            if (size <= THRESHOLD) {
                long sum = 0;
                for (int i = start; i < end; i++) {
                    sum += array[i];
                }
                return sum;
            }

            // Recursive case: split in half
            int mid = start + size / 2;

            SumTask leftTask = new SumTask(array, start, mid);
            SumTask rightTask = new SumTask(array, mid, end);

            // Fork right: goes into work stealing deque
            rightTask.fork();

            // Compute left directly: uses current thread
            long leftResult = leftTask.compute();

            // Join right: waits and retrieves result
            long rightResult = rightTask.join();

            return leftResult + rightResult;
        }
    }

    public static void main(String[] args) throws Exception {
        int size = 10_000_000;
        long[] array = new long[size];
        for (int i = 0; i < size; i++) {
            array[i] = i + 1;
        }

        // Create pool with parallelism matching CPU count
        ForkJoinPool pool = new ForkJoinPool(
            Runtime.getRuntime().availableProcessors()
        );

        SumTask task = new SumTask(array, 0, size);
        long result = pool.invoke(task);
        System.out.println("Sum: " + result);

        pool.shutdown();
    }
}

This code will automatically distribute the work across all CPU cores. On a machine with eight cores, it will create roughly eight levels of recursion at the leaves, keeping all eight cores busy simultaneously.


Summary

The Executors class gives you four factory shortcuts:

Factory MethodThreadsQueueUse For
newFixedThreadPool(n)Always nUnboundedSteady CPU bound work with known concurrency
newCachedThreadPool()0 to unlimitedSynchronousQueueBurst of short lived tasks
newSingleThreadExecutor()Always 1UnboundedSequential ordered processing
newWorkStealingPool()CPU countPer thread dequeParallel recursive work

ForkJoinPool is not just another thread pool. It is a parallel computation engine built around divide and conquer. Work stealing keeps all threads productive by letting idle threads take tasks from busy threads' private queues. You write your parallel logic in RecursiveTask (with a return value) or RecursiveAction (without one), implement a compute() method that splits the problem and uses fork and join, and the pool handles the rest.

The key insight is that ForkJoinPool and regular thread pools solve different problems. Regular pools handle independent tasks. ForkJoinPool handles tasks that spawn subtasks. Using the wrong tool in the wrong situation leads to either deadlock or wasted potential.

Master the work stealing algorithm, understand why fork does not block and why join helps rather than blocks, choose the right threshold, and you will have a thorough answer ready for any ForkJoinPool interview question.