Chapter 16: The Executor Framework, CompletableFuture, and Virtual Threads
By Ali Naqi | October 26, 2026
⚡ Chapter 16: The Executor Framework, CompletableFuture, and Virtual Threads
1. Thread Pools: The Worker Queue Architecture
Instead of creating and discarding threads continuously, a Thread Pool creates and maintains a bounded collection of reusable worker threads.
Think of a busy shipping warehouse:
* Rather than hiring a new worker every time a package arrives and firing them when it is shipped, the warehouse keeps 10 full-time workers on staff.
* Packages land on an incoming conveyor belt (blocking queue).
* Whenever a worker finishes an assignment, they pull the next package off the belt.
* If all workers are busy, packages sit safely in the queue until hands free up.
Incoming Tasks ───► [ Blocking Task Queue ]
│
┌─────────────┼─────────────┐
▼ ▼ ▼
[ Worker 1 ] [ Worker 2 ] [ Worker 3 ] <-- Reusable Threads
2. The Core Interfaces: Executor, ExecutorService, and ScheduledExecutorService
The concurrency framework in java.util.concurrent builds on three layered interfaces:
* Executor: The minimal base interface with a single method: void execute(Runnable command).
* ExecutorService: The full-featured engine. Adds lifecycle management (shutdown(), shutdownNow()), task tracking, and the ability to submit returnable Callable tasks via Future<T> submit(...).
* ScheduledExecutorService: Extends ExecutorService to support delayed or periodic task execution (replacing the legacy java.util.Timer).
3. Working with ThreadPoolExecutor
While factory helpers exist in java.util.concurrent.Executors, production systems configure ThreadPoolExecutor directly to maintain control over system bounds.
import java.util.concurrent.*;
public class CustomPoolDemo {
public static void main(String[] args) {
int corePoolSize = 4;
int maxPoolSize = 8;
long keepAliveTime = 60L;
// Bounded queue prevents OutOfMemoryError under heavy traffic spikes
BlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(100);
ThreadPoolExecutor executor = new ThreadPoolExecutor(
corePoolSize,
maxPoolSize,
keepAliveTime,
TimeUnit.SECONDS,
workQueue,
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.CallerRunsPolicy() // Rejection Policy
);
// Submit tasks
for (int i = 1; i <= 10; i++) {
final int taskId = i;
executor.execute(() -> {
System.out.println("Processing task " + taskId + " on " + Thread.currentThread().getName());
});
}
// Orderly teardown
executor.shutdown();
}
}
The 4 Standard Rejection Policies
When the queue fills up and all worker threads up to maxPoolSize are busy, the pool invokes its RejectedExecutionHandler:
* AbortPolicy (Default): Throws a RejectedExecutionException.
* CallerRunsPolicy: Forces the thread that submitted the task (often the main thread) to execute it. This throttles incoming load naturally.
* DiscardPolicy: Silently drops the task with no error.
* DiscardOldestPolicy: Discards the unhandled task waiting at the head of the queue, then retries submission.
4. Asynchronous Pipelines with CompletableFuture
The classic Future<T> introduced in Java 5 had a flaw: the only way to read its result was calling .get(), which blocks the calling thread until the computation completes.
Java 8 introduced CompletableFuture<T>, enabling non-blocking, event-driven pipelines. You can chain callbacks, combine multiple independent asynchronous operations, and handle errors functionally.
A. Non-Blocking Transformation Chains
import java.util.concurrent.CompletableFuture;
public class CompletableFuturePipeline {
public static void main(String[] args) {
CompletableFuture.supplyAsync(() -> {
// Runs on the common ForkJoinPool by default
simulateSlowNetworkCall();
return "user_id_4812";
})
.thenApply(userId -> {
// Transform value: (String -> UserProfile)
return "ProfileData[" + userId + "]";
})
.thenAccept(profile -> {
// Terminal consumption
System.out.println("Rendered to dashboard: " + profile);
})
.exceptionally(throwable -> {
// Graceful error fallback
System.err.println("Pipeline failed: " + throwable.getMessage());
return null;
});
// Keep main thread alive long enough to see async output
sleep(2000);
}
private static void simulateSlowNetworkCall() {
try { Thread.sleep(800); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
private static void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}
B. Combining Independent Asynchronous Operations (thenCombine)
When you need to fetch data from two separate microservices at the same time and merge the results:
CompletableFuture<Double> priceFuture = CompletableFuture.supplyAsync(() -> fetchProductPrice(99));
CompletableFuture<Double> shippingFuture = CompletableFuture.supplyAsync(() -> fetchShippingCost("US-NY"));
// Wait for both to complete independently, then combine their results
CompletableFuture<Double> totalCostFuture = priceFuture.thenCombine(
shippingFuture,
(price, shipping) -> price + shipping
);
System.out.println("Total checkout: $" + totalCostFuture.join());
5. Modern Concurrency: Virtual Threads (Project Loom)
Java 21 finalized a structural shift in how Java handles concurrency: Virtual Threads.
The Problem with Platform Threads (1:1 Model)
Historically, every java.lang.Thread was a thin wrapper around an Operating System (OS) thread (a 1:1 mapping).
When a platform thread issues a blocking I/O call (reading a file, querying a SQL database, awaiting an HTTP response), the underlying OS thread is parked in an idle state. It cannot do any other work, yet its 1 MB memory footprint remains allocated on the host OS. This limits typical JVMs to several thousand active concurrent connections.
The Virtual Thread Solution (M:N Model)
Virtual threads are lightweight user-mode threads managed directly by the JVM, not the operating system. Millions of virtual threads run on top of a small pool of standard OS carrier threads (an M:N mapping).
Virtual Threads (Millions): [VT 1] [VT 2] [VT 3] [VT 4] [VT 5] ... [VT 1,000,000]
│ │ │
▼ ▼ ▼
Carrier Threads (OS Cores): [ Carrier Thread 1 ] [ Carrier Thread 2 ]
│ │
▼ ▼
Physical CPU: [ Core 1 ] [ Core 2 ]
When code running inside a virtual thread reaches a blocking operation:
* The JVM intercepts the blocking call.
* It unmounts the virtual thread from its carrier thread, storing its execution state on the Java heap.
* The carrier thread is immediately free to run other virtual threads.
* Once the I/O completes, the JVM schedules the virtual thread to mount back onto any available carrier thread and resume execution.
6. Using Virtual Threads in Practice
Virtual threads do not replace platform threads for CPU-heavy tasks like image rendering or cryptography. They are designed specifically for high-throughput, I/O-bound workloads.
Creating Virtual Threads
public class VirtualThreadBasics {
public static void main(String[] args) throws InterruptedException {
// 1. Using Thread.ofVirtual()
Thread vThread = Thread.ofVirtual()
.name("vthread-worker")
.start(() -> System.out.println("Running on virtual thread: " + Thread.currentThread()));
vThread.join();
// 2. Using an ExecutorService dedicated to Virtual Threads
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
final int taskId = i;
executor.submit(() -> {
Thread.sleep(1000); // Does NOT block an OS thread!
return taskId;
});
}
} // try-with-resources calls shutdown() and waits for all 10,000 tasks to finish!
System.out.println("Processed 10,000 concurrent tasks smoothly.");
}
}
> Rule of Thumb for Virtual Threads: Do not pool virtual threads. Pooling exists to conserve expensive resources. Virtual threads are cheap and disposable: allocate one whenever you have an I/O task, and let the garbage collector reclaim it when it finishes.
>
7. Practical Example: High-Throughput Web Health Checker
Let's build a monitoring tool that checks the status of dozens of microservice endpoints simultaneously using an ExecutorService and aggregates their responses with non-blocking collectors.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.*;
public class ServiceHealthChecker {
record Endpoint(String name, String url) {}
record HealthResult(String name, int statusCode, long responseTimeMs) {}
public static void main(String[] args) {
List<Endpoint> endpoints = List.of(
new Endpoint("Auth Service", "https://postman-echo.com/status/200"),
new Endpoint("Billing API", "https://postman-echo.com/status/200"),
new Endpoint("Inventory Gateway", "https://postman-echo.com/status/503"),
new Endpoint("Customer Portal", "https://postman-echo.com/status/200")
);
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3))
.build();
// Use a virtual-thread-per-task executor for high-concurrency network I/O
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<CompletableFuture<HealthResult>> futures = endpoints.stream()
.map(endpoint -> CompletableFuture.supplyAsync(() -> {
long start = System.currentTimeMillis();
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(endpoint.url()))
.timeout(Duration.ofSeconds(4))
.GET()
.build();
HttpResponse<Void> response = client.send(request, HttpResponse.BodyHandlers.discarding());
long duration = System.currentTimeMillis() - start;
return new HealthResult(endpoint.name(), response.statusCode(), duration);
} catch (Exception e) {
long duration = System.currentTimeMillis() - start;
return new HealthResult(endpoint.name(), 500, duration);
}
}, executor))
.toList();
// Await and report all results
futures.stream()
.map(CompletableFuture::join)
.forEach(result -> {
String statusLabel = (result.statusCode() == 200) ? "UP" : "DOWN/DEGRADED";
System.out.printf("[%s] %-20s | Status Code: %d | Latency: %d ms%n",
statusLabel, result.name(), result.statusCode(), result.responseTimeMs());
});
}
}
}
📝 Chapter Summary
* Thread Pools resolve the overhead of raw thread management by keeping worker threads alive and dispatching incoming tasks through a bounded queue.
* Configure ThreadPoolExecutor directly in production so you can pick appropriate core/max capacities and safe Rejection Policies like CallerRunsPolicy.
* CompletableFuture<T> eliminates blocking .get() calls by letting you construct functional, asynchronous data pipelines with thenApply, thenCombine, and exceptionally.
* Platform Threads map 1:1 to heavy OS threads, making blocking calls expensive.
* Virtual Threads (Project Loom) are lightweight user-mode threads managed by the JVM (M:N mapping). When blocked on I/O, they unmount from their carrier thread, unlocking high concurrency with simple imperative code.
* Virtual threads are designed for I/O-bound tasks, not CPU-intensive number crunching, and should never be pooled.