Java

Java Virtual Threads Explained: Simpler, Scalable Concurrency

Thousands of thin light threads spun from a spool beside a few heavy ropes

For years, Java developers faced an uncomfortable trade-off in concurrent programming: write simple, readable blocking code that doesn’t scale, or write fast, scalable asynchronous code that’s hard to read and debug. Virtual threads, delivered through Project Loom, largely erase that trade-off. This guide explains what they are, how they work, and why they’re one of the most important additions to modern Java.

The old problem: threads were expensive

In Java, the natural way to handle a request is “one thread per request.” It’s beautifully simple: each request gets its own thread, runs top to bottom, and blocking calls (a database query, an API call) just… block. The code reads like a straightforward story.

The catch is that a traditional Java thread — now called a platform thread — is a thin wrapper around an operating-system thread. OS threads are heavy: each reserves a large chunk of memory for its stack, and the OS can only juggle so many. In practice a server tops out at a few thousand platform threads. So under the one-thread-per-request model, a few thousand concurrent requests — many of them just waiting on I/O — was your ceiling.

To scale past that, developers turned to asynchronous, non-blocking styles: callbacks, futures, reactive streams. These scale well, but the cost is complexity. The clean linear story fractures into chains of callbacks, stack traces become useless, and debugging gets painful. You traded readability for scalability.

The virtual thread idea

Virtual threads change the economics. A virtual thread is a lightweight thread managed by the JVM, not the OS. It is cheap — a virtual thread starts with a tiny stack that grows as needed, so you can create millions of them without running out of memory.

Here’s the clever part. Many virtual threads are multiplexed onto a small pool of platform threads (called carrier threads). When a virtual thread runs, it’s mounted on a carrier. But the moment it hits a blocking I/O call — waiting on the network, say — the JVM unmounts it: the virtual thread is parked, and its carrier thread is immediately freed to run another virtual thread. When the I/O completes, the virtual thread is remounted and continues.

The result is the best of both worlds: you write simple blocking code, and the runtime quietly delivers the scalability that previously required async gymnastics.

Seeing it in code

The API is deliberately familiar. Starting a virtual thread looks almost like starting any thread:

Thread.startVirtualThread(() -> {
    System.out.println("Hello from a virtual thread");
});

The more common pattern is an executor. Compare a traditional fixed pool with the virtual-thread version:

// Old: capped at 200 threads — the 201st task waits
var pool = Executors.newFixedThreadPool(200);

// New: a fresh virtual thread per task, scaling to huge numbers
var vpool = Executors.newVirtualThreadPerTaskExecutor();

try (vpool) {
    for (int i = 0; i < 1_000_000; i++) {
        vpool.submit(() -> {
            // simple BLOCKING code — and that's fine now
            var data = callSlowService();
            process(data);
        });
    }
}

A million tasks, each written in plain blocking style, is entirely reasonable with virtual threads. With platform threads it would be impossible. Crucially, the code inside the task didn’t change — no callbacks, no reactive operators, just readable sequential logic.

Virtual vs. platform threads at a glance

  • Platform thread — wraps one OS thread; heavy (large stack); limited to a few thousand; ideal for CPU-bound work that keeps a core busy.
  • Virtual thread — scheduled by the JVM onto carrier threads; extremely light; millions possible; ideal for I/O-bound work that spends its time waiting.

The mental model: platform threads are a scarce resource you pool carefully; virtual threads are cheap and disposable — you create one per task without a second thought.

When virtual threads help (and when they don’t)

Virtual threads shine for I/O-bound, high-concurrency workloads — exactly what most web applications are. A service that spends its time waiting on databases and downstream APIs can now handle vastly more concurrent requests with the simple thread-per-request model. Modern frameworks such as Spring Boot can run request handling on virtual threads, often unlocking this with minimal configuration.

They do not speed up CPU-bound work. If your tasks are busy computing rather than waiting, the bottleneck is the CPU, and having a million threads won’t add cores. For raw computation, sized platform-thread pools remain the right tool.

Two things to watch out for

Virtual threads are easy to adopt, but two gotchas are worth knowing:

  1. Pinning. If a virtual thread runs inside a synchronized block during a blocking call, it can get “pinned” to its carrier thread and unable to unmount — which defeats the scalability benefit. The modern guidance is to prefer ReentrantLock over synchronized around blocking operations in hot paths.
  2. Don’t pool them. Pooling exists to reuse expensive resources. Virtual threads are cheap, so pooling them is an anti-pattern — create a new virtual thread per task and let it end. Use newVirtualThreadPerTaskExecutor(), not a fixed pool.

There’s also a related advance worth knowing: structured concurrency, which treats a group of related tasks as a single unit of work with clean cancellation and error handling — a natural companion to virtual threads for writing correct concurrent code.

The takeaway

Virtual threads let Java scale to enormous concurrency without giving up the simple, readable, blocking style that made the language pleasant to write. Reach for them on I/O-bound, high-concurrency services; keep platform threads for CPU-bound work; watch out for pinning and don’t pool them. If you learned Java concurrency the hard way with callbacks and reactive chains, virtual threads are the upgrade you’ve been waiting for — and a great companion to the modern features in our new Java 25 features guide.

Frequently Asked Questions

What are virtual threads in Java?

Virtual threads are lightweight threads managed by the Java runtime rather than the operating system. Millions can exist at once because each uses very little memory, letting you write simple blocking code that scales to huge numbers of concurrent tasks.

What is the difference between virtual threads and platform threads?

A platform thread is a thin wrapper over an OS thread — heavy, limited to a few thousand per machine. A virtual thread is scheduled by the JVM onto a small pool of platform threads and is cheap to create, so you can have millions. When a virtual thread blocks on I/O, the JVM parks it and reuses the underlying platform thread for other work.

Do virtual threads make Java code faster?

They increase throughput for I/O-bound workloads by letting far more tasks run concurrently, not by making any single task faster. For CPU-bound work they offer little benefit, since the bottleneck is compute, not waiting.

Do I need to rewrite my code to use virtual threads?

Usually very little. Virtual threads implement the same Thread API, so existing blocking code often works as-is. The main change is creating threads via the virtual-thread executor instead of a fixed platform-thread pool, and avoiding long-held locks that pin a virtual thread.



Related Articles

Interactive Spring Boot Course SPA
Java

Interactive Spring Boot Course SPA

Spring Boot Mastery Course Spring Boot Mastery Overview Modules Module 1: Core Module 2: REST APIs Module 3: Data Module 4: Advanced Project Capstone Project Extras Interview Prep