Java virtual threads let you write plain blocking code and still serve hundreds of thousands of concurrent tasks. The JVM, not the operating system, schedules them. You keep the thread-per-request model, drop the reactive complexity, and stop sizing thread pools by guesswork. Virtual threads became final in Java 21 through JEP 444, and they are now a standard tool for I/O-bound services.
Quick Takeaways
- Virtual threads are lightweight threads managed by the JVM. They cost roughly a few hundred bytes to a few KB at creation, versus about 1 MB of reserved stack for a platform thread.
- They shine on I/O-bound workloads (HTTP calls, JDBC, file access). They give no speedup for CPU-bound work.
- Never pool them. Create one per task with
Executors.newVirtualThreadPerTaskExecutor(). - Watch for pinning (
synchronizedblocks around blocking calls on older JDKs) and unbounded resource use downstream.
| Feature | Platform Thread | Virtual Thread |
|---|---|---|
| Scheduler | OS kernel | JVM (ForkJoinPool carrier threads) |
| Memory per thread | ~1 MB stack reserved | Grows on heap, starts tiny |
| Practical limit | Thousands | Millions |
| Creation cost | High | Very low |
| Pooling | Required | Anti-pattern |
| Best for | CPU-bound work | I/O-bound work |
What Are Java Virtual Threads?
A virtual thread is an instance of java.lang.Thread that is not tied to one OS thread. The JVM mounts it on a carrier thread (a platform thread from a ForkJoinPool) when it runs. When the virtual thread hits a blocking operation, the JVM unmounts it and frees the carrier for other work.
The result: blocking is cheap. A thread waiting on a socket no longer wastes an OS thread.
The Problem Virtual Threads Solve
Classic Java servers use one platform thread per request. Each platform thread maps 1:1 to an OS thread. OS threads are expensive, so servers cap pools at a few hundred or a few thousand. When requests block on I/O, throughput hits the pool ceiling long before CPU or network saturates.
Developers escaped this with asynchronous styles: CompletableFuture chains, reactive streams (Project Reactor, RxJava). These scale, but they fragment stack traces, complicate debugging, and break try/catch and ThreadLocal intuition.
Virtual threads restore the simple model. One task, one thread, plain sequential code.
Little’s Law and Why It Matters
Concurrency equals throughput times latency (L = λW). If each request spends 100 ms waiting on I/O and you want 10,000 requests per second, you need about 1,000 requests in flight at once. With 200 platform threads, you cap at about 2,000 requests per second. With virtual threads, the in-flight limit disappears, so throughput is bound by CPU, network, or downstream systems instead.
Syntax Breakdown: Creating Virtual Threads
Java offers several entry points. Each example below is isolated for clarity.
Option 1: Thread.startVirtualThread()
public class StartVirtual {
public static void main(String[] args) throws InterruptedException {
// Starts immediately on a virtual thread
Thread vt = Thread.startVirtualThread(() ->
System.out.println("Running on: " + Thread.currentThread()));
vt.join(); // Wait for completion
}
}
The output prints something like VirtualThread[#21]/runnable@ForkJoinPool-1-worker-1. It shows the virtual thread ID and the carrier it is mounted on.
Option 2: Thread.ofVirtual() Builder
public class BuilderExample {
public static void main(String[] args) throws InterruptedException {
// Named virtual thread, created unstarted
Thread vt = Thread.ofVirtual()
.name("worker-", 0) // worker-0, worker-1, ...
.unstarted(() -> System.out.println(Thread.currentThread().getName()));
vt.start();
vt.join();
}
}
Use the builder when you need names, an uncaught-exception handler, or an unstarted thread. Names matter for debugging and thread dumps.
Option 3: Executors.newVirtualThreadPerTaskExecutor()
import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;
public class ExecutorExample {
public static void main(String[] args) {
// try-with-resources: close() waits for all submitted tasks
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10_000).forEach(i ->
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // Blocking is cheap here
return i;
}));
} // Implicit close() blocks until all tasks finish
}
}
This program launches 10,000 tasks that each sleep for one second. It finishes in roughly one second of wall time, not 10,000 seconds, and it does so without a 10,000-thread OS cost. ExecutorService implements AutoCloseable as of Java 19, so try-with-resources waits for completion.
How the Scheduler Works
| Concept | Detail |
|---|---|
| Carrier threads | Platform threads in a dedicated ForkJoinPool |
| Default parallelism | Number of available processors |
| Mounting | VT runs on a carrier until it blocks |
| Unmounting | Blocking call parks the VT; its stack is copied to the heap |
| Remounting | When I/O completes, the VT is scheduled on any free carrier |
You can tune the scheduler with system properties such as -Djdk.virtualThreadScheduler.parallelism=N and -Djdk.virtualThreadScheduler.maxPoolSize=N. Defaults suit most workloads.
Practical Implementation: A Concurrent HTTP Fan-Out
Real services call several backends per request. Here is a clean fan-out with virtual threads.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class FanOut {
private static final HttpClient CLIENT = HttpClient.newHttpClient();
static String fetch(String url) throws Exception {
HttpRequest request = HttpRequest.newBuilder(URI.create(url)).GET().build();
// Blocking send(): the virtual thread unmounts while waiting for the network
return CLIENT.send(request, HttpResponse.BodyHandlers.ofString()).body();
}
public static void main(String[] args) throws Exception {
List<String> urls = List.of(
"https://example.com/a",
"https://example.com/b",
"https://example.com/c");
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = new ArrayList<>();
for (String url : urls) {
futures.add(executor.submit(() -> fetch(url)));
}
for (Future<String> f : futures) {
System.out.println(f.get().length() + " bytes");
}
}
}
}
The code reads top to bottom. There are no callbacks and no thenCompose chains. A failure surfaces as an ExecutionException from Future.get() with a normal stack trace.
Structured Concurrency (Preview)
Structured concurrency treats a group of related tasks as one unit of work. In JDK 21 through later releases, StructuredTaskScope has been delivered as a preview API, so you must enable preview features and check the API shape for your JDK version, since it has been revised between releases.
// Requires --enable-preview. API shape varies by JDK release; verify against your version.
import java.util.concurrent.StructuredTaskScope;
public class StructuredExample {
record Page(String user, String orders) {}
static Page load() throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var user = scope.fork(() -> "alice"); // Subtask 1
var orders = scope.fork(() -> "3 orders"); // Subtask 2
scope.join().throwIfFailed(); // Wait; propagate first failure
return new Page(user.get(), orders.get());
}
}
}
If one subtask fails, the scope cancels the others. No orphaned threads leak. Treat this as the direction of travel, and use ExecutorService for production code until the API is final in your target JDK.
Edge Cases and Pitfalls
1. Pinning
A virtual thread is pinned when it cannot unmount from its carrier. Pinning happens when it blocks inside a synchronized block or while a native frame is on the stack. A pinned thread holds the carrier hostage and can starve the scheduler.
In JDK 21 through 23, blocking inside synchronized pins the carrier. JDK 24 (JEP 491) removed most synchronized-related pinning. If you are on JDK 21, prefer ReentrantLock.
import java.util.concurrent.locks.ReentrantLock;
public class SafeCounter {
private final ReentrantLock lock = new ReentrantLock();
private int value;
public int incrementAndFetch() {
lock.lock(); // Virtual-thread friendly: parking unmounts the thread
try {
return ++value;
} finally {
lock.unlock();
}
}
}
Diagnose pinning with -Djdk.tracePinnedThreads=full or the jdk.VirtualThreadPinned JFR event.
2. Do Not Pool Virtual Threads
Pools exist to share expensive resources. Virtual threads are cheap, so pooling them adds contention for no benefit. Create one per task.
3. Limit Concurrency with Semaphores, Not Pool Size
Millions of threads can overwhelm a database or an API. Bound the resource, not the thread count.
import java.util.concurrent.Semaphore;
public class BoundedDb {
// Allow at most 50 concurrent DB calls regardless of thread count
private static final Semaphore DB_PERMITS = new Semaphore(50);
static String query(String sql) throws InterruptedException {
DB_PERMITS.acquire(); // Virtual thread parks cheaply while waiting
try {
return runQuery(sql);
} finally {
DB_PERMITS.release();
}
}
private static String runQuery(String sql) { return "row"; }
}
4. ThreadLocal Memory
Each virtual thread has its own ThreadLocal map. With millions of threads, large thread-local values multiply memory use. Prefer passing data explicitly or evaluate ScopedValue (a preview/evolving API) for immutable context sharing.
5. CPU-Bound Work
Virtual threads are not preemptively time-sliced on CPU. A tight loop holds its carrier. Keep CPU-heavy work on a bounded platform-thread pool or the common ForkJoinPool.
Virtual Threads vs. Alternatives
| Approach | Code Style | Scalability (I/O) | Debuggability | Learning Curve |
|---|---|---|---|---|
| Platform threads (pool) | Blocking | Limited by pool | Excellent | Low |
CompletableFuture |
Async chains | High | Fragmented traces | Medium |
| Reactive (Reactor/RxJava) | Declarative streams | Very high | Hard | High |
| Virtual threads | Blocking | Very high | Excellent | Low |
| Kotlin coroutines | Suspend functions | Very high | Good | Medium |
Reactive frameworks still win when you need backpressure, stream composition operators, or event-driven pipelines. For plain request/response services, virtual threads deliver comparable scalability with far simpler code.
Best Practices and Refactoring: Bad Code vs. Good Code
Anti-Pattern 1: Pooling Virtual Threads
// BAD: Fixed pool defeats the purpose of virtual threads
ExecutorService pool = Executors.newFixedThreadPool(200, Thread.ofVirtual().factory());
// GOOD: One virtual thread per task, no pool sizing
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Anti-Pattern 2: Blocking Inside synchronized (JDK 21)
// BAD on JDK 21-23: Pins the carrier during the network call
public synchronized String load(String url) throws Exception {
return httpClient.send(request(url), BodyHandlers.ofString()).body();
}
// GOOD: Lock only guards the state, not the blocking call
private final ReentrantLock lock = new ReentrantLock();
public String load(String url) throws Exception {
String body = httpClient.send(request(url), BodyHandlers.ofString()).body();
lock.lock();
try {
cache.put(url, body); // Short critical section
} finally {
lock.unlock();
}
return body;
}
Anti-Pattern 3: Reactive Wrapper Around Blocking Code
// BAD: Async ceremony hides simple blocking logic
CompletableFuture<User> f = CompletableFuture
.supplyAsync(() -> userRepo.find(id), ioPool)
.thenApplyAsync(u -> enrich(u), ioPool);
// GOOD: Straight-line code on a virtual thread
User user = userRepo.find(id);
User enriched = enrich(user);
Framework Support
| Framework | How to Enable |
|---|---|
| Spring Boot 3.2+ | spring.threads.virtual.enabled=true |
| Helidon 4 | Built on virtual threads by default |
| Quarkus | @RunOnVirtualThread annotation |
| Tomcat / Jetty | Configure a virtual-thread executor |
Check your framework’s release notes for exact property names, since support has expanded across versions.
Migration Checklist
- Upgrade to JDK 21 or newer. JDK 24+ reduces
synchronizedpinning. - Replace fixed thread pools for I/O tasks with
newVirtualThreadPerTaskExecutor(). - Audit
synchronizedblocks that wrap blocking calls and swap inReentrantLockon JDK 21-23. - Add semaphores or connection limits for databases and downstream APIs.
- Review
ThreadLocalusage for large objects. - Load test and inspect pinning with JFR before production rollout.
- Update monitoring: thread dumps for virtual threads need
jcmd <pid> Thread.dump_to_file -format=json <file>, since regularjstackdoes not list them by default.
Measuring the Difference
A simple benchmark compares blocking tasks on a fixed pool against virtual threads.
import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;
public class Benchmark {
static void run(String label, java.util.concurrent.ExecutorService executor) {
long start = System.nanoTime();
try (executor) {
IntStream.range(0, 10_000).forEach(i ->
executor.submit(() -> {
Thread.sleep(Duration.ofMillis(500)); // Simulated I/O
return i;
}));
}
System.out.printf("%s: %d ms%n", label, (System.nanoTime() - start) / 1_000_000);
}
public static void main(String[] args) {
run("Fixed pool (200)", Executors.newFixedThreadPool(200));
run("Virtual threads", Executors.newVirtualThreadPerTaskExecutor());
}
}
Expect the fixed pool to take about 25 seconds (10,000 tasks ÷ 200 threads × 0.5 s) and the virtual-thread run to finish in about 0.5 to 1 second. Exact numbers vary by hardware, but the ratio is the point.
When Not to Use Virtual Threads
- CPU-bound computation: more threads than cores add nothing.
- Code relying on thread identity or heavy
ThreadLocalcaches: per-thread caches lose their value when threads are short-lived. - Native-heavy code (JNI) that blocks: native frames pin the carrier.
- Streaming pipelines needing backpressure: reactive tools fit better.
FAQ
What is the difference between virtual threads and platform threads in Java?
A platform thread wraps an OS thread and is scheduled by the kernel. A virtual thread is scheduled by the JVM and mounted on a small pool of carrier platform threads. Virtual threads are far cheaper to create and block, so you can run millions of them concurrently.
Are Java virtual threads faster than platform threads?
Not for a single task. Virtual threads improve throughput for I/O-bound workloads by letting many blocked tasks wait without consuming OS threads. For CPU-bound work, they offer no speedup and can add small overhead.
Which Java version supports virtual threads?
Virtual threads were a preview feature in Java 19 and 20 and became a final, standard feature in Java 21 (JEP 444). Later releases, including Java 24, improve them by largely eliminating synchronized-related pinning.
Should I replace reactive programming with virtual threads?
For most request/response services, yes: virtual threads give similar scalability with simpler code and better stack traces. Keep reactive streams where you need backpressure or complex stream operators.




