Java 21 — Virtual Threads Explained with Real Examples
Understand Java virtual threads — how they work, when to use them, migration from thread pools, and real-world performance comparisons with platform threads.
Java 21 introduced virtual threads — lightweight threads managed by the JVM instead of the OS. They’re the biggest concurrency change since Java 5’s java.util.concurrent.
The promise: write simple blocking code (like Thread.sleep, JDBC calls, HTTP requests) and scale to millions of concurrent tasks — without reactive frameworks, without callback hell, without Kotlin coroutines.
The problem with platform threads
Traditional Java threads (now called “platform threads”) map 1:1 to OS threads. Each one:
- Allocates ~1MB of stack memory
- Costs microseconds for context switching
- Is limited by the OS (typically thousands, not millions)
// Try creating 100,000 platform threads
public class PlatformThreadDemo {
public static void main(String[] args) throws Exception {
long start = System.currentTimeMillis();
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 100_000; i++) {
Thread thread = new Thread(() -> {
try { Thread.sleep(1000); }
catch (InterruptedException e) { }
});
thread.start();
threads.add(thread);
}
for (Thread thread : threads) {
thread.join();
}
System.out.println("Time: " + (System.currentTimeMillis() - start) + "ms");
// OutOfMemoryError on most machines
}
}
This crashes. 100,000 threads × 1MB = 100GB of stack memory. Even 10,000 threads strain most servers.
Virtual threads — the solution
Virtual threads are managed by the JVM. They’re mounted onto a small pool of platform threads (carrier threads) and unmounted when blocked.
public class VirtualThreadDemo {
public static void main(String[] args) throws Exception {
long start = System.currentTimeMillis();
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 100_000; i++) {
Thread thread = Thread.ofVirtual().start(() -> {
try { Thread.sleep(1000); }
catch (InterruptedException e) { }
});
threads.add(thread);
}
for (Thread thread : threads) {
thread.join();
}
System.out.println("Time: " + (System.currentTimeMillis() - start) + "ms");
// Completes in ~1 second
}
}
100,000 virtual threads, each sleeping for 1 second, complete in about 1 second total. Virtual threads use only a few KB of memory each and share the carrier threads.
How virtual threads work
Virtual Thread 1 ─┐
Virtual Thread 2 ─┤
Virtual Thread 3 ─┤──→ Carrier Thread 1 (OS thread)
Virtual Thread 4 ─┤──→ Carrier Thread 2 (OS thread)
Virtual Thread 5 ─┤
... │
Virtual Thread N ─┘
When a virtual thread blocks (I/O, sleep, lock):
- The JVM unmounts it from the carrier thread
- The carrier thread picks up another virtual thread
- When the I/O completes, the virtual thread is remounted on any available carrier
From your code’s perspective, Thread.sleep(1000) blocks for 1 second. But the carrier thread isn’t wasted — it serves other virtual threads during that time.
Creating virtual threads
Direct creation
// Start immediately
Thread vt = Thread.ofVirtual().start(() -> {
System.out.println("Running on: " + Thread.currentThread());
});
// Named (useful for debugging)
Thread vt = Thread.ofVirtual()
.name("worker-", 0)
.start(() -> { /* ... */ });
ExecutorService (preferred)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = urls.stream()
.map(url -> executor.submit(() -> fetchUrl(url)))
.toList();
for (Future<String> future : futures) {
System.out.println(future.get());
}
}
newVirtualThreadPerTaskExecutor() creates a new virtual thread for every submitted task. No pool size to configure — that’s the point.
Real-world example: HTTP server
A typical web server handles requests with a thread pool. With 200 threads, it handles 200 concurrent requests. With virtual threads, it handles millions:
// Before: platform thread pool (limited)
var server = HttpServer.create(new InetSocketAddress(8080), 0);
server.setExecutor(Executors.newFixedThreadPool(200));
// After: virtual thread per request (unlimited)
var server = HttpServer.create(new InetSocketAddress(8080), 0);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
Each request gets its own virtual thread. Blocking I/O (database queries, HTTP calls) doesn’t waste a carrier thread.
Spring Boot with virtual threads
# application.yml (Spring Boot 3.2+)
spring:
threads:
virtual:
enabled: true
One line. Every request handler now runs on a virtual thread. Your existing blocking code (JDBC, RestTemplate, etc.) scales without modification.
When virtual threads help
Virtual threads shine when your application spends most of its time waiting on I/O:
- Database queries
- HTTP calls to other services
- File I/O
- Network socket operations
Benchmark: 10,000 concurrent HTTP calls
// Platform threads: 200 threads, processing 10,000 calls
try (var executor = Executors.newFixedThreadPool(200)) {
var futures = IntStream.range(0, 10_000)
.mapToObj(i -> executor.submit(() -> httpClient.send(request, bodyHandler)))
.toList();
futures.forEach(f -> { try { f.get(); } catch (Exception e) {} });
}
// Time: ~50 seconds (200 concurrent, 10,000 / 200 = 50 batches)
// Virtual threads: one per call
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var futures = IntStream.range(0, 10_000)
.mapToObj(i -> executor.submit(() -> httpClient.send(request, bodyHandler)))
.toList();
futures.forEach(f -> { try { f.get(); } catch (Exception e) {} });
}
// Time: ~1 second (all 10,000 concurrent)
50x faster — because all 10,000 requests run concurrently instead of in batches of 200.
When virtual threads don’t help
CPU-bound work
If your threads are doing computation (no I/O), virtual threads add overhead without benefit:
// CPU-bound: virtual threads won't help
executor.submit(() -> {
long result = 0;
for (long i = 0; i < 1_000_000_000L; i++) {
result += i;
}
return result;
});
For CPU work, use ForkJoinPool or platform thread pools sized to the number of CPU cores.
Synchronized blocks (pinning)
When a virtual thread enters a synchronized block while doing I/O, it gets pinned to the carrier thread — the carrier can’t serve other virtual threads:
synchronized (lock) {
// This pins the carrier thread
database.query("SELECT ..."); // I/O inside synchronized = bad
}
Fix: use ReentrantLock instead of synchronized:
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
database.query("SELECT ...");
} finally {
lock.unlock();
}
ReentrantLock doesn’t pin the virtual thread. Java 25 further reduces pinning issues, but the best practice remains: use ReentrantLock for virtual thread-friendly code.
Migration guide
Step 1: Replace thread pools
// Before
ExecutorService executor = Executors.newFixedThreadPool(200);
// After
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Step 2: Remove thread pool tuning
You no longer need to calculate optimal pool sizes. Remove:
corePoolSize/maxPoolSizeconfigurations- Custom
ThreadPoolExecutorconfigurations - Pool size calculations based on I/O vs CPU ratio
Step 3: Replace synchronized with ReentrantLock
Find synchronized blocks that contain I/O operations and replace them.
Step 4: Remove thread-local caching
ThreadLocal is expensive with virtual threads (potentially millions of threads = millions of ThreadLocal values). Use scoped values (Java 25) or pass values explicitly.
Step 5: Test and measure
- Monitor carrier thread utilization
- Look for pinning events (
-Djdk.tracePinnedThreads=short) - Compare throughput before and after
Virtual threads vs Kotlin coroutines
| Aspect | Virtual Threads | Kotlin Coroutines |
|---|---|---|
| Language | Java | Kotlin |
| Mechanism | JVM-managed threads | Compiler-generated state machines |
| Blocking code | Just works | Needs suspend keyword |
| Existing Java libraries | Compatible | Needs wrappers (withContext(Dispatchers.IO)) |
| Structured concurrency | Java 25 (StructuredTaskScope) | Built-in (coroutineScope) |
| Cancellation | Via interruption | Cooperative (isActive, ensureActive) |
Virtual threads are simpler — your existing blocking Java code scales without changes. Kotlin coroutines are more powerful — structured concurrency, flows, channels. Both solve the same underlying problem: efficient async I/O.
Summary
- Virtual threads = lightweight threads managed by the JVM
- Scale to millions of concurrent tasks with minimal memory
- Best for I/O-bound work (database, HTTP, file I/O)
- Don’t help for CPU-bound work
- Replace
synchronizedwithReentrantLockto avoid pinning - One line to enable in Spring Boot 3.2+
- Migration is straightforward: replace thread pools, remove tuning
Virtual threads make the simple blocking programming model scale. No reactive frameworks, no callbacks, no color functions. Just Thread.sleep() that actually works at scale.