Even in the age of virtual threads, the classic Java concurrency toolbox β ExecutorService, CompletableFuture, and ForkJoinPool β is not going anywhere. Each one solves a different problem, and picking the wrong one is a common source of latency bugs. This post walks through all three with runnable examples, then adds AI prompts to review and refactor your own concurrent code.
1. ExecutorService β Managed Task Submission
ExecutorService decouples task creation from execution. You submit Callable or Runnable tasks and get back Future handles. It manages the thread lifecycle so you do not have to.
import java.util.concurrent.*;
import java.util.List;
public class ExecutorExample {
public static void main(String[] args) throws Exception {
// try-with-resources shuts down and waits (Java 19+ API)
try (ExecutorService executor = Executors.newFixedThreadPool(4)) {
// Callable returns a value; Runnable does not
Callable<Integer> task = () -> {
Thread.sleep(200);
return 42;
};
// Submit three tasks; each returns a Future immediately
List<Future<Integer>> futures = List.of(
executor.submit(task),
executor.submit(task),
executor.submit(task)
);
for (Future<Integer> f : futures) {
System.out.println("Result: " + f.get()); // blocks until task completes
}
} // executor.close() called here: orderly shutdown + wait
}
}
Key points: Future.get() blocks the calling thread until the result is available. If the task threw an exception, get() wraps it in an ExecutionException. For non-blocking pipelines, use CompletableFuture instead.
2. CompletableFuture β Non-Blocking Pipelines
CompletableFuture is built for chaining. You compose async steps with thenApply, combine independent calls with thenCombine, and handle errors without try-catch with exceptionally.
import java.util.concurrent.*;
public class CompletableFutureExample {
public static void main(String[] args) throws Exception {
CompletableFuture<String> pipeline =
CompletableFuture
.supplyAsync(() -> fetchUser(1L)) // step 1: async
.thenApply(String::toUpperCase) // step 2: transform (same thread)
.thenCombine( // step 3: combine with another future
CompletableFuture.supplyAsync(() -> fetchOrder(1L)),
(user, order) -> user + " / " + order
)
.exceptionally(ex -> "fallback: " + ex.getMessage()); // step 4: error handler
System.out.println(pipeline.get()); // ANKUR / laptop
}
static String fetchUser(long id) { sleep(100); return "ankur"; }
static String fetchOrder(long id) { sleep(150); return "laptop"; }
static void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}
Key points: thenApply transforms the result synchronously in the completing thread. thenApplyAsync hands it to the common pool. Use thenCompose (not thenApply) when the transformation itself returns a CompletableFuture β otherwise you get a CompletableFuture<CompletableFuture<T>>.
3. ForkJoinPool β Divide-and-Conquer CPU Work
ForkJoinPool uses work-stealing: idle threads steal sub-tasks from busy threads’ queues. It is purpose-built for CPU-bound recursive algorithms where splitting the problem recursively is natural.
import java.util.concurrent.*;
public class ForkJoinSum extends RecursiveTask<Long> {
private static final int THRESHOLD = 1_000; // split until chunks are <= 1000 elements
private final long[] data;
private final int start, end;
ForkJoinSum(long[] data, int start, int end) {
this.data = data;
this.start = start;
this.end = end;
}
@Override
protected Long compute() {
if (end - start <= THRESHOLD) {
// Base case: compute sequentially
long sum = 0;
for (int i = start; i < end; i++) sum += data[i];
return sum;
}
// Recursive case: split in half
int mid = (start + end) >>> 1; // unsigned right shift avoids overflow
ForkJoinSum left = new ForkJoinSum(data, start, mid);
ForkJoinSum right = new ForkJoinSum(data, mid, end);
left.fork(); // queue left for async execution
long rightResult = right.compute(); // run right on current thread
return left.join() + rightResult; // wait for left, combine
}
public static void main(String[] args) {
long[] data = new long[1_000_000];
for (int i = 0; i < data.length; i++) data[i] = i;
long total = ForkJoinPool.commonPool()
.invoke(new ForkJoinSum(data, 0, data.length));
System.out.println("Sum = " + total); // 499999500000
}
}
How the Code Works
- ExecutorService decouples task submission from execution strategy.
Future.get()blocks the caller β fine for simple fan-out, wrong for deep async pipelines where you want to chain and transform results. - CompletableFuture models a dataflow graph. Each stage in the chain is registered as a callback on the previous stage’s completion. No thread blocks waiting for earlier stages β the pipeline progresses as each stage finishes.
- ForkJoinPool uses work-stealing queues. When a thread calls
fork(), it pushes a sub-task to the bottom of its own deque. Idle threads steal from the top of other threads’ deques, keeping all CPUs busy even when the work is unbalanced.
When to Use Which
- ExecutorService (virtual threads) β
Executors.newVirtualThreadPerTaskExecutor()for I/O-bound work where you want one-task-per-virtual-thread simplicity. See the Virtual Threads benchmark for numbers. - ExecutorService (fixed pool) β CPU-bound tasks where you want to limit parallelism to the number of cores.
- CompletableFuture β orchestrating multiple async calls with dependencies: fan-out to microservices, transform results, combine, handle errors in a pipeline without blocking.
- ForkJoinPool β CPU-bound recursive algorithms: array sums, merge sorts, tree traversals, parallel collectors (
parallelStream()uses the common ForkJoinPool under the hood). - StructuredTaskScope (Java 25) β when you need scoped lifetimes and automatic cancellation of a group of concurrent tasks. Supersedes most
CompletableFuture.allOfpatterns.
AI Prompts You Can Use
Paste these into Claude, ChatGPT, or Cursor with your file attached:
Prompt 1 β Classify My Concurrency Usage
What it does: Audits a Java class and lists every use of Thread, ExecutorService, CompletableFuture, and ForkJoinPool. For each one, classifies the workload (I/O-bound, CPU-bound, mixed) and recommends whether to keep it, migrate to virtual threads, or convert to StructuredTaskScope.
When to use it: Start here before making any changes β get a full picture of the concurrency model first.
Scan this Java class and list every use of Thread, ExecutorService, CompletableFuture, and ForkJoinPool. For each one, classify the workload (I/O-bound, CPU-bound, mixed) and recommend whether to keep it, migrate to virtual threads, or convert to StructuredTaskScope.
Prompt 2 β Untangle Nested CompletableFutures
What it does: Fixes the two most common CompletableFuture mistakes: calling .get() inside an async stage (which blocks a pool thread and defeats the point), and using thenApply where thenCompose is needed (producing a nested future). Adds exceptionally handlers for each external call.
When to use it: When a CompletableFuture chain is growing in complexity or has unexplained deadlocks.
Refactor this CompletableFuture chain. Remove any .get() calls that block inside async stages. Replace nested thenApply(CompletableFuture) with thenCompose. Add exceptionally handlers for each external call step.
Prompt 3 β Convert Future.get() Blocking Code
What it does: Converts old blocking Future.get() code to a non-blocking CompletableFuture pipeline. Preserves error-handling semantics and log points, and explains where the threading model changes.
When to use it: Legacy code that submits tasks to an executor and then immediately blocks on get() β a common Java 7/8 pattern.
Convert this Future.get() blocking code to a non-blocking CompletableFuture pipeline. Preserve the error-handling semantics and log points. Explain where the threading model changes.
Prompt 4 β Write a RecursiveTask
What it does: Takes a sequential algorithm and writes the equivalent ForkJoinPool RecursiveTask. Picks a sensible THRESHOLD based on the data size and justifies the choice. Explains the fork-then-compute-right-then-join pattern.
When to use it: When you have a CPU-bound divide-and-conquer problem (sort, sum, transform) that should use all available cores.
Given this sequential algorithm: [paste your algorithm], write an equivalent ForkJoinPool RecursiveTask that splits the work when size > THRESHOLD. Pick a sensible THRESHOLD and justify it based on data size and CPU count.
Prompt 5 β Migrate to StructuredTaskScope
What it does: Rewrites a CompletableFuture.allOf fan-out into a Java 25 StructuredTaskScope. Shows how cancellation and exception propagation differ: StructuredTaskScope automatically cancels all forked tasks when any one fails, while CompletableFuture.allOf does not.
When to use it: When you have a fan-out pattern that should cancel all subtasks if any single one fails β the most common correctness requirement for aggregators.
Refactor this CompletableFuture.allOf fan-out into a Java 25 StructuredTaskScope. Show how cancellation and exception propagation differ from the original. Include error handling for both ShutdownOnFailure and ShutdownOnSuccess joiners.
See Also
- Java 21 to Java 25 LTS: Every Feature You Actually Need to Know
- Virtual Threads vs Platform Threads β Benchmarks and Code
- Java Streams API Deep Dive + Collectors Cookbook
- Modern Java Testing: JUnit 6 + AssertJ + Mockito 5 + Testcontainers
- Demystifying Thread Safety in Java: A Practical Guide
- Solving the Producer-Consumer Problem with BlockingQueue
- Deep Dive into Javaβs PriorityBlockingQueue
- ExecutorService.invokeAny(): Winning the Race for the First Result
- From Raw Threads to Virtual Threads: A Developerβs Guide
- Java Memory Management and Garbage Collection
FAQs
Is CompletableFuture obsolete with virtual threads?
No. Virtual threads solve the cost of thread creation and blocking, but they do not provide a way to express async pipelines, combine results from multiple concurrent operations, or chain transformations. CompletableFuture is still the clearest way to model those dependency graphs. For scoped lifetime and cancellation, StructuredTaskScope (Java 25) is a better option than allOf.
Should I size ForkJoinPool manually?
Usually not. The common pool defaults to Runtime.getRuntime().availableProcessors() - 1, which leaves one processor free for the main thread. Override only when you have a specific reason β for example, if your RecursiveTask mixes CPU and I/O work, a larger pool may help.
Can I use CompletableFuture with virtual threads?
Yes. Pass a custom Executor backed by virtual threads to supplyAsync(supplier, executor) and all subsequent async stages will run on virtual threads. This combines the pipeline composition of CompletableFuture with the cheap blocking of virtual threads.
Conclusion
ExecutorService, CompletableFuture, and ForkJoinPool each have a niche that virtual threads do not eliminate. Executors manage task lifecycle and thread reuse; CompletableFuture orchestrates async dependencies; ForkJoinPool crunches CPU-bound recursive work. Understanding which tool fits which problem β and pairing them with AI-assisted code review β is what separates maintainable concurrent code from subtle latency bugs.