Virtual Threads: Lightweight Concurrency in the JVM
For decades, Java developers faced a tradeoff: either use expensive platform threads that map 1:1 to operating system threads, or embrace complex asynchronous programming with callbacks and futures. Virtual threads, introduced as a preview in Java 19 and finalized in Java 21, eliminate this choice by providing cheap, plentiful threads that run on top of a small pool of carrier threads.
The Platform Thread Problem
Traditional Java threads are platform threads—direct wrappers around OS threads. Creating an OS thread allocates significant memory (typically 1-2 MB for stack space) and involves kernel scheduling overhead. A server handling 10,000 concurrent requests would need 10,000 threads, consuming 10-20 GB of memory just for stacks before doing any actual work.
This scarcity forced developers into two camps. Either they limited thread pools and risked thread starvation when threads blocked on I/O, or they adopted reactive programming models like RxJava or Project Reactor. Reactive code avoids blocking but introduces complexity: callback chains, backpressure handling, and debugging stack traces that span multiple schedulers.
How Virtual Threads Work
Virtual threads decouple application concurrency from OS concurrency. Instead of each thread being an OS resource, virtual threads are managed entirely by the JVM. When a virtual thread encounters a blocking operation—reading from a socket, waiting on a lock, sleeping—the JVM automatically unmounts it from its carrier thread. The carrier thread becomes available to run other virtual threads while the original thread remains logically blocked.
This is fundamentally different from async/await in languages like JavaScript or Python. Virtual threads maintain the full semantics of blocking code. Your code looks synchronous, but the runtime handles the scheduling. There’s no color distinction between blocking and non-blocking functions, no special syntax, and no refactoring required to make existing blocking libraries work.
The memory footprint is dramatically smaller. A virtual thread’s initial stack might be only a few kilobytes, growing as needed. Running a million virtual threads is feasible on modest hardware—something impossible with platform threads.
When Virtual Threads Matter
Virtual threads shine in I/O-bound applications where threads spend most of their time waiting. Web services making database queries, microservices calling other APIs, batch jobs reading files—these all benefit immediately. Code that was artificially constrained by thread pool sizes can now scale to match actual concurrency demands.
They’re less beneficial for CPU-bound work. If your application spends most of its time computing rather than waiting, you still want a thread pool sized to your CPU core count. Virtual threads don’t make calculations faster; they just make waiting cheaper.
Database connection pooling becomes interesting with virtual threads. Traditionally, connection pools existed partly to limit threads and partly to reuse expensive connections. With virtual threads, the thread scarcity problem disappears, but connection reuse remains valuable. The pattern shifts: you might keep the pool but size it differently, or adopt connection-per-request patterns that were previously impractical.
Adoption Considerations
Most blocking Java code runs unchanged on virtual threads. The JVM’s built-in I/O operations—sockets, files, locks—all support the mounting and unmounting mechanism. Third-party libraries that use standard Java concurrency primitives typically work without modification.
The main pitfall is synchronized blocks and methods. When a virtual thread enters a synchronized block, the JVM pins it to its carrier thread, preventing unmounting. Long-running synchronized blocks can starve the carrier pool. The fix is migrating to ReentrantLock or other java.util.concurrent primitives that support virtual thread scheduling.
Thread-local variables also warrant attention. With cheap threads, patterns that store per-request state in thread locals can create more instances than expected. Scoped values, introduced alongside virtual threads, provide a better alternative for passing context through call stacks without unbounded allocation.
The Broader Pattern
Virtual threads represent a return to simpler concurrency models enabled by better runtime implementation. Rather than pushing complexity onto application developers, the JVM handles the mechanical work of scheduling lightweight tasks onto real threads. This mirrors trends in other ecosystems—Go’s goroutines, Erlang’s processes, Kotlin coroutines—where the language or runtime provides cheap concurrency primitives.
The result is code that’s easier to write, read, and maintain, with performance characteristics that previously required expert-level async programming. For the broad middle of server-side Java applications, virtual threads remove a longstanding architectural constraint.