← Writing
3 min read

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.

JavaConcurrencyBackend

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.

ClassicExecutor.java
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.

Reactive.java
// 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.

VirtualThreads.java
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.

Locks.java
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.