Java Virtual Threads: Practical Guide
1. Overview
Virtual threads are an official feature from Java 21 (JEP 444) that help you write synchronous (blocking style) code while still achieving high throughput like non-blocking I/O. This article covers everything from the concept, creating virtual threads, comparing performance with platform threads, to integrating them into Spring Boot. After reading, you’ll know when to use virtual threads and when not to.
2. Problems with Traditional Platform Threads
Each platform thread in Java maps 1-1 to an OS thread. OS threads consume memory (typically 1MB stack) and creating/context switching has high overhead. For this reason, applications handling tens of thousands of concurrent requests (for example, servers waiting on I/O) are typically limited by the number of thread pools they can create.
ExecutorService executor = Executors.newFixedThreadPool(200);
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> {
try {
Thread.sleep(1000); // simulate I/O call
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
executor.shutdown();
With a pool of 200 threads, 10,000 tasks must wait in queue, and total completion time is prolonged because each thread can only handle one task at a time, even though most of the time is spent waiting (blocked), not computing.
3. What is a Virtual Thread
A virtual thread is a thread managed by the JVM (not the OS), mounted onto a platform thread (called a carrier thread) only when executing code. When a virtual thread calls a blocking operation (I/O, Thread.sleep, lock…), the JVM automatically unmounts it from the carrier thread, freeing the carrier thread to handle other virtual threads.
The result: you can create millions of virtual threads without worrying about resource exhaustion, while code is still written in the familiar blocking style — no need for CompletableFuture, no need for reactive.
3.1. Creating a Simple Virtual Thread
Thread vThread = Thread.ofVirtual()
.name("order-processor")
.start(() -> {
System.out.println("Running on: " + Thread.currentThread());
});
vThread.join();
Thread.ofVirtual() returns a builder to configure the virtual thread. .start() creates and starts immediately. Printing the thread will show something like:
Running on: VirtualThread[#21,order-processor]/runnable@ForkJoinPool-1-worker-1
Note the @ForkJoinPool-... part — that’s the carrier thread currently carrying this virtual thread.
4. Executor for Virtual Threads
The most common way to use virtual threads in real applications is through ExecutorService.
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < 10_000; i++) {
int id = i;
futures.add(executor.submit(() -> {
Thread.sleep(1000); // simulate I/O call (DB, HTTP…)
return "Task " + id + " done";
}));
}
for (Future<String> f : futures) {
f.get();
}
}
newVirtualThreadPerTaskExecutor() creates a new virtual thread for each task, without reusing threads like traditional thread pools. Because the cost of creating a virtual thread is very low (a few hundred bytes, not 1MB), this is completely reasonable. The try-with-resources block ensures the executor is closed and waits for tasks to complete when exiting the block.
Compared to the example in section 2 (pool of 200 platform threads), this code completes all 10,000 tasks in about 1 second at roughly the same time (because there’s no pool limit anymore), instead of having to wait in queue multiple times.
5. Performance Comparison with a Real Test
class VirtualThreadBenchmarkTest {
@Test
void virtualThreadsShouldHandleManyBlockingTasksFast() throws InterruptedException {
int taskCount = 10_000;
CountDownLatch latch = new CountDownLatch(taskCount);
Instant start = Instant.now();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < taskCount; i++) {
executor.submit(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
latch.countDown();
}
});
}
}
latch.await();
Duration elapsed = Duration.between(start, Instant.now());
assertThat(elapsed).isLessThan(Duration.ofSeconds(5));
}
}
This test creates 10,000 tasks, each sleeps for 100ms. If run sequentially, it would take 1000 seconds, but with virtual threads, everything completes in seconds because tasks run truly in parallel. CountDownLatch is used to wait for all tasks to complete before asserting.
Note: Real benchmarks should also measure CPU and memory using JFR (Java Flight Recorder), instead of just measuring time, because time also depends on the machine running the test.
6. Pitfall: Pinning with synchronized
A virtual thread will not unmount from the carrier thread if it’s inside a synchronized block — this phenomenon is called pinning. In this case, blocking I/O inside synchronized will occupy the carrier thread entirely, losing the benefits of virtual threads.
public class InventoryService {
private final Object lock = new Object();
public void reserveStock(String sku) {
synchronized (lock) {
callExternalInventoryApi(sku); // blocking I/O inside synchronized -> pinning!
}
}
}
The synchronized (lock) line holds the carrier thread for the entire duration of the call to callExternalInventoryApi, even though it’s an I/O operation. If thousands of virtual threads are pinned together, the carrier thread pool (by default equal to the number of CPU cores) will quickly be exhausted.
Solution: replace synchronized with java.util.concurrent.locks.ReentrantLock, which is well-compatible with virtual threads (no pinning).
public class InventoryService {
private final ReentrantLock lock = new ReentrantLock();
public void reserveStock(String sku) {
lock.lock();
try {
callExternalInventoryApi(sku);
} finally {
lock.unlock();
}
}
}
ReentrantLock allows a virtual thread to unmount normally when encountering blocking I/O, maintaining the “lightweight” nature of virtual threads.
7. Integrating Virtual Threads into Spring Boot
From Spring Boot 3.2, you only need to enable one property to make Spring Web (Tomcat) use virtual threads for each request.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
spring.threads.virtual.enabled=true
This property enables virtualThreadExecutor for Tomcat (and other executors in Spring Boot that support it). Each incoming HTTP request will be handled on a separate virtual thread, instead of sharing a fixed platform thread pool (Tomcat’s default 200 threads).
@RestController
@RequestMapping("/orders")
class OrderController {
private final OrderService orderService;
OrderController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping("/{id}")
public OrderResponse getOrder(@PathVariable Long id) {
return orderService.fetchOrderWithDetails(id); // calling DB + other APIs, blocking normally
}
}
The controller code doesn’t need any changes — you still write in blocking style as before. When load testing with tools like ab or k6, you’ll see throughput increase significantly for endpoints with high I/O latency (calling DB, calling other services), because Tomcat is no longer limited by the default 200 threads.
8. When NOT to Use Virtual Threads
- CPU-bound tasks: If the work is primarily computation (no waiting I/O), virtual threads provide no benefit — the number of carrier threads is still limited by the number of CPU cores. Using
ForkJoinPoolorparallelStream()is more suitable. - Code with heavy
ThreadLocalusage: Virtual threads are compatible withThreadLocal, but if you create millions of virtual threads, each holding largeThreadLocalwill consume significant memory — consider usingScopedValue(preview from Java 21) instead. - Old libraries with
synchronizedin many places: As mentioned in section 6, pinning can silently reduce performance which you might not notice immediately. - Limited connection pool: Virtual threads help create many parallel requests, but if the DB connection pool has only 20 connections, you’ll still be bottlenecked there — you need to increase the pool size accordingly or use a connection pool that supports high concurrency.
Note: Virtual threads solve the number of threads problem, not the speed of processing each task. Don’t expect it to make CPU-bound code run faster.
9. Conclusion
Virtual threads help you write simple blocking code while still scaling well for thousands of concurrent requests, particularly suitable for I/O-bound applications like web services, microservices calling many APIs/DBs. You only need to change the executor or enable one property in Spring Boot, no need to rewrite logic in a reactive style. However, watch out for the pinning pitfall with synchronized and remember that virtual threads don’t help with CPU-bound tasks.