Virtual threads let plain, readable blocking code scale like async code — the biggest change to Java concurrency in years.
A traditional platform thread is a thin wrapper over an operating-system thread: each costs around a megabyte of stack and a kernel scheduling slot, so servers top out at a few thousand. A request that spends most of its time waiting on a database or HTTP call holds that scarce thread the whole time.
A virtual thread (final in Java 21) is managed by the JVM. It runs on a small pool of carrier platform threads, and when it blocks on I/O, a lock or sleep, the JVM unmounts it and frees the carrier for another virtual thread. You can have millions of them.
Creating them:
Executors.newVirtualThreadPerTaskExecutor()— one new virtual thread per task.Thread.ofVirtual().start(runnable)orThread.startVirtualThread(runnable).
The programming model is ordinary blocking code: write var body = client.send(request, ...), not callbacks. Stack traces, debuggers and profilers work as usual.
Guidelines:
- Don't pool virtual threads. They are cheap; create one per task. To limit concurrency against a fragile resource, use a
Semaphore, not a small pool. - They help I/O-bound workloads with many concurrent tasks. CPU-bound work gains nothing — there are still only as many cores.
- In Java 21, blocking inside a
synchronizedblock pins the virtual thread to its carrier, so frequent long blocking undersynchronizedcan starve the carriers. PreferReentrantLockaround blocking calls on Java 21; JDK 24 removed most pinning. - Be careful with
ThreadLocalcaches of expensive objects — with a million threads you get a million copies.
Frameworks such as Spring Boot, Helidon and Quarkus can run request handling on virtual threads with a configuration switch.
Example
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
public class Virtual {
static final Semaphore DB_PERMITS = new Semaphore(1_000); // limit concurrency, don't pool
static String query(int id) throws InterruptedException {
DB_PERMITS.acquire();
try { Thread.sleep(Duration.ofMillis(100)); return "row " + id; } // simulated blocking I/O
finally { DB_PERMITS.release(); }
}
public static void main(String[] args) {
Instant start = Instant.now();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
int id = i;
executor.submit(() -> query(id)); // one virtual thread per task
}
} // waits for all tasks
// ~1 s: 10,000 calls x 100 ms with at most 1,000 in flight (sequential: ~17 min)
System.out.println(Duration.between(start, Instant.now()).toSeconds() + " s");
}
}Remember
- Virtual threads are cheap JVM-managed threads that unmount while blocked
- Create one per task; limit access to resources with a Semaphore, not a pool
- On Java 21, avoid long blocking inside
synchronized(pinning); useReentrantLock
Watch out for
- Using virtual threads for CPU-heavy computation and expecting a speed-up.