Use virtual threads for high-concurrency, blocking I/O workloads written in thread-per-task style — servers handling many requests that each spend most of their time waiting on sockets, databases, or downstream services. Do not use them for CPU-bound work, and never pool them.
Where they win
The sweet spot is the classic thread-per-request server with lots of in-flight, mostly-waiting requests: REST endpoints fanning out to other services, database calls, message consumers. Each request gets its own virtual thread, writes simple sequential blocking code, and unmounts while waiting — so a handful of carriers serve thousands of concurrent requests. You get reactive-level throughput with imperative-level readability and stack traces.
// Thread-per-request: one virtual thread per task, no pool reuse
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
for (Request r : incoming) {
exec.submit(() -> {
var user = userService.fetch(r.userId()); // blocks -> unmounts
var orders = orderService.fetch(r.userId()); // blocks -> unmounts
respond(r, render(user, orders));
});
}
}
Where they do not help
- CPU-bound work (encoding, hashing, number crunching): a virtual thread holds its carrier the whole time it computes — there is nothing to unmount on. You gain nothing over a platform-thread pool sized to the core count, and you add scheduling overhead. Use a fixed pool or a parallel stream.
- Code dominated by
synchronizedaround I/O or native blocking: pinning negates the benefit (less so after JEP 491 in Java 24, but native frames still pin).
Anti-patterns
Other cautions: heavy ThreadLocal use multiplied across millions of threads can blow up memory (prefer ScopedValue); and migrating a fixed pool to virtual threads can suddenly overwhelm a downstream that the pool was implicitly rate-limiting.
Rule of thumb
If a task spends most of its life blocked waiting, virtual threads scale it almost for free. If it spends most of its life computing, they do not — bound that work to the number of CPUs.