Java has long been a powerhouse for enterprise applications, and Spring Boot has made developing them an absolute dream. But even with Spring Boot’s magic, a persistent bottleneck has challenged developers: the overhead of traditional thread-per-request models when dealing with blocking I/O operations. Think database calls, external API integrations, or even file system access – these operations often leave threads idle, waiting for responses, and consuming precious resources.

Enter Project Loom and Java Virtual Threads, a game-changer that promises to revolutionize how we build scalable applications. And when combined with Spring Boot, the results are nothing short of transformative.

The Problem: Threads are Expensive (and Blocked Threads are Worse)

Historically, the Java Virtual Machine (JVM) has relied on operating system (OS) threads. Creating an OS thread is a relatively heavy operation, consuming memory and CPU cycles. In a typical Spring Boot application serving many concurrent requests, each request might get its own thread.

When one of these threads encounters a blocking I/O operation (e.g., waiting for a database query to return), the thread effectively pauses. It holds onto its resources but isn’t doing any useful work. If you have hundreds or thousands of such concurrent requests, you quickly run into:

  • Resource Exhaustion: Too many OS threads can overwhelm the system, leading to high memory consumption and frequent context switching by the OS scheduler, which degrades performance.
  • Limited Throughput: The maximum number of concurrent requests your application can handle is directly tied to the number of available OS threads in your thread pool.

The Solution: Virtual Threads – Lightweight, Abundant, and Non-Blocking (Behind the Scenes)

Project Loom introduces Virtual Threads, also known as “fibers” or “green threads” in other languages. These are user-mode threads managed by the JVM, not directly by the OS. The key characteristics of Virtual Threads are:

  • Extremely Lightweight: They consume minimal memory compared to OS threads. You can have millions of Virtual Threads running concurrently on a single JVM.
  • Scheduled on Carrier Threads: Virtual Threads don’t directly run on the CPU. Instead, the JVM schedules them onto a smaller pool of underlying OS threads, called “carrier threads.”
  • Efficient I/O Handling: When a Virtual Thread encounters a blocking I/O operation, the JVM can “park” that Virtual Thread, unmounting it from its carrier thread. The carrier thread is then free to execute another Virtual Thread. Once the I/O operation completes, the Virtual Thread is “unparked” and remounted onto an available carrier thread.

This mechanism allows a small number of carrier threads to efficiently manage a massive number of Virtual Threads, dramatically improving throughput for I/O-bound applications without requiring complex asynchronous programming models (like CompletableFuture or reactive programming) for simple blocking operations.

Spring Boot and Virtual Threads: A Match Made in Heaven

Spring Boot, with its opinionated nature and sensible defaults, makes integrating Virtual Threads remarkably straightforward. As of Spring Boot 3.2 (and compatible with Java 21+), enabling Virtual Threads for your web applications is often just a configuration change away.

Let’s dive into how you can leverage them.

1. Enabling Virtual Threads in Spring Boot

The simplest way to enable Virtual Threads for your Spring Boot application’s embedded web server (like Tomcat or Jetty) is to add a single property to your application.properties or application.yml file:

application.properties

Properties

spring.threads.virtual.enabled=true

That’s it! With this property set, Spring Boot will configure its default task execution (including the web server’s thread pool) to use Virtual Threads. This means every incoming HTTP request will now be processed by a Virtual Thread.

2. Using Virtual Threads with Custom TaskExecutors

While the spring.threads.virtual.enabled property handles the web server, you might have other parts of your application that use TaskExecutors for background processing or asynchronous tasks. You can easily configure these to use Virtual Threads as well.

Java

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.AsyncTaskExecutor;
import org.springframework.core.task.VirtualThreadTaskExecutor;

@Configuration
public class AppConfig {

    @Bean
    public AsyncTaskExecutor applicationTaskExecutor() {
        // This will create a TaskExecutor that uses Virtual Threads
        return new VirtualThreadTaskExecutor("my-virtual-thread-pool");
    }

    // You can then inject and use this executor
    // @Autowired
    // private AsyncTaskExecutor applicationTaskExecutor;
    // applicationTaskExecutor.execute(() -> { /* some task */ });
}

3. Understanding the Impact on Blocking I/O

Consider a typical Spring Boot REST controller that interacts with a database and an external API:

Java

@Service
public class ProductService {

    private final ProductRepository productRepository;
    private final ExternalApiClient externalApiClient;

    public ProductService(ProductRepository productRepository, ExternalApiClient externalApiClient) {
        this.productRepository = productRepository;
        this.externalApiClient = externalApiClient;
    }

    public ProductDetails getProductDetails(Long productId) {
        // Blocking database call
        Product product = productRepository.findById(productId)
                                         .orElseThrow(() -> new ProductNotFoundException(productId));

        // Blocking external API call
        SupplierInfo supplierInfo = externalApiClient.getSupplierInfo(product.getSupplierId());

        return new ProductDetails(product, supplierInfo);
    }
}

@RestController
@RequestMapping("/products")
public class ProductController {

    private final ProductService productService;

    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    @GetMapping("/{id}")
    public ProductDetails getProduct(@PathVariable Long id) {
        return productService.getProductDetails(id);
    }
}

In a traditional setup, if productRepository.findById() or externalApiClient.getSupplierInfo() takes time, the OS thread serving this request would be blocked. With Virtual Threads enabled, when these blocking calls occur, the Virtual Thread is unmounted from its carrier thread. The carrier thread can then pick up and execute another Virtual Thread that is ready to run. Once the database or API call returns, the original Virtual Thread is remounted and continues its execution.

The key benefit: You write simple, synchronous-looking code, but gain the scalability advantages typically associated with complex asynchronous programming. Your code remains easy to read, debug, and maintain, while the JVM handles the efficient scheduling of I/O-bound tasks.

4. The Thread.builder().virtual().build() Approach

For more granular control, or when you need to explicitly create a Virtual Thread for a specific task outside of Spring’s managed executors, you can use the Thread.builder() API introduced in Java 19/21:

Java

import java.util.concurrent.Executors;

public class VirtualThreadExample {

    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            System.out.println("Running in virtual thread: " + Thread.currentThread());
            try {
                // Simulate blocking I/O
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            System.out.println("Finished in virtual thread: " + Thread.currentThread());
        };

        // Create and start a single virtual thread
        Thread virtualThread = Thread.builder().virtual().name("my-first-vt").build(task);
        virtualThread.start();
        virtualThread.join(); // Wait for the virtual thread to complete

        // Or create an ExecutorService that uses virtual threads
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 10; i++) {
                executor.submit(task);
            }
        } // executor.close() will wait for all submitted tasks to complete
    }
}

When to Use Virtual Threads (and When Not To)

Use Virtual Threads for:

  • I/O-Bound Applications: This is their primary use case. Any application that spends a significant amount of time waiting for external resources (databases, network calls, file systems) will see massive benefits in scalability and throughput.
  • Microservices: Ideal for services that frequently interact with other services or data stores.
  • Simplifying Asynchronous Code: If you’ve been struggling with CompletableFuture chains or reactive programming purely to handle I/O concurrency, Virtual Threads offer a simpler, more “traditional” programming model.

Virtual Threads are NOT a silver bullet for:

  • CPU-Bound Applications: If your application is constantly crunching numbers and spending most of its time on CPU-intensive computations, Virtual Threads won’t magically make it faster. In fact, too many CPU-bound Virtual Threads could degrade performance if they constantly contend for the limited carrier threads.
  • Replacing Reactive Programming (Entirely): Reactive programming (e.g., Spring WebFlux) still has its place, especially for event-driven architectures, streaming data, or situations where non-blocking backpressure and explicit asynchronous flow control are critical. Virtual Threads simplify the implementation of blocking I/O in a concurrent context but don’t inherently change the fundamental reactive paradigm.
  • Solving all Concurrency Problems: You still need to manage shared state, use proper synchronization primitives (locks, semaphores), and avoid race conditions. Virtual Threads make concurrency easier to scale, but not necessarily easier to reason about correctness in all scenarios.

Practical Considerations

  • Monitoring: While Virtual Threads are lightweight, monitoring their behavior might require updated tools or understanding how the JVM exposes their state.
  • Thread Locals: Be mindful of ThreadLocal usage. While Virtual Threads support ThreadLocals, excessive use can still lead to memory overhead. Consider ScopedValue (also part of Project Loom) as a more efficient alternative for passing context within a request.
  • Debugging: Debugging Virtual Threads should be largely similar to traditional threads in most modern IDEs.
  • Compatibility: Ensure you are running on Java 21 or newer and Spring Boot 3.2 or newer to fully leverage these features.

Conclusion

Java Virtual Threads, integrated seamlessly with Spring Boot, are set to redefine how we approach scalability in modern enterprise applications. By effectively eliminating the “thread-per-request” bottleneck for blocking I/O, they allow developers to write clean, straightforward code that can handle immense loads without resorting to complex asynchronous patterns.

If your Spring Boot services are I/O-bound, the time to explore Virtual Threads is now. They promise not just improved performance and reduced resource consumption, but also a significant simplification of your codebase, letting you focus on business logic rather than intricate concurrency management. Embrace the future of Java concurrency – it’s lighter, faster, and beautifully integrated with Spring Boot.

What are your thoughts on Virtual Threads? Have you tried them in your Spring Boot applications yet? Share your experiences in the comments below!



Discover more from GhostProgrammer - Jeff Miller

Subscribe to get the latest posts sent to your email.

By Jeffery Miller

I am known for being able to quickly decipher difficult problems to assist development teams in producing a solution. I have been called upon to be the Team Lead for multiple large-scale projects. I have a keen interest in learning new technologies, always ready for a new challenge.