The Problem with Platform Threads
Platform threads (the traditional Thread class) are thin wrappers around OS threads. Each one consumes:
- ~1 MB of stack memory
- Kernel resources for scheduling
- Expensive context switches (~1-10 μs)
This means a typical server can sustain only thousands of platform threads before running out of memory or spending all its time context-switching.
But modern servers handle blocking I/O — calling databases, APIs, file systems — where threads spend most of their time waiting. A thread blocked on a network call is occupying 1 MB of RAM and an OS thread slot while doing nothing.
The Throughput Dilemma

Before Java 21, you had two choices:
- Thread-per-request — Simple code, but limited to ~thousands of concurrent requests.
- Reactive/Async — Scales well, but code becomes complex callback chains that are hard to read and debug.
Virtual threads give you the simplicity of option 1 with the scalability of option 2.
How Virtual Threads Work
Virtual threads are managed by the JVM, not the OS. The JVM maintains a small pool of carrier threads (platform threads, typically one per CPU core) and mounts virtual threads onto them:

When a virtual thread blocks (I/O, sleep, lock acquisition), the JVM unmounts it from the carrier thread and stores its state on the heap. The carrier thread is immediately freed to run another virtual thread. When the blocking operation completes, the virtual thread is mounted back onto an available carrier thread and resumes.
This mounting/unmounting is vastly cheaper than an OS context switch — it’s just copying a small stack frame to/from the heap.
Creating Virtual Threads
Direct Creation
// Create and start a virtual thread
Thread vThread = Thread.ofVirtual().start(() -> {
System.out.println("Running on: " + Thread.currentThread());
});
// Create without starting
Thread vThread2 = Thread.ofVirtual().unstarted(() -> {
System.out.println("Will run on a virtual thread");
});
vThread2.start();
// Named virtual thread (useful for debugging)
Thread vThread3 = Thread.ofVirtual()
.name("worker-", 1) // worker-1, worker-2, etc.
.start(() -> doWork());
Platform Thread for Comparison
Thread platformThread = Thread.ofPlatform().start(() -> {
System.out.println("Running on: " + Thread.currentThread());
});
ExecutorService with Virtual Threads
// One virtual thread per task — the recommended approach
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
executor.submit(() -> {
// Each task gets its own virtual thread
String result = callExternalService(); // Blocking — but cheap!
processResult(result);
});
}
} // Auto-closes and waits for all tasks to complete
This creates 100,000 virtual threads — something impossible with platform threads (would require ~100 GB of stack memory). With virtual threads, the JVM handles it with a small carrier pool.
When Virtual Threads Unmount
The JVM unmounts a virtual thread from its carrier when it encounters:
| Blocking Operation | Unmounts? | Notes |
|---|---|---|
Thread.sleep() | ✓ | Frees carrier immediately |
ReentrantLock.lock() | ✓ | Unmounts while waiting for lock |
Semaphore.acquire() | ✓ | Unmounts while waiting for permit |
BlockingQueue.take() | ✓ | Unmounts while queue is empty |
| TCP/UDP Socket I/O | ✓ | Network reads/writes |
Future.get() | ✓ | Waiting for async result |
synchronized block (Java 21) | ✗ PINS | Fixed in Java 24 (JEP 491) |
| Native/JNI calls | ✗ PINS | Can’t unmount — native stack |
| CPU-intensive work | N/A | No blocking — stays mounted |
Pinning
Pinning occurs when a virtual thread cannot be unmounted. The carrier thread is stuck until the virtual thread finishes the pinned operation. This reduces throughput because other virtual threads can’t use that carrier.
In Java 21-23, synchronized pins the carrier. Starting with Java 24 (JEP 491), synchronized blocks properly unmount virtual threads.
For Java 21-23, prefer ReentrantLock over synchronized in hot paths:
// AVOID in Java 21-23 (pins carrier thread):
public synchronized String getData() {
return httpClient.get("http://api.example.com/data"); // Pins entire I/O wait!
}
// PREFER (unmounts while waiting for lock AND during I/O):
private final ReentrantLock lock = new ReentrantLock();
public String getData() {
lock.lock();
try {
return httpClient.get("http://api.example.com/data");
} finally {
lock.unlock();
}
}
Performance Characteristics
What Virtual Threads Improve
- Throughput — More concurrent requests with the same hardware.
- Resource efficiency — Thousands of blocked threads without GB of wasted RAM.
- Code simplicity — Write blocking code without callback hell.
What Virtual Threads Do NOT Improve
- Single-request latency — Each individual request takes the same time.
- CPU-bound workloads — If threads never block, virtual threads add no benefit.
- Algorithmic performance — The computation itself isn’t faster.
Best Practices
DO
// 1. Use newVirtualThreadPerTaskExecutor — let JVM manage the pool
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = tasks.stream()
.map(task -> executor.submit(task))
.toList();
}
// 2. Write simple, blocking code — it's efficient with virtual threads
String data = httpClient.send(request, BodyHandlers.ofString()).body(); // Blocking is OK!
// 3. Use try-with-resources for executors (Java 21+)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// executor.close() waits for completion
}
DON’T
// 1. DON'T create fixed-size virtual thread pools — defeats the purpose
ExecutorService BAD = Executors.newFixedThreadPool(100); // Platform threads!
// Virtual threads should be unlimited — JVM manages carrier pool internally
// 2. DON'T use virtual threads for CPU-bound work
// No benefit — they'll just stay mounted on carriers the whole time
// 3. DON'T pool virtual threads — they're cheap to create
// Unlike platform threads, there's no reason to reuse them
// 4. DON'T rely on thread priority — virtual threads ignore it
Thread.ofVirtual().start(() -> {
Thread.currentThread().setPriority(Thread.MAX_PRIORITY); // Ignored!
});
Virtual Threads Properties
| Property | Virtual Thread | Platform Thread |
|---|---|---|
| Stack memory | ~few KB (grows on demand) | ~1 MB (pre-allocated) |
| OS resource | None (heap-only) | 1:1 with OS thread |
| Daemon | Always true | Configurable |
| Priority | Ignored | Respected |
| Thread group | Fixed VirtualThreads | Configurable |
| Max count | Millions | Thousands |
| Context switch cost | ~nanoseconds (mount/unmount) | ~microseconds (OS) |
| Debugging | Harder (many threads) | Familiar |
Migration Strategy
Existing Code
Most existing blocking code benefits immediately from virtual threads. The migration is often just changing the executor:
// Before (platform threads)
ExecutorService executor = Executors.newFixedThreadPool(200);
// After (virtual threads) — just change this one line
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Spring Boot (3.2+)
# application.properties
spring.threads.virtual.enabled=true
This makes all request-handling threads virtual — an entire web server upgrade in one config line.
Structured Concurrency (Preview — Java 21+)
// Structured Concurrency scopes virtual thread lifetime to a code block
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<String> user = scope.fork(() -> fetchUser(userId));
Subtask<List<Order>> orders = scope.fork(() -> fetchOrders(userId));
scope.join(); // Wait for both
scope.throwIfFailed(); // Propagate exceptions
return new UserProfile(user.get(), orders.get());
}
// If one fails, the other is cancelled automatically
// No thread leaks — scope guarantees cleanup
Virtual Threads vs Reactive Programming
| Aspect | Virtual Threads | Reactive (Project Reactor, RxJava) |
|---|---|---|
| Code style | Sequential/blocking | Chained operators/callbacks |
| Readability | Familiar, imperative | Steep learning curve |
| Debugging | Stack traces work | Stack traces are cryptic |
| Back-pressure | Thread blocking = natural back-pressure | Explicit operators needed |
| Ecosystem maturity | New (Java 21) | Battle-tested |
| Performance | Excellent for I/O | Excellent for I/O |
| CPU overhead | Slightly higher | Lower (fewer allocations) |
| Migration effort | Change executor | Rewrite entire codebase |
For most applications, virtual threads provide equivalent throughput to reactive frameworks with dramatically simpler code.
Summary
- Virtual threads are lightweight threads managed by the JVM, not the OS.
- They unmount from carrier threads during blocking operations, freeing carriers for other work.
- Write simple blocking code — the JVM makes it efficient.
- Use
Executors.newVirtualThreadPerTaskExecutor()— don’t pool virtual threads. - Best for I/O-heavy workloads (servers, microservices, database access).
- No benefit for CPU-bound computation.
- Thread safety rules are identical to platform threads — you still need synchronization.
- In Java 21-23, prefer
ReentrantLockoversynchronizedto avoid pinning.
References
- JEP 444: Virtual Threads
- Oracle Java Docs — Virtual Threads
- JEP 491: Synchronize Virtual Threads without Pinning
- Oracle Java Magazine — Exploring the Design of Java’s New Virtual Threads
- Oracle Java Magazine — Virtual Threads: Are Futures a Thing of the Past?
- HappyCoders.eu — Virtual Threads in Java