Executors, CompletableFuture and Virtual Threads in Java
Codevity Admin · 10/25/2026
Codevity Admin · 10/25/2026
Creating a raw Thread for every task is wasteful: threads are expensive to create and an unbounded number of them can exhaust memory. A thread pool reuses a fixed set of threads and queues the extra work. Java gives you this through the ExecutorService interface.
submit() accepts a Runnable (no result) or a Callable (returns a value and may throw) and gives back a Future, a handle to a result that is not ready yet. future.get() blocks until it is.
import java.util.*;
import java.util.concurrent.*;
public class PoolDemo {
public static void main(String[] args) throws Exception {
try (ExecutorService pool = Executors.newFixedThreadPool(3)) { // close() waits for the tasks (Java 19+)
List<Callable<Integer>> tasks = new ArrayList<>();
for (int i = 1; i <= 5; i++) {
int n = i;
tasks.add(() -> n * n);
}
int sum = 0;
for (Future<Integer> future : pool.invokeAll(tasks)) {
sum += future.get();
}
System.out.println(sum); // 55
int zero = 0;
Future<Integer> failing = pool.submit(() -> 10 / zero);
try {
failing.get();
} catch (ExecutionException e) {
System.out.println(e.getCause()); // java.lang.ArithmeticException: / by zero
}
}
}
}An exception thrown inside the task is wrapped in an ExecutionException when you call get(). In code written before Java 19, call shutdown() in a finally block, then awaitTermination(...).
| Factory method | Pool shape | Use for |
|---|---|---|
newFixedThreadPool(n) | n threads, unbounded queue | Steady CPU-bound work |
newCachedThreadPool() | Grows on demand, idle threads die after 60 s | Many short tasks (can create too many threads) |
newSingleThreadExecutor() | One thread, tasks run in order | Serialized background work |
newScheduledThreadPool(n) | Delayed and periodic tasks | Timers and polling |
newVirtualThreadPerTaskExecutor() | A new virtual thread per task (Java 21) | Large amounts of blocking I/O |
For production, many teams construct a ThreadPoolExecutor directly, because the Executors factories use unbounded queues or thread counts. Its settings are: core pool size, maximum pool size, keep-alive time, a work queue (bound it) and a rejection policy (what to do when both the queue and the pool are full).
How big should a pool be? For CPU-bound work, about the number of cores. For work that waits on I/O, more, because most threads are idle at any moment.
Future.get() forces you to block. CompletableFuture (Java 8) lets you describe what happens when the result arrives:
import java.util.concurrent.*;
public class Async {
public static void main(String[] args) throws Exception {
CompletableFuture<Integer> price = CompletableFuture.supplyAsync(() -> 100);
CompletableFuture<Integer> tax = CompletableFuture.supplyAsync(() -> 18);
CompletableFuture<Integer> total = price.thenCombine(tax, Integer::sum); // wait for both
System.out.println(total.get()); // 118
CompletableFuture<String> failing = CompletableFuture
.supplyAsync(Async::fetchFromService)
.exceptionally(ex -> "fallback"); // recover from an error
System.out.println(failing.get()); // fallback
String text = CompletableFuture.supplyAsync(() -> "java")
.thenApply(String::toUpperCase) // transform the result
.thenCompose(s -> CompletableFuture.supplyAsync(() -> s + "!")) // chain another async call
.join(); // like get(), without checked exceptions
System.out.println(text); // JAVA!
}
static String fetchFromService() {
throw new IllegalStateException("service down");
}
}| Method | Meaning |
|---|---|
supplyAsync / runAsync | Start work on the common pool (or on an executor you pass in) |
thenApply | Transform the result (like map) |
thenCompose | Chain a step that itself returns a CompletableFuture (like flatMap) |
thenCombine | Combine two independent futures |
allOf / anyOf | Wait for all, or for the first of many |
| / | Recover from an exception |
Pass your own executor to for blocking work, so it does not starve the shared common pool.
is the thread-safe map. Reads do not block, and writes lock only a small part of the table (in Java 8 and later, individual bins). It does not allow null keys or values. Compound actions must use its atomic methods such as , and , not followed by .
Other concurrent collections: (many reads, rare writes), , and the family. wraps every method in a lock but still needs external locking while you iterate.
A normal ("platform") thread maps one-to-one to an operating system thread, so a server can only afford thousands. A virtual thread is a lightweight thread managed by the JVM. When it blocks on I/O, the JVM unmounts it from its carrier thread, so you can run hundreds of thousands at once. You keep the simple blocking style, with no callbacks.
Rules of thumb for virtual threads:
exceptionallyhandlesupplyAsyncConcurrentHashMapmergecomputeputIfAbsentgetputCopyOnWriteArrayListConcurrentLinkedQueueBlockingQueueCollections.synchronizedList(...)synchronizedReentrantLockspring.threads.virtual.enabled=trueCompletableFuture.allOfimport java.util.concurrent.*;
public class SafeCounts {
public static void main(String[] args) throws InterruptedException {
ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>();
Runnable work = () -> {
for (int i = 0; i < 1000; i++) {
counts.merge("hits", 1, Integer::sum); // atomic read-modify-write
}
};
Thread a = new Thread(work);
Thread b = new Thread(work);
a.start();
b.start();
a.join();
b.join();
System.out.println(counts.get("hits")); // 2000
}
}import java.util.concurrent.*;
import java.util.stream.*;
public class VirtualThreads {
public static void main(String[] args) throws Exception {
Thread virtual = Thread.ofVirtual().name("virtual-1").start(
() -> System.out.println("hello from a virtual thread"));
virtual.join();
System.out.println(virtual.isVirtual()); // true
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
var futures = IntStream.range(0, 1000)
.mapToObj(i -> executor.submit(() -> {
Thread.sleep(10); // a blocking call: cheap on a virtual thread
return i;
}))
.toList();
long sum = 0;
for (var future : futures) {
sum += future.get();
}
System.out.println(sum); // 499500
}
}
}