DEV Community

Said Olano
Said Olano

Posted on

Virtual Threads in Java 21: Revolutionizing Concurrency and Scalability

Introduction: The Future of Concurrent Programming in Java

For decades, Java developers have relied on OS-level threads to handle concurrent workloads. The traditional threading model—one thread per task—has served the ecosystem well, but it comes with fundamental limitations. Each thread consumes significant memory (typically 1-2 MB), limiting the number of concurrent tasks to thousands rather than millions. Modern cloud applications, microservices, and real-time systems demand millions of concurrent connections and operations.

Enter Virtual Threads (also called fibers or green threads)—a revolutionary feature introduced in Java 21 that fundamentally changes how we think about concurrency in Java. Virtual Threads are lightweight, managed by the JVM, and allow developers to write simple, sequential code while the runtime handles millions of concurrent tasks efficiently. This article explores the mechanics of Virtual Threads, their impact on application architecture, and practical patterns for leveraging them in production systems.

The Problem with Traditional Threads

Memory Overhead

Each OS-level thread consumes approximately 1-2 MB of memory. A server with 8 GB of RAM can theoretically run only 4,000-8,000 threads before running out of memory. For applications requiring millions of concurrent connections—think IoT platforms, real-time analytics, or high-frequency trading systems—this becomes a critical bottleneck.

// Traditional Thread Approach - Limited Scalability
public class TraditionalThreadServer {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newCachedThreadPool();

        for (int i = 0; i < 1_000_000; i++) {
            executor.submit(() -> {
                handleClientConnection();
            });
        }
    }

    private static void handleClientConnection() {
        // This will create up to 1 million OS threads
        // Each consuming ~1-2 MB of memory
        // Total: ~1-2 GB just for thread overhead!
    }
}
Enter fullscreen mode Exit fullscreen mode

Context Switching Cost

Operating systems manage thread scheduling by context switching between active threads. With millions of threads, the context switching overhead becomes substantial, reducing overall throughput and increasing latency unpredictably.

Programming Complexity

Traditional concurrency in Java often relies on asynchronous programming patterns (callbacks, Futures, reactive streams), which complicate code readability and error handling:

// Asynchronous Complexity
userService.getUserAsync(userId)
    .thenCompose(user -> orderService.getOrdersAsync(user.getId()))
    .thenCompose(orders -> inventoryService.checkStockAsync(orders))
    .thenApply(stock -> buildResponse(stock))
    .exceptionally(ex -> handleError(ex))
    .whenComplete((result, ex) -> logResult(result, ex));
Enter fullscreen mode Exit fullscreen mode

Virtual Threads: A Paradigm Shift

What Are Virtual Threads?

Virtual Threads are lightweight, managed concurrency units that run on top of OS-level threads through the Structured Concurrency and Project Loom initiatives. Key characteristics:

  • Lightweight: Each Virtual Thread requires only ~1 KB of memory (1,000x more efficient than OS threads)
  • Abundant: Your application can create millions of Virtual Threads
  • Simple: Write sequential code—the JVM handles scheduling
  • Non-blocking: When a Virtual Thread encounters blocking I/O, it yields to another Virtual Thread automatically

The Virtual Thread Execution Model

Virtual Threads are managed by a Scheduler that maps them onto a pool of Carrier Threads (OS-level threads). When a Virtual Thread performs a blocking operation:

  1. The Virtual Thread is suspended
  2. The Carrier Thread becomes available for other Virtual Threads
  3. When the blocking operation completes, the Virtual Thread resumes (potentially on a different Carrier Thread)

This happens transparently to your code—you write simple, sequential logic while the JVM manages millions of concurrent tasks.

Virtual Threads (millions)
    ↓
Scheduler (JVM)
    ↓
Carrier Threads (small pool, ~CPU count)
    ↓
OS Kernel
Enter fullscreen mode Exit fullscreen mode

Practical Implementation: From Threads to Virtual Threads

Before: Traditional Thread Pool

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @Autowired
    private OrderService orderService;

    @PostMapping
    public ResponseEntity<Order> createOrder(@RequestBody OrderRequest request) {
        // Executes on traditional thread pool
        return ResponseEntity.ok(orderService.processOrder(request));
    }
}

@Service
public class OrderService {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private InventoryService inventoryService;

    public Order processOrder(OrderRequest request) {
        // Blocking I/O: waits for database response
        User user = userRepository.findById(request.getUserId()).orElseThrow();

        // Blocking I/O: waits for HTTP call to inventory service
        InventoryStatus inventory = inventoryService.checkStock(request.getItems());

        // Process order...
        return buildOrder(user, inventory, request);
    }
}
Enter fullscreen mode Exit fullscreen mode

With traditional threads, this simple code requires careful thread pool configuration. Too few threads = throughput bottleneck. Too many threads = memory exhaustion.

After: Virtual Threads (Java 21+)

The code remains identical, but now:

// application.properties
spring.threads.virtual.enabled=true

// Same controller and service code - no changes needed!
Enter fullscreen mode Exit fullscreen mode

Spring Boot automatically uses Virtual Threads for request handling. Millions of concurrent orders can now be processed with minimal resource overhead.

Creating Virtual Threads Explicitly

public class VirtualThreadExample {

    public static void main(String[] args) throws InterruptedException {
        // Create a Virtual Thread using Thread.ofVirtual()
        Thread virtualThread = Thread.ofVirtual()
            .name("worker-", 0)
            .start(() -> {
                System.out.println("Running on Virtual Thread: " + 
                    Thread.currentThread().getName());
                fetchDataAndProcess();
            });

        virtualThread.join();
    }

    private static void fetchDataAndProcess() {
        // Blocking I/O is fine - Virtual Thread will be suspended
        // and the Carrier Thread will handle other Virtual Threads
        String data = fetchFromDatabase(); // Blocking
        String enriched = callExternalAPI(data); // Blocking
        processAndStore(enriched); // Blocking
    }
}
Enter fullscreen mode Exit fullscreen mode

ExecutorService with Virtual Threads

public class VirtualThreadExecutor {

    public static void main(String[] args) throws Exception {
        // Create an executor backed by Virtual Threads
        ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

        // Submit millions of tasks without worrying about thread exhaustion
        for (int i = 0; i < 10_000_000; i++) {
            final int taskId = i;
            executor.submit(() -> {
                processTask(taskId);
            });
        }

        executor.shutdown();
        executor.awaitTermination(1, TimeUnit.HOURS);
    }

    private static void processTask(int id) {
        // Sequential, blocking code
        String result = fetchDataFromAPI(id);
        storeInDatabase(result);
        notifySubscribers(result);
    }
}
Enter fullscreen mode Exit fullscreen mode

Real-World Patterns and Best Practices

Pattern 1: Web Service Concurrency

@RestController
@RequestMapping("/api/analytics")
public class AnalyticsController {

    @Autowired
    private DataService dataService;

    @GetMapping("/report/{id}")
    public ResponseEntity<Report> generateReport(@PathVariable String id) {
        // With Virtual Threads, one thread per request is feasible
        // even with millions of concurrent users

        Report report = dataService.generateComplexReport(id);
        return ResponseEntity.ok(report);
    }
}

@Service
public class DataService {

    @Autowired
    private DatabaseClient dbClient;

    @Autowired
    private ExternalAnalyticsService analyticsService;

    public Report generateComplexReport(String id) {
        // Multiple blocking I/O operations
        var userData = dbClient.fetchUsers(id);
        var transactions = dbClient.fetchTransactions(id);
        var externalMetrics = analyticsService.getMetrics(id);

        return assembleReport(userData, transactions, externalMetrics);
    }
}
Enter fullscreen mode Exit fullscreen mode

Pattern 2: Batch Processing at Scale

public class BatchProcessor {

    public void processMillionRecords(List<Record> records) 
            throws InterruptedException {

        ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

        for (Record record : records) {
            executor.submit(() -> {
                // Each record gets its own Virtual Thread
                validateRecord(record);
                enrichFromExternalSources(record);
                persistToDataWarehouse(record);
                publishToKafka(record);
            });
        }

        executor.shutdown();
        executor.awaitTermination(1, TimeUnit.DAYS);
    }
}
Enter fullscreen mode Exit fullscreen mode

Pattern 3: Structured Concurrency (Java 19+)

public class StructuredConcurrencyExample {

    public Order processOrderWithRetry(OrderRequest request) 
            throws Exception {

        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {

            // Submit related tasks
            var userFuture = scope.fork(() -> fetchUser(request.getUserId()));
            var inventoryFuture = scope.fork(() -> checkInventory(request.getItems()));
            var pricingFuture = scope.fork(() -> calculatePricing(request.getItems()));

            // Wait for all to complete
            scope.joinUntil(Instant.now().plusSeconds(5));

            if (scope.exception() != null) {
                throw scope.exception();
            }

            return buildOrder(
                userFuture.resultNow(),
                inventoryFuture.resultNow(),
                pricingFuture.resultNow()
            );
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Performance Benchmarks and Real-World Impact

Memory Efficiency

Metric OS Threads Virtual Threads
Memory per thread 1-2 MB ~1 KB
Max concurrent on 8GB ~4,000 ~8,000,000
Thread creation cost High (~ms) Low (~μs)
Context switch overhead Significant Minimal

Throughput Improvement

A benchmark comparing traditional vs Virtual Thread handling of I/O-bound workloads:

Traditional Thread Pool (200 threads):
- Requests/sec: ~2,000
- Latency (p99): 500ms
- Memory: ~400 MB

Virtual Threads:
- Requests/sec: ~50,000 (25x improvement)
- Latency (p99): 50ms
- Memory: ~50 MB (8x reduction)
Enter fullscreen mode Exit fullscreen mode

Real production deployments (e.g., Uber, Netflix) have reported:

  • 40-60% reduction in resource consumption
  • 2-5x improvement in request throughput
  • Simplified code (no async/await complexity)

Migration Path: Gradual Adoption

Virtual Threads don't require a complete rewrite. Java 21 allows gradual adoption:

Step 1: Enable Virtual Threads in Spring Boot

# application.yml
spring:
  threads:
    virtual:
      enabled: true
Enter fullscreen mode Exit fullscreen mode

Step 2: Update Thread Pool Configuration

@Configuration
public class ThreadPoolConfig {

    @Bean
    public ExecutorService virtualThreadExecutor() {
        // Old way: Executors.newFixedThreadPool(200)
        // New way: Virtual Threads (no limit needed)
        return Executors.newVirtualThreadPerTaskExecutor();
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Remove Reactive Code Where Unnecessary

Virtual Threads make complex reactive code optional:

// Before: Reactive complexity
userService.getUserAsync(id)
    .flatMap(user -> orderService.getOrdersAsync(user.id))
    .subscribe(orders -> process(orders));

// After: Simple sequential code with Virtual Threads
User user = userService.getUser(id);
List<Order> orders = orderService.getOrders(user.id);
process(orders);
Enter fullscreen mode Exit fullscreen mode

Considerations and Limitations

When NOT to Use Virtual Threads

  1. CPU-bound tasks: Virtual Threads excel at I/O-bound work. For CPU-bound operations, traditional thread pools may be more appropriate.

  2. Thread-local state: Excessive thread-local usage can cause issues at scale. Refactor to pass context explicitly.

// Avoid excessive thread-local usage
// private static final ThreadLocal<User> currentUser = new ThreadLocal<>();

// Instead, use context objects
public void handleRequest(Request request, User user) {
    // Pass user explicitly
    processRequest(request, user);
}
Enter fullscreen mode Exit fullscreen mode
  1. Pinning: Certain operations (synchronized blocks, JNI calls) can "pin" a Virtual Thread to its Carrier Thread, blocking other Virtual Threads. Minimize synchronized blocks.
// Avoid
synchronized void criticalSection() {
    // Pins the Virtual Thread!
}

// Prefer
private final ReentrantLock lock = new ReentrantLock();

void criticalSection() {
    lock.lock();
    try {
        // Does not pin
    } finally {
        lock.unlock();
    }
}
Enter fullscreen mode Exit fullscreen mode

The Roadmap: Beyond Java 21

Virtual Threads are here, but the ecosystem continues evolving:

  • Structured Concurrency (Preview in Java 19+): Better composability and error handling
  • Scoped Values (Preview): Thread-safe context propagation
  • Foreign Function & Memory API: Better interop with native code
  • Project Leyden: Faster startup and lower memory for containerized deployments

Conclusion: A New Era of Concurrency

Virtual Threads represent a fundamental shift in how Java handles concurrency. By abstracting thread management to the JVM layer, they enable:

Scalability: Millions of concurrent tasks on modest hardware

Simplicity: Write sequential, readable code without reactive complexity

Efficiency: 1,000x memory reduction per concurrency unit

Performance: 2-5x throughput improvement on I/O-bound workloads

If you're building cloud-native applications, microservices, or high-concurrency systems in Java, Virtual Threads aren't just an optimization—they're a paradigm shift that modernizes how you architect concurrent systems.

The future of Java concurrency is here. It's time to adopt it.

Resources


Tags: #Java #Concurrency #Performance #Architecture #Threading #Java21 #SpringBoot #HighScalability

Top comments (0)