What Are Virtual Threads?
Virtual Threads (JEP 444), officially released in Java 21, are lightweight threads managed by the JVM. Unlike traditional Platform Threads that map 1:1 to OS threads, millions of Virtual Threads can run on top of a small number of OS threads (Carrier Threads).
Think of it like virtual memory. Just as the OS maps limited physical RAM to a large virtual address space, the JVM maps a small number of OS threads to a large number of Virtual Threads.
| Aspect | Platform Thread | Virtual Thread |
|---|---|---|
| Mapping | 1:1 with OS thread | M:N (many to few OS threads) |
| Memory | ~1MB per thread | ~1-2KB per thread |
| Max concurrency | Thousands | Millions |
| Scheduling | OS scheduler | JVM ForkJoinPool |
| Pooling | Thread pool required | No pooling needed |
| Best for | CPU-intensive | I/O-bound |
The key takeaway: Virtual Threads do not make code run “faster.” Their purpose is throughput scaling. By yielding the Carrier Thread to other tasks during I/O wait time, they dramatically increase the number of concurrent operations.
Basic Usage
Thread.ofVirtual()
The most basic way to create a Virtual Thread.
public class VirtualThreadBasic {
public static void main(String[] args) throws InterruptedException {
// Create and start a Virtual Thread
Thread vThread = Thread.ofVirtual()
.name("my-vthread")
.start(() -> {
System.out.println("Thread: " + Thread.currentThread());
System.out.println("Is virtual? " + Thread.currentThread().isVirtual());
});
vThread.join();
// Output:
// Thread: VirtualThread[#21,my-vthread]/runnable@ForkJoinPool-1-worker-1
// Is virtual? true
}
}
ExecutorService (Recommended)
In practice, Executors.newVirtualThreadPerTaskExecutor() is used more often. It creates a new Virtual Thread for each task, and when used with try-with-resources, it automatically waits for all tasks to complete.
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class VirtualThreadExecutor {
public static void main(String[] args) throws Exception {
// Process 10,000 concurrent tasks
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < 10_000; i++) {
final int taskId = i;
futures.add(executor.submit(() -> {
// Simulating I/O (in practice: HTTP calls, DB queries, etc.)
Thread.sleep(100);
return "result-" + taskId;
}));
}
// Print only the first 5 results
for (int i = 0; i < 5; i++) {
System.out.println(futures.get(i).get());
}
System.out.println("... total " + futures.size() + " tasks completed");
}
// All Virtual Threads complete when the try block exits
// Output:
// result-0
// result-1
// result-2
// result-3
// result-4
// ... total 10000 tasks completed
}
}
Where a Platform Thread pool would handle 200 at a time, you can now scale to 10,000 concurrent tasks. The only code change is replacing newFixedThreadPool(200) with newVirtualThreadPerTaskExecutor().
Performance Comparison
Measured data from a Spring Boot environment (Tomcat, blocking I/O).
| Metric | Platform Thread (200 pool) | Virtual Thread | Improvement |
|---|---|---|---|
| 1,000 concurrent requests | Queuing starts | All processed | ~2x |
| 10,000+ concurrent requests | Timeouts occur | Smooth processing | ~3x |
| I/O response latency | 448ms | 319ms | 28.8% decrease |
| Memory usage | Baseline | -43% | 43% savings |
For CPU-bound work, there is virtually no performance difference. Virtual Threads shine in I/O-bound services.
3 Key Pitfalls
1. Pinning — The synchronized Block Trap
When a Virtual Thread performs blocking I/O inside a synchronized block, it cannot unmount from the Carrier Thread and becomes pinned. This eliminates the benefits of lightweight threads.
import java.util.concurrent.Executors;
import java.util.concurrent.locks.ReentrantLock;
public class PinningExample {
// Problem: I/O inside synchronized -> Pinning occurs
private static final Object syncLock = new Object();
static void badExample() throws Exception {
synchronized (syncLock) {
Thread.sleep(1000); // Blocking I/O simulation -> Carrier Thread pinned!
}
}
// Solution: Replace with ReentrantLock
private static final ReentrantLock reentrantLock = new ReentrantLock();
static void goodExample() throws Exception {
reentrantLock.lock();
try {
Thread.sleep(1000); // Blocking I/O simulation -> No pinning
} finally {
reentrantLock.unlock();
}
}
public static void main(String[] args) throws Exception {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// Run 10 concurrent tasks with ReentrantLock
for (int i = 0; i < 10; i++) {
executor.submit(() -> {
goodExample();
System.out.println(Thread.currentThread() + " done");
return null;
});
}
}
System.out.println("All tasks completed (no pinning)");
}
}
Netflix once experienced an application-wide deadlock caused by synchronized pinning. You can detect pinning with JFR (Java Flight Recorder).
# Detect pinning events
java -Djdk.tracePinnedThreads=full -jar your-app.jar
Note that starting with Java 24 (JEP 491), this issue is fundamentally resolved — synchronized no longer causes pinning.
2. ThreadLocal Memory Explosion
With a Platform Thread pool (200 threads), ThreadLocal caches hold at most 200 instances. But since Virtual Threads are not pooled, 50,000 concurrent threads means 50,000 instances are created.
import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.Executors;
public class ThreadLocalDanger {
// Danger: ThreadLocal caching with Virtual Threads
// 50,000 Virtual Threads -> 50,000 instances created!
static final ThreadLocal<SimpleDateFormat> BAD_FORMATTER =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
// Solution: Share a thread-safe immutable object -> only one instance
static final DateTimeFormatter GOOD_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd");
public static void main(String[] args) throws Exception {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 5; i++) {
executor.submit(() -> {
// Use immutable DateTimeFormatter (recommended)
String date = LocalDate.now().format(GOOD_FORMATTER);
System.out.println(Thread.currentThread().threadId() + " -> " + date);
return null;
});
}
}
// Output:
// 21 -> 2026-04-07
// 22 -> 2026-04-07
// ...
}
}
3. Protecting Downstream Systems
Virtual Threads are cheap, but DB connection pools and external APIs are not. Use a Semaphore to limit concurrency.
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
public class SemaphoreExample {
// Limit concurrent access to match the DB connection pool size
static final Semaphore dbSemaphore = new Semaphore(3); // Example: max 3 concurrent
public static void main(String[] args) throws Exception {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10; i++) {
final int taskId = i;
executor.submit(() -> {
dbSemaphore.acquire();
try {
// Simulating a DB query
System.out.println("Task " + taskId + " running (concurrent: "
+ (3 - dbSemaphore.availablePermits()) + "/3)");
Thread.sleep(500);
} finally {
dbSemaphore.release();
}
return null;
});
}
}
System.out.println("All tasks completed");
}
}
Enabling in Spring Boot
With Spring Boot 3.2+ and Java 21, a single line enables Virtual Threads.
# application.properties
spring.threads.virtual.enabled=true
This setting makes Tomcat HTTP request handling, @Async methods, and message listeners (Kafka, RabbitMQ) all run on Virtual Threads.
However, if you are already using WebFlux, Virtual Threads are unnecessary. WebFlux is already non-blocking, and mixing the two can cause issues. Virtual Threads deliver the greatest benefit in Spring MVC (blocking I/O) based services.
Summary
| Scenario | Recommendation |
|---|---|
| I/O-bound services (DB, API calls) | Actively use Virtual Threads |
| CPU-intensive tasks (image processing, encryption) | Keep Platform Thread pool |
| Using Java 21 | Watch out for synchronized pinning |
| Using Java 24+ | Pinning resolved, use with confidence |
| ThreadLocal caching | Replace with shared immutable objects |
| Need to limit concurrency | Use Semaphore |
Do not pool Virtual Threads. The design intent is to create one per task and discard it. Abandon the old assumption that “threads are expensive” and shift to the new model: “Virtual Threads are nearly free.”