Back to Blog

Java Concurrency Revolution: Mastering Virtual Threads

12 min read
JavaConcurrencyVirtual ThreadsPerformance

Java Concurrency Revolution: Mastering Virtual Threads

Java's concurrency model has evolved significantly with the introduction of Virtual Threads in JDK 21. This feature represents one of the most significant advancements in Java's concurrency model since the introduction of the Fork/Join framework in Java 7.

Understanding Virtual Threads

Virtual threads are lightweight threads that dramatically reduce the overhead of concurrent programming in Java. Unlike platform threads (the traditional threads we've used for decades), virtual threads are:

  • Managed by the JVM rather than the operating system
  • Extremely lightweight (thousands or millions can be created)
  • Automatically multiplexed onto a smaller set of carrier threads
  • Designed for I/O-bound workloads where threads spend most of their time waiting

Traditional Threads vs. Virtual Threads

// Traditional approach with platform threads
ExecutorService executor = Executors.newFixedThreadPool(100);

for (int i = 0; i < 10000; i++) {
    executor.submit(() -> {
        // This task might block for I/O
        processRequest();
    });
}

With traditional threads, we're limited by the thread pool size (100 in this example). If we have more concurrent tasks than threads, they'll queue up and wait.

// Modern approach with virtual threads
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 1_000_000; i++) {
        executor.submit(() -> {
            // Each task gets its own virtual thread
            processRequest();
        });
    }
}

With virtual threads, we can create a new thread for each task, even if there are millions of them. The JVM efficiently manages these threads, scheduling them onto a smaller number of carrier threads.

Implementing Virtual Threads

Creating Virtual Threads

There are several ways to create virtual threads:

// Method 1: Direct creation
Thread vThread = Thread.ofVirtual().start(() -> {
    System.out.println("Hello from a virtual thread!");
});

// Method 2: Using a factory
ThreadFactory factory = Thread.ofVirtual().factory();
Thread vThread = factory.newThread(() -> {
    System.out.println("Hello from a virtual thread!");
});
vThread.start();

// Method 3: Using an executor service
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(() -> {
        System.out.println("Hello from a virtual thread!");
    });
}

Structured Concurrency

Virtual threads work well with structured concurrency, a new concurrency model that helps manage the lifecycle of concurrent tasks:

void processOrders(List<Order> orders) throws ExecutionException, InterruptedException {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        // Fork tasks for each order
        List<StructuredTaskScope.Subtask<ProcessResult>> tasks = orders.stream()
            .map(order -> scope.fork(() -> processOrder(order)))
            .toList();
        
        // Wait for all tasks to complete or any to fail
        scope.join();
        scope.throwIfFailed();
        
        // Process results
        List<ProcessResult> results = tasks.stream()
            .map(StructuredTaskScope.Subtask::get)
            .toList();
        
        // Do something with results
        generateReport(results);
    }
}

Performance Benefits

Scalability Improvements

Virtual threads excel in scenarios with many concurrent I/O operations:

// Benchmark: Download 10,000 URLs
void downloadWithPlatformThreads() throws InterruptedException {
    int concurrencyLevel = 200; // Limited by platform thread overhead
    ExecutorService executor = Executors.newFixedThreadPool(concurrencyLevel);
    
    try {
        List<Future<?>> futures = new ArrayList<>();
        for (String url : urls) {
            futures.add(executor.submit(() -> downloadUrl(url)));
        }
        
        for (Future<?> future : futures) {
            future.get();
        }
    } finally {
        executor.shutdown();
        executor.awaitTermination(1, TimeUnit.MINUTES);
    }
}

void downloadWithVirtualThreads() throws InterruptedException {
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        List<Future<?>> futures = new ArrayList<>();
        for (String url : urls) {
            futures.add(executor.submit(() -> downloadUrl(url)));
        }
        
        for (Future<?> future : futures) {
            future.get();
        }
    }
}

In benchmarks, the virtual threads version can handle significantly more concurrent downloads with less memory and CPU overhead.

Memory Efficiency

Virtual threads use a fraction of the memory compared to platform threads:

  • A platform thread typically requires ~2MB of stack memory
  • A virtual thread can use as little as ~1KB when idle

This means you can create millions of virtual threads with the same memory footprint as thousands of platform threads.

Best Practices

When to Use Virtual Threads

Virtual threads are ideal for:

  • I/O-bound workloads: Network calls, database queries, file operations
  • High concurrency scenarios: Handling many simultaneous client connections
  • Request-per-thread models: Web servers, API gateways

They're less beneficial for:

  • CPU-intensive tasks: Number crunching, complex calculations
  • Thread-local heavy code: Applications that make extensive use of ThreadLocal
  • Synchronized blocks: Code with heavy synchronization (though this is improving)

Avoiding Common Pitfalls

Thread Locals

Thread locals can cause memory leaks with virtual threads:

// Problematic use of ThreadLocal with virtual threads
ThreadLocal<ExpensiveResource> resourceHolder = ThreadLocal.withInitial(() -> {
    return new ExpensiveResource(); // Could create millions of these!
});

// Better approach
ThreadLocal<ExpensiveResource> resourceHolder = new ThreadLocal<>();
try {
    if (resourceHolder.get() == null) {
        resourceHolder.set(new ExpensiveResource());
    }
    // Use the resource
} finally {
    resourceHolder.remove(); // Clean up to prevent leaks
}

Blocking in Synchronized Blocks

Virtual threads can be pinned to carrier threads during synchronized blocks:

// Problematic: Virtual thread gets pinned during I/O
synchronized (lock) {
    // Virtual thread is pinned to carrier thread
    response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
    // Still pinned until exiting synchronized block
}

// Better approach
String response;
synchronized (lock) {
    // Short critical section with no I/O
    prepareRequest();
}
// Not pinned during I/O
response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());

Migration Strategies

Identifying Candidates for Migration

Good candidates for migration to virtual threads:

  1. Thread pools handling I/O-bound tasks
  2. Connection pools with artificial size limits
  3. Asynchronous callback-based code that could be simplified

Gradual Migration Approach

// Step 1: Identify thread pools
ExecutorService legacyExecutor = Executors.newFixedThreadPool(100);

// Step 2: Create a virtual thread executor
ExecutorService virtualExecutor = Executors.newVirtualThreadPerTaskExecutor();

// Step 3: Create a feature flag
boolean useVirtualThreads = Boolean.getBoolean("app.useVirtualThreads");

// Step 4: Use the appropriate executor based on the flag
ExecutorService executor = useVirtualThreads ? virtualExecutor : legacyExecutor;

// Step 5: Use the executor in your code
executor.submit(() -> processRequest());

Monitoring and Tuning

// Register JMX beans to monitor virtual threads
@Bean
public ThreadMXBean threadMXBean() {
    return ManagementFactory.getThreadMXBean();
}

// Periodically log thread statistics
@Scheduled(fixedRate = 60000)
public void logThreadStats() {
    ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
    log.info("Thread count: {}", threadMXBean.getThreadCount());
    log.info("Peak thread count: {}", threadMXBean.getPeakThreadCount());
    log.info("Total started thread count: {}", threadMXBean.getTotalStartedThreadCount());
}

Real-World Examples

Web Server Implementation

public class SimpleVirtualThreadServer {
    public static void main(String[] args) throws IOException {
        var server = HttpServer.create(new InetSocketAddress(8080), 0);
        
        // Set up a thread factory for virtual threads
        Executor executor = Executors.newVirtualThreadPerTaskExecutor();
        server.setExecutor(executor);
        
        server.createContext("/api", exchange -> {
            // Each request gets its own virtual thread
            String response = processRequest(exchange);
            exchange.sendResponseHeaders(200, response.length());
            try (OutputStream os = exchange.getResponseBody()) {
                os.write(response.getBytes());
            }
        });
        
        server.start();
        System.out.println("Server started on port 8080");
    }
    
    private static String processRequest(HttpExchange exchange) {
        // Simulate I/O operations (database queries, external API calls)
        try {
            Thread.sleep(100); // Simulate network delay
            return "Response from " + exchange.getRequestURI();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return "Error processing request";
        }
    }
}

Database Connection Pool

public class VirtualThreadDbConnectionPool {
    private final DataSource dataSource;
    
    public VirtualThreadDbConnectionPool(String jdbcUrl, String username, String password) {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl(jdbcUrl);
        config.setUsername(username);
        config.setPassword(password);
        
        // With virtual threads, we can use a smaller connection pool
        // since threads don't block waiting for connections
        config.setMaximumPoolSize(20);
        config.setMinimumIdle(5);
        
        this.dataSource = new HikariDataSource(config);
    }
    
    public <T> List<T> queryInParallel(List<String> queries, Function<ResultSet, T> mapper) {
        List<T> results = new ArrayList<>(queries.size());
        
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            List<StructuredTaskScope.Subtask<T>> tasks = queries.stream()
                .map(query -> scope.fork(() -> executeQuery(query, mapper)))
                .toList();
            
            scope.join();
            scope.throwIfFailed();
            
            for (var task : tasks) {
                results.add(task.get());
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("Query execution interrupted", e);
        } catch (ExecutionException e) {
            throw new RuntimeException("Query execution failed", e.getCause());
        }
        
        return results;
    }
    
    private <T> T executeQuery(String sql, Function<ResultSet, T> mapper) {
        try (Connection conn = dataSource.getConnection();
             PreparedStatement stmt = conn.prepareStatement(sql);
             ResultSet rs = stmt.executeQuery()) {
            
            return mapper.apply(rs);
        } catch (SQLException e) {
            throw new RuntimeException("Database query failed: " + sql, e);
        }
    }
}

Future Directions

Virtual threads are just the beginning of Project Loom's improvements to Java concurrency. Future JDK releases are expected to bring:

  • Further optimizations for synchronized blocks
  • Better integration with existing concurrency APIs
  • Enhanced debugging and profiling tools
  • More structured concurrency constructs

Conclusion

Virtual threads represent a paradigm shift in Java concurrency. By dramatically reducing the overhead of thread creation and management, they enable a simpler, more direct style of concurrent programming that scales to millions of concurrent operations.

For I/O-bound applications, the benefits are immediate and substantial: improved throughput, reduced memory usage, and simpler code. While not a silver bullet for all concurrency challenges, virtual threads address one of the most common bottlenecks in modern server applications.

As you begin exploring virtual threads in your applications, focus on I/O-bound workloads, be mindful of thread locals and synchronization, and measure the performance impact in your specific use cases. With careful implementation, virtual threads can transform the scalability and efficiency of your Java applications.