Virtual Threads (JEP 444): A Million Threads Without the Pain
The Platform Thread Problem
Every Java thread since Java 1.0 maps 1:1 to an OS thread. OS threads are heavy:
- Stack memory: 512 KB – 2 MB per thread (configurable with
-Xss, default 512 KB on Linux) - Context switch cost: ~1–10 μs per switch (kernel mode transition + cache invalidation)
- Hard limit: A 64-bit machine with 8 GB RAM can support roughly 8,000–16,000 OS threads before running out of stack memory
This limit shapes how Java servers are built. Thread-per-request (one OS thread per HTTP request) works for hundreds of concurrent users. Beyond that, developers use reactive programming (CompletableFuture, RxJava, Project Reactor) — async, non-blocking code that is notoriously difficult to write, read, and debug.
Virtual threads remove this ceiling while keeping synchronous, sequential code style.
How Virtual Threads Work
flowchart TB
subgraph JVM["JVM — ForkJoinPool (N carrier threads, N = CPU cores)"]
subgraph CT1["Carrier Thread 1 (OS Thread)"]
VT1["Virtual Thread A\n(mounted — executing)"]
end
subgraph CT2["Carrier Thread 2 (OS Thread)"]
VT3["Virtual Thread C\n(mounted — executing)"]
end
VT2["Virtual Thread B\n(unmounted — waiting on I/O)"]
VT4["Virtual Thread D\n(unmounted — waiting on lock)"]
VT_N["Virtual Thread ...N\n(unmounted — parked)"]
end
Heap["JVM Heap\nVirtual thread stacks\nstored as objects (~few KB each)"]
VT2 & VT4 & VT_N --> Heap
Key mechanism — mount/unmount:
- Virtual thread is mounted onto a carrier thread when it has work to do
- When the virtual thread hits a blocking I/O call (e.g.,
socket.read()), it unmounts from its carrier thread - The carrier thread immediately picks up another virtual thread
- When the I/O completes, the virtual thread is rescheduled — it will mount onto any available carrier thread
The virtual thread’s stack is stored in the JVM heap (not OS stack space) — a few KB, not hundreds of KB. You can have millions of virtual threads on a machine with 8 GB RAM.
Creating Virtual Threads
Thread.ofVirtual()
// Start a virtual thread immediately
Thread vt = Thread.ofVirtual()
.name("order-processor")
.start(() -> processOrder(orderId));
vt.join(); // wait for completion
// Build but don't start
Thread vt = Thread.ofVirtual()
.name("worker-", 0) // auto-numbered: worker-0, worker-1, ...
.unstarted(() -> doWork());
vt.start();
Thread.startVirtualThread()
// Convenience — equivalent to Thread.ofVirtual().start(runnable)
Thread vt = Thread.startVirtualThread(() -> processOrder(orderId));
vt.join();
Executors.newVirtualThreadPerTaskExecutor()
The recommended way for server applications — one virtual thread per submitted task:
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = new ArrayList<>();
for (String orderId : orderIds) {
futures.add(executor.submit(() -> fetchOrderDetails(orderId)));
}
for (Future<String> f : futures) {
System.out.println(f.get());
}
}
// executor closed, all virtual threads finished
This is the drop-in replacement for Executors.newFixedThreadPool(n) — but instead of a fixed number of OS threads, you get one virtual thread per task.
Platform Thread vs Virtual Thread
// Platform thread — maps to OS thread
Thread platform = Thread.ofPlatform()
.name("platform-worker")
.start(() -> doWork());
// Virtual thread — JVM-managed lightweight thread
Thread virtual = Thread.ofVirtual()
.name("virtual-worker")
.start(() -> doWork());
// Check type
System.out.println(platform.isVirtual()); // false
System.out.println(virtual.isVirtual()); // true
| Property | Platform Thread | Virtual Thread |
|---|---|---|
| OS thread mapping | 1:1 | M:N (many virtual : few OS) |
| Stack size | 512 KB – 2 MB | ~2 KB (heap-stored, grows on demand) |
| Creation cost | Slow (~1ms, syscall) | Fast (~1μs, no syscall) |
| Max count per JVM | ~10,000–20,000 | Millions |
| Daemon | Configurable | Always daemon |
| Priority | Configurable | Always NORM_PRIORITY |
| ThreadGroup | Yes | Empty group |
Migrating a Spring Boot Application
The simplest migration: one property.
# application.properties — Spring Boot 3.2+
spring.threads.virtual.enabled=true
This switches the embedded Tomcat (or Jetty/Undertow) to use a virtual thread per HTTP request. No code changes needed.
For programmatic configuration:
@Bean
public TomcatProtocolHandlerCustomizer<?> virtualThreadCustomizer() {
return protocolHandler ->
protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
}
Spring WebFlux (Reactive) vs Virtual Threads
With virtual threads, you can use blocking code on the “reactive” stack without degrading performance:
// Before: forced to use reactive API
@GetMapping("/order/{id}")
public Mono<Order> getOrder(@PathVariable String id) {
return orderRepository.findById(id); // reactive repository
}
// After: blocking code works fine with virtual threads
@GetMapping("/order/{id}")
public Order getOrder(@PathVariable String id) {
return orderRepository.findByIdBlocking(id); // regular blocking call
}
Virtual threads make blocking I/O as efficient as reactive non-blocking I/O, without the complexity.
JDBC and Database Access
JDBC is inherently blocking. With platform threads, blocking on a DB query wastes a thread. With virtual threads, the virtual thread unmounts while waiting for the DB — the carrier thread is free.
// This blocks on the JDBC call — but with virtual threads, that's fine
try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Order>> futures = orderIds.stream()
.map(id -> exec.submit(() -> {
// This blocking JDBC call unmounts the virtual thread
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(
"SELECT * FROM orders WHERE id = ?")) {
ps.setString(1, id);
ResultSet rs = ps.executeQuery();
return mapToOrder(rs);
}
}))
.toList();
return futures.stream().map(f -> {
try { return f.get(); }
catch (Exception e) { throw new RuntimeException(e); }
}).toList();
}
Connection pool sizing: With virtual threads, hundreds of virtual threads may try to acquire a database connection simultaneously. Size your connection pool based on the database’s capacity, not thread count. For most databases, 10–50 connections are sufficient even with thousands of virtual threads.
spring.datasource.hikari.maximum-pool-size=20
Pinning — The Main Pitfall
A virtual thread is pinned when it cannot unmount from its carrier thread. Pinning happens when:
- The virtual thread executes inside a
synchronizedblock or method - The virtual thread calls a native method
When pinned, the carrier thread is blocked — defeating the purpose of virtual threads.
// PROBLEM: synchronized pins the virtual thread
synchronized (lock) {
Thread.sleep(1000); // virtual thread is pinned during sleep!
}
// SOLUTION: use ReentrantLock instead
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
Thread.sleep(1000); // virtual thread unmounts here — carrier is free
} finally {
lock.unlock();
}
Detecting Pinning
# JVM flag to print a stack trace whenever pinning occurs
java -Djdk.tracePinnedThreads=full MyApp
# Or short form (just thread name, no stack trace)
java -Djdk.tracePinnedThreads=short MyApp
Output:
Thread[#21,ForkJoinPool-1-worker-1,5,CarrierThreads]
...
com.example.MyService.processOrder(MyService.java:42) <== monitors:1
This tells you exactly which synchronized block caused pinning.
Common Pinning Sources
| Pinning cause | Fix |
|---|---|
synchronized block | Replace with ReentrantLock |
synchronized method | Replace with ReentrantLock |
| Older JDBC drivers | Upgrade — most modern drivers removed synchronized |
| Older HTTP clients | Upgrade or use java.net.http.HttpClient |
Note: Not all pinning is catastrophic. Short synchronized blocks (sub-microsecond) pin briefly — acceptable. Long blocking operations inside synchronized are the problem.
Thread-Local Variables and Virtual Threads
ThreadLocal works with virtual threads but has a problem at scale: if you create 1,000,000 virtual threads and each inherits a ThreadLocal, you create 1,000,000 copies of that value.
// Problematic with millions of virtual threads
static ThreadLocal<UserContext> CONTEXT = new ThreadLocal<>();
// Better: use ScopedValue (JEP 446, Preview)
static ScopedValue<UserContext> CONTEXT = ScopedValue.newInstance();
See Scoped Values for the virtual-thread-friendly alternative.
How Many Virtual Threads Can You Run?
// Benchmark: how many virtual threads can one JVM handle?
int count = 1_000_000;
CountDownLatch latch = new CountDownLatch(count);
for (int i = 0; i < count; i++) {
Thread.startVirtualThread(() -> {
try {
Thread.sleep(Duration.ofSeconds(1));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
latch.countDown();
}
});
}
latch.await();
System.out.println("1 million virtual threads completed");
On a typical developer laptop with 16 GB RAM, this runs in ~1–2 seconds peak memory usage of a few hundred MB. The equivalent with platform threads would require ~500 GB of stack memory.
When NOT to Use Virtual Threads
Virtual threads shine for I/O-bound work. They do not help for CPU-bound work:
// CPU-bound: no blocking, no I/O, no benefit from virtual threads
// Use platform threads / ForkJoinPool.commonPool() instead
long result = 0;
for (long i = 0; i < 1_000_000_000L; i++) {
result += compute(i);
}
For CPU-intensive parallel work, the ForkJoinPool with platform threads is still the right tool. Virtual threads are for tasks that spend most of their time waiting — HTTP calls, DB queries, file I/O, sleep.
Key Takeaways
- Virtual threads are JVM-managed, lightweight threads — a few KB each vs. 512 KB+ for OS threads
- They mount on carrier threads when executing and unmount when blocking on I/O — the carrier is free during the wait
Executors.newVirtualThreadPerTaskExecutor()is the standard executor for virtual thread applications- Spring Boot 3.2+:
spring.threads.virtual.enabled=trueswitches Tomcat to virtual threads instantly synchronizedblocks pin virtual threads — replace withReentrantLockfor long-blocking critical sections- Detect pinning with
-Djdk.tracePinnedThreads=full ThreadLocalworks but is memory-heavy at scale — preferScopedValue(preview)- Virtual threads do not improve CPU-bound workloads — only I/O-bound
Next: Structured Concurrency (JEP 453) — organize concurrent subtasks safely with StructuredTaskScope, eliminating thread leaks and simplifying cancellation.