Virtual Threads in Java 21 are a game-changer for concurrent programming. After running them in production for 6 months, here are my findings and recommendations.
What are Virtual Threads?
Virtual threads are lightweight threads managed by the JVM rather than the OS. They enable writing simple, synchronous code that scales like asynchronous code.
Traditional Threads (Platform Threads)
├── 1 Thread = 1 OS Thread
├── Stack size: ~1MB
├── Context switch: Expensive (kernel mode)
└── Practical limit: ~10,000 threads
Virtual Threads
├── Many Virtual Threads = Few OS Threads
├── Stack size: ~KB (grows as needed)
├── Context switch: Cheap (user mode)
└── Practical limit: Millions of threads
Creating Virtual Threads
// Method 1: Thread.startVirtualThread()
Thread.startVirtualThread(() -> {
System.out.println("Running in virtual thread");
});
// Method 2: Thread.ofVirtual()
Thread vThread = Thread.ofVirtual()
.name("my-virtual-thread")
.start(() -> processRequest());
// Method 3: ExecutorService (Recommended for production)
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10000).forEach(i -> {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return fetchData(i);
});
});
}
Spring Boot Integration
// application.yml
spring:
threads:
virtual:
enabled: true
// Or programmatically
@Configuration
public class VirtualThreadConfig {
@Bean
public TomcatProtocolHandlerCustomizer<?> virtualThreadExecutor() {
return protocolHandler -> {
protocolHandler.setExecutor(
Executors.newVirtualThreadPerTaskExecutor());
};
}
@Bean
public AsyncTaskExecutor applicationTaskExecutor() {
return new TaskExecutorAdapter(
Executors.newVirtualThreadPerTaskExecutor());
}
}
Real-World Example: API Gateway
@RestController
@RequiredArgsConstructor
public class AggregatorController {
private final UserService userService;
private final OrderService orderService;
private final RecommendationService recommendationService;
@GetMapping("/dashboard/{userId}")
public DashboardResponse getDashboard(@PathVariable Long userId)
throws InterruptedException, ExecutionException {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// All calls execute concurrently on virtual threads
Future<User> userFuture = executor.submit(
() -> userService.getUser(userId));
Future<List<Order>> ordersFuture = executor.submit(
() -> orderService.getRecentOrders(userId));
Future<List<Product>> recommendationsFuture = executor.submit(
() -> recommendationService.getRecommendations(userId));
// Wait for all results
return DashboardResponse.builder()
.user(userFuture.get())
.recentOrders(ordersFuture.get())
.recommendations(recommendationsFuture.get())
.build();
}
}
}
Structured Concurrency (Preview in Java 21)
@GetMapping("/order/{orderId}")
public OrderDetails getOrderDetails(@PathVariable Long orderId)
throws InterruptedException, ExecutionException {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<Order> orderTask = scope.fork(
() -> orderService.getOrder(orderId));
Subtask<Customer> customerTask = scope.fork(
() -> customerService.getCustomer(orderId));
Subtask<List<Item>> itemsTask = scope.fork(
() -> itemService.getItems(orderId));
scope.join(); // Wait for all
scope.throwIfFailed(); // Propagate errors
return new OrderDetails(
orderTask.get(),
customerTask.get(),
itemsTask.get()
);
}
}
Performance Benchmarks
Testing with 10,000 concurrent requests making HTTP calls:
| Metric | Platform Threads | Virtual Threads |
|---|---|---|
| Max Threads | 200 (pool limit) | 10,000 |
| Response Time (avg) | 2.5s | 0.3s |
| Memory Usage | 4GB | 800MB |
| Throughput | 80 req/s | 1,200 req/s |
When to Use Virtual Threads
✅ Good for:
- I/O-bound operations (HTTP calls, database queries)
- High-concurrency scenarios
- Simplifying async code
❌ Not ideal for:
- CPU-bound operations
- Operations with synchronized blocks on shared resources
- Native code that holds OS threads
Gotchas and Best Practices
- Avoid synchronized blocks - Use ReentrantLock instead
// Bad - pins virtual thread to carrier
synchronized (lock) {
// blocking operation
}
// Good - virtual thread friendly
lock.lock();
try {
// blocking operation
} finally {
lock.unlock();
}
- Monitor pinned threads
java -Djdk.tracePinnedThreads=full -jar app.jar
- Dont pool virtual threads - Create new ones as needed
Virtual threads fundamentally change how we write concurrent Java applications. The future is here!