Java Concurrency in 2026: From Executors to Virtual Threads
Threads, thread pools, and why virtual threads (Project Loom) change how you write I/O-heavy Java. A working tour from the classic executor model to the millions-of-threads world.
For most of Java's life, threads were expensive. Each platform thread maps to an OS thread, costs about a megabyte of stack, and takes real time to create. So the whole ecosystem grew around not making threads — pool them, reuse them, guard them carefully. Virtual threads change that assumption, and if you write I/O-heavy backends, they change how you write them.
Here's the progression, from the model you probably learned to the one you should be using now.
The classic model: pool and reuse
You never created raw threads in real code; you handed tasks to an executor backed by a bounded pool.
ExecutorService pool = Executors.newFixedThreadPool(200);
for (Request req : requests) {
pool.submit(() -> handle(req)); // reuse one of 200 threads
}The pool size was a painful trade-off. Too small and requests queue behind each other. Too large and you drown the machine in context-switching and memory. And here's the catch that makes it worse: most server work is I/O-bound — waiting on a database, an HTTP call, a queue. A platform thread blocked on I/O holds its expensive OS thread hostage while doing nothing.
Why blocking hurt so much
Say each request spends 90% of its time waiting on the database. With 200 threads, you can serve ~200 concurrent requests even though the CPU is nearly idle — because the threads, not the CPU, are the bottleneck. The old answer was async, reactive code: callbacks, CompletableFuture chains, reactive streams. It scaled, but it turned readable sequential logic into inside-out spaghetti.
// Scales, but the control flow is inverted and stack traces are useless
fetchUser(id)
.thenCompose(user -> fetchOrders(user))
.thenApply(orders -> summarize(orders))
.exceptionally(err -> fallback(err));Virtual threads: cheap enough to stop pooling
A virtual thread is scheduled by the JVM, not the OS. It costs a few hundred bytes, and when it blocks on I/O, the JVM unmounts it from its carrier thread and runs something else. Millions can exist at once. The killer feature: you write plain, blocking, sequential code, and it scales like async.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (Request req : requests) {
executor.submit(() -> {
var user = fetchUser(req.userId()); // blocks — and that's fine
var orders = fetchOrders(user); // JVM parks the vthread here
return summarize(orders);
});
}
}That code reads top to bottom, has real stack traces, and yet the blocking calls don't waste an OS thread — the JVM parks the virtual thread and reuses the carrier for other work. This is the whole point of Project Loom: the readability of blocking code with the scalability of async.
The one sharp edge: pinning
There's a gotcha. If a virtual thread blocks inside a synchronized block, it can get pinned to its carrier thread — it can't unmount, and you lose the benefit. The fix is to prefer ReentrantLock over synchronized around blocking calls.
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
callSlowService(); // safe to block; the vthread can still unmount
} finally {
lock.unlock();
}What actually changes in practice
The mental shift is real: stop treating threads as scarce. One virtual thread per task, per request, per job. No sizing the pool, no reactive gymnastics for I/O concurrency. Keep bounded pools only for CPU-bound work and for rate-limiting calls to a fragile downstream.
I still reach for a queue when work needs to be durable — surviving a restart, retried on failure. Virtual threads solve in-process concurrency; they don't replace a real queue for that. But for the everyday "fan out a hundred I/O calls and wait," they've quietly made the hardest part of Java concurrency simple.