Back to ForkJoinPool & Work Stealing
Q2Question 2 of 5

How do CF and parallelStream use ForkJoinPool?

Key Concepts
  • ForkJoinPool.commonPool() is the default executor for CompletableFuture.supplyAsync() and all *Async() methods.
  • parallelStream() also uses commonPool() by default, sharing the same threads as CompletableFuture.
  • CompletableFuture does not use fork/join splitting; it uses the pool as a regular executor with work-stealing benefits.
  • parallelStream does use true fork/join splitting via Spliterator.trySplit() with recursive task forking.
  • Both sharing commonPool means blocking IO in CF starves parallelStream and vice versa.
  • Isolate by passing dedicated executors to CF and submitting parallelStream to a custom ForkJoinPool.

Definition

ForkJoinPool.commonPool() is the default executor for both CompletableFuture async methods and parallelStream; CF uses it as a regular pool while parallelStream uses true fork/join splitting, and both sharing the pool creates isolation risks.
  • CF default – supplyAsync(), runAsync(), and all *Async() methods use commonPool() when no executor is specified.
  • parallelStream default – stream parallel operations use commonPool() for splitting and processing.
  • CF usage – does not use fork/join splitting; submits tasks as regular Runnables; work-stealing helps idle workers pick up queued tasks.
  • parallelStream usage – uses true fork/join splitting via Spliterator.trySplit(); recursive task forking and result merging.
  • Shared pool risk – blocking IO in CF tasks starves parallelStream; heavy parallelStream work delays CF callbacks.
  • Isolation – pass dedicated executors to CF; submit parallelStream inside a custom ForkJoinPool.

ForkJoinPool is the central task room at National Bank of India. When you submit async work without specifying where to run it, it goes to the central room. When you process accounts in parallelStream, it also goes there. Everyone shares the same 3 desks (cores - 1 threads). If someone brings blocking work, desks are occupied and everything stalls. Fix: bring your own room (custom executor) for blocking tasks.

Understanding the Concepts

How does CompletableFuture use ForkJoinPool?

CompletableFuture uses commonPool as a regular executor. It does not split tasks recursively. It just submits Runnable or Callable tasks. Work-stealing helps because idle workers pick up queued CF tasks from other workers' deques.

JAVA
// All use commonPool by default:
CompletableFuture.supplyAsync(() -> compute());        // commonPool
CompletableFuture.runAsync(() -> process());            // commonPool
cf.thenApplyAsync(fn);                                  // commonPool

// Override with custom pool:
CompletableFuture.supplyAsync(() -> compute(), myPool); // custom
cf.thenApplyAsync(fn, myPool);                           // custom

How does parallelStream use ForkJoinPool?

parallelStream uses true fork/join splitting. The Spliterator splits the data source into chunks, each chunk becomes a ForkJoinTask, workers process chunks in parallel, and results are merged.

JAVA
// Uses commonPool by default:
list.parallelStream().map(this::process).toList();  // commonPool

// Override with custom ForkJoinPool:
ForkJoinPool custom = new ForkJoinPool(8);
List<Result> results = custom.submit(() ->
    list.parallelStream().map(this::process).toList()
).join();
// Runs on custom pool, not commonPool

How do CF and parallelStream differ in their use of the pool?

| Feature | CompletableFuture | parallelStream |
|---|---|---|
| Task type | Regular Runnable/Callable | ForkJoinTask (recursive) |
| Splitting | No splitting | Spliterator.trySplit() |
| Work-stealing benefit | Idle workers pick up CFs | Idle workers steal data chunks |
| Data processing | Independent tasks | Splits and merges data |

Why is sharing commonPool a problem?

JAVA
// Component A: blocking HTTP calls on commonPool via CF
for (int i = 0; i < 3; i++) {
    CompletableFuture.supplyAsync(() -> httpCall()); // blocks commonPool!
}

// Component B: report generation via parallelStream
report = accounts.parallelStream()  // STALLED! same pool!
    .map(this::generateStatement)
    .toList();

How do you isolate the pools?

JAVA
// CF: use dedicated IO pool
ExecutorService ioPool = Executors.newFixedThreadPool(20);
CompletableFuture.supplyAsync(() -> httpCall(), ioPool);

// parallelStream: use custom ForkJoinPool
ForkJoinPool reportPool = new ForkJoinPool(8);
reportPool.submit(() -> accounts.parallelStream().map(...).toList()).join();

// commonPool: free for lightweight CPU work

How do you configure commonPool?

BASH
# System properties (set at JVM start)
-Djava.util.concurrent.ForkJoinPool.common.parallelism=16
-Djava.util.concurrent.ForkJoinPool.common.threadFactory=com.bank.CustomThreadFactory
-Djava.util.concurrent.ForkJoinPool.common.exceptionHandler=com.bank.CustomHandler

Prefer dedicated pools over changing global settings.

What are common mistakes?

JAVA
// WRONG: blocking IO on commonPool via CF
CompletableFuture.supplyAsync(() -> jdbcQuery()); // blocks shared pool!

// WRONG: assuming parallelStream uses a separate pool
list.parallelStream().map(this::heavyIO); // ALSO on commonPool!

// CORRECT: isolate both
CompletableFuture.supplyAsync(() -> jdbcQuery(), ioPool);
customFJP.submit(() -> list.parallelStream().map(...)).join();

What are common interview follow-up questions?

  • Does CompletableFuture use fork/join splitting? No, it uses the pool as a regular executor.
  • Can you change commonPool after JVM start? No, only via system properties at startup.
  • What happens if you call parallelStream inside a CF callback? It runs on the same commonPool thread, potentially causing contention.

Visual Overview

DIAGRAM
  ForkJoinPool.commonPool()
  ┌────────────────────────────────────────────┐
  │ Shared by:                                  │
  │                                              │
  │ CompletableFuture  →  regular task submit   │
  │ parallelStream     →  fork/join splitting   │
  │ Arrays.parallelSort → fork/join splitting   │
  │                                              │
  │ Parallelism: cores - 1                       │
  │ Blocking IO: STALLS EVERYTHING               │
  └────────────────────────────────────────────┘

  Isolation:
  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐
  │ ioPool (20) │  │ Custom FJP  │  │ commonPool  │
  │ CF IO tasks │  │ parallelStr │  │ lightweight │
  └─────────────┘  └─────────────┘  └─────────────┘

Key Takeaway

ForkJoinPool serves two roles in the Java concurrency framework. For CompletableFuture, commonPool is the default executor for supplyAsync and all async methods, but CF does not use fork/join splitting. It just submits tasks as regular Runnables, with work-stealing helping idle workers pick up queued tasks. For parallelStream, commonPool is also the default, and parallelStream does use true fork/join splitting via Spliterator.trySplit() with recursive task forking and result merging.

The problem is that both share the same pool with only cores - 1 threads. Blocking IO in CF tasks starves parallelStream. Heavy parallelStream work delays CF callbacks. The fix is isolation: pass dedicated executors to CF methods, and submit parallelStream operations inside a custom ForkJoinPool.

At National Bank of India, market data HTTP calls on commonPool stalled the report generation parallelStream. Moving HTTP calls to a dedicated IO pool and running reports on a custom ForkJoinPool(8) eliminated the cross-contamination.

Remember one sentence: CF and parallelStream share commonPool by default; isolate them with dedicated executors to prevent cross-contamination.

---

Share
Use ← → arrow keys to navigate