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:

MetricPlatform ThreadsVirtual Threads
Max Threads200 (pool limit)10,000
Response Time (avg)2.5s0.3s
Memory Usage4GB800MB
Throughput80 req/s1,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

  1. 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();
}
  1. Monitor pinned threads
java -Djdk.tracePinnedThreads=full -jar app.jar
  1. Dont pool virtual threads - Create new ones as needed

Virtual threads fundamentally change how we write concurrent Java applications. The future is here!