Virtual Threads

Virtual threads, standardized in Java 21, are lightweight JVM-managed threads that let you write simple blocking-style code while scaling to huge numbers of concurrent I/O-bound tasks.

Virtual threads are a lightweight thread implementation managed by the JVM rather than the operating system, finalized as a standard feature in Java 21 (JEP 444) after being previewed in Java 19 and 20 (JEP 425 / 436). They let developers write straightforward, blocking-style, one-thread-per-task code and still scale to hundreds of thousands of concurrent tasks — something previously only achievable with complex asynchronous or reactive programming styles.

What Is a Virtual Thread

A virtual thread is an instance of java.lang.Thread, just like a traditional thread — it has the same API — but it is not permanently backed by a dedicated operating system (OS) thread. Instead, the JVM manages many virtual threads on top of a small pool of OS threads called carrier threads, mounting a virtual thread onto a carrier only while it has actual work to do.

Thread.ofVirtual() creates a virtual thread directly; newVirtualThreadPerTaskExecutor() gives every submitted task its own virtual thread.

Thread vt = Thread.ofVirtual().start(() -> {
    System.out.println("running on: " + Thread.currentThread());
});
vt.join();

// Or via an ExecutorService dedicated to virtual threads
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(() -> System.out.println("task on a virtual thread"));
}

Platform Threads vs. Virtual Threads

A platform thread (the traditional kind, created via new Thread() or a fixed thread pool) is a thin wrapper around a dedicated OS thread for its entire lifetime — OS threads are relatively expensive (megabyte-scale stacks by default, kernel scheduling overhead), so a JVM process can typically only support a few thousand of them at once. A virtual thread's stack is stored on the heap and is small and growable, and virtual threads are scheduled by the JVM itself, not the OS — so a single JVM can support millions of virtual threads.

Platform thread : 1:1 with an OS thread; ~thousands feasible per JVM; OS-scheduled
Virtual thread   : many:few with OS carrier threads; ~millions feasible; JVM-scheduled

Lightweight Concurrency

Because virtual threads are cheap to create and cheap to keep idle, the traditional advice to pool and reuse threads mostly disappears — you are encouraged to create a new virtual thread per task/request rather than sharing a fixed-size pool. This mirrors how the JDK's own Executors.newVirtualThreadPerTaskExecutor() is designed: unbounded, one virtual thread per submitted task.

Launching 100,000 tasks, each blocking briefly, would exhaust platform-thread pools quickly, but is routine for virtual threads.

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 100_000; i++) {
        int id = i;
        executor.submit(() -> {
            // simulate blocking I/O
            try { Thread.sleep(100); } catch (InterruptedException ignored) {}
            return id;
        });
    }
} // executor.close() waits for all submitted tasks to finish

Blocking I/O Suitability

Virtual threads shine specifically for I/O-bound, blocking-style workloads: network calls, database queries, file I/O — anything where a thread spends most of its time waiting rather than computing. When a virtual thread blocks on JDK-supported blocking operations (most blocking I/O, Thread.sleep, many java.util.concurrent operations), the JVM unmounts it from its carrier thread, freeing that carrier to run other virtual threads, and remounts it (potentially on a different carrier) once the operation completes.

This unmount/remount is transparent to your code — you write ordinary sequential, blocking-style logic and get the scalability benefits without callbacks, futures, or reactive operators.

Scheduler Concept

Virtual threads are scheduled by a JVM-internal scheduler (a work-stealing ForkJoinPool by default) onto a limited set of carrier platform threads — by default sized to the number of available processors. This is an M:N scheduling model: many virtual threads (M) time-share a smaller number of carrier threads (N). The scheduler decides when to mount a runnable virtual thread onto a free carrier.

The specific scheduler implementation (a ForkJoinPool with default parallelism equal to available processors) is a JVM implementation detail, not a language guarantee — treat it as 'typical HotSpot behavior' rather than a specification requirement.

Pinning Considerations

In certain situations a virtual thread cannot be unmounted from its carrier and stays 'pinned' — most notably while executing code inside a synchronized block or method, or while inside a native method or a foreign function call. A pinned virtual thread blocked on I/O ties up its carrier thread for the duration, which defeats the scalability benefit and, if many virtual threads pin simultaneously, can exhaust the small carrier pool.

Replacing synchronized with ReentrantLock around blocking operations avoids pinning virtual threads to their carriers.

// Pinning risk: a synchronized block holding a virtual thread during blocking I/O
synchronized (lock) {
    // any blocking call here pins the carrier thread
    someBlockingNetworkCall();
}

// Prefer java.util.concurrent.locks.ReentrantLock, which does not pin
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
    someBlockingNetworkCall(); // does not pin the carrier
} finally {
    lock.unlock();
}

Not a Replacement for CPU Parallelism

Virtual threads solve a concurrency problem (handling many simultaneously waiting tasks cheaply), not a parallelism problem. For CPU-bound work — heavy computation with little or no blocking — virtual threads offer no advantage over platform threads, because the actual limiting factor is available CPU cores, not thread creation cost. Running more virtual threads than you have cores does not make CPU-bound computation finish faster; use a bounded, sized thread pool (or the fork/join framework) for compute-heavy parallel work instead.

It is a common misconception to call virtual threads 'faster threads' — they are not inherently faster per unit of work; they are cheaper to create and hold idle, which matters enormously for I/O concurrency and not at all for raw compute throughput.

Structured Concurrency (Related, Preview)

Virtual threads are often paired with the structured concurrency API (a preview feature in recent JDKs, evolving across releases) which treats a group of related subtasks running on their own virtual threads as a single unit of work with a well-defined lifetime, simplifying error handling and cancellation compared to unstructured thread/executor use. This is a separate, evolving API from virtual threads themselves and worth exploring once virtual threads are comfortable.

Java Version Context

Virtual threads were introduced as a preview feature in Java 19 (JEP 425) and refined in a second preview in Java 20 (JEP 436), then finalized as a standard, non-preview feature in Java 21 (JEP 444). Code targeting Java 21 or later can use Thread.ofVirtual() and Executors.newVirtualThreadPerTaskExecutor() directly without any preview flags.

Practical Usage

The most common adoption pattern is swapping a fixed-size platform-thread pool used for handling requests (e.g. in a web server's request-handling layer) for Executors.newVirtualThreadPerTaskExecutor(), so each incoming request gets its own virtual thread and ordinary blocking calls (JDBC queries, HTTP client calls) no longer need to be pooled carefully to avoid thread exhaustion.

import java.util.concurrent.Executors;
import java.util.concurrent.ExecutorService;

public class RequestHandler {
    private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

    void handleRequest(Runnable requestLogic) {
        executor.submit(requestLogic); // one virtual thread per request
    }
}

Key Takeaways

Virtual threads are lightweight, JVM-managed threads standardized in Java 21, using the same java.lang.Thread API as platform threads.

They use an M:N model, mounting onto a small pool of carrier platform threads only while doing actual work.

They excel at high-concurrency, blocking-style I/O workloads, not CPU-bound computation.

Pinning (inside synchronized blocks, or native calls) ties a virtual thread to its carrier and should be avoided for blocking operations — prefer ReentrantLock.

Virtual threads are not 'faster threads' — they reduce the cost of concurrency, not the cost of computation.

Create a new virtual thread per task rather than pooling them, unlike the traditional platform-thread-pool pattern.

ExecutorService pool = Executors.newFixedThreadPool(200); // pooling virtual threads

ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); // unbounded, one per task

Virtual threads are meant to be created freely per task, not pooled like expensive platform threads; pooling them defeats their design and adds needless complexity.

// CPU-bound matrix multiplication spread across 10,000 virtual threads for (int i = 0; i < 10_000; i++) Thread.ofVirtual().start(() -> heavyMathWork());

// CPU-bound work: use a bounded pool sized to available cores ExecutorService pool = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()); for (int i = 0; i < 10_000; i++) pool.submit(() -> heavyMathWork());

Virtual threads don't add CPU parallelism beyond available cores; flooding CPU-bound work across many virtual threads just adds scheduling overhead without speeding up computation.

synchronized (lock) { HttpResponse<String> resp = client.send(request, BodyHandlers.ofString()); // blocking call while pinned } lock.lock(); try { HttpResponse<String> resp = client.send(request, BodyHandlers.ofString()); } finally { lock.unlock(); }

Blocking I/O inside a synchronized block pins the virtual thread to its carrier for the whole blocking duration; a java.util.concurrent.locks.Lock does not pin.

What is the fundamental structural difference between a platform thread and a virtual thread?

A platform thread is a 1:1 wrapper around a dedicated OS thread for its whole life. A virtual thread is JVM-managed, stored with a small heap-allocated stack, and is scheduled M:N onto a small pool of carrier platform threads, mounting only while it has runnable work.

Why can a JVM support millions of virtual threads but only thousands of platform threads?

Platform threads each require a dedicated OS thread with a relatively large, fixed-size stack and OS-level scheduling overhead. Virtual threads have small, growable heap-allocated stacks and are scheduled entirely by the JVM, so their per-thread cost is far lower.

What does it mean for a virtual thread to be 'pinned', and what commonly causes it?

A pinned virtual thread cannot be unmounted from its carrier thread while blocked — the carrier stays occupied for the whole blocking operation. It's commonly caused by blocking inside a synchronized block/method or during native/foreign function calls.

Why are virtual threads not a solution for CPU-bound workloads?

Virtual threads reduce the cost of thread creation and blocking, not the number of available CPU cores. CPU-bound work is limited by actual processor throughput, so running it across more virtual threads than there are cores does not make it complete faster.

In what Java version did virtual threads become a standard (non-preview) feature, and what was the JEP?

Java 21, via JEP 444, after previews in Java 19 (JEP 425) and Java 20 (JEP 436).

Why is it recommended to create a new virtual thread per task rather than reuse a pool of them?

Virtual threads are designed to be cheap and disposable; pooling them (as you would expensive platform threads) adds unnecessary complexity and defeats their intended usage pattern of unbounded, per-task creation.

What replacement is recommended for synchronized blocks that wrap blocking operations on virtual threads, and why?

java.util.concurrent.locks.ReentrantLock, because acquiring it does not pin the virtual thread to its carrier the way a synchronized block does, allowing the carrier to be freed for other virtual threads during a blocking call.

Write a program that launches 10 virtual threads, each printing its thread description, and waits for all to finish.

import java.util.ArrayList; import java.util.List; public class VirtualThreadDemo { public static void main(String[] args) throws InterruptedException { List<Thread> threads = new ArrayList<>(); for (int i = 0; i < 10; i++) { int id = i; Thread t = Thread.ofVirtual().start(() -> System.out.println("task " + id + " on " + Thread.currentThread())); threads.add(t); } for (Thread t : threads) t.join(); System.out.println("all done"); } }

Thread.ofVirtual().start(...) creates and starts a virtual thread per task; join() on each ensures the main thread waits for every virtual thread before printing 'all done'. Output order of the 10 tasks is not guaranteed since they run concurrently.

Will running these 3 CPU-heavy tasks on virtual threads finish faster than running them on 3 platform threads, on a 4-core machine?

No, not meaningfully faster. Since this work is CPU-bound (a tight computational loop, no blocking), virtual threads offer no scheduling advantage over platform threads — both are ultimately limited by the same available CPU cores, and virtual threads only pay off for blocking/I/O-bound workloads.

Write code demonstrating the recommended pattern for handling many concurrent blocking HTTP-style requests using virtual threads.

import java.util.concurrent.*; public class RequestSimulator { static String simulateBlockingCall(int id) throws InterruptedException { Thread.sleep(50); // simulate blocking I/O return "response " + id; } public static void main(String[] args) throws InterruptedException { try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) { for (int i = 0; i < 1000; i++) { int id = i; executor.submit(() -> { try { System.out.println(simulateBlockingCall(id)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); } } // executor.close() blocks until all 1000 tasks complete } }

This launches 1000 concurrent blocking tasks, each its own virtual thread, without exhausting OS threads — something that would require careful pool sizing or async code with platform threads, but is routine with virtual threads because idle (blocked) virtual threads unmount from their carriers.

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 3; i++) {
        int n = i;
        executor.submit(() -> {
            for (int j = 0; j < 1_000_000_000L; j++) { /* pure CPU loop */ }
            return n;
        });
    }
}