Spring Boot 3’s virtual threads, powered by Project Loom, fundamentally change how your application handles concurrency, allowing thousands of concurrent tasks to run on a small number of OS threads.

Let’s see this in action. Imagine a simple web service that simulates a long-running operation, like calling an external API or performing a database query.

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.concurrent.TimeUnit;

@RestController
public class SlowServiceController {

    @GetMapping("/slow")
    public String slowOperation() {
        try {
            // Simulate a blocking operation
            TimeUnit.SECONDS.sleep(5);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return "Operation interrupted!";
        }
        return "Operation completed!";
    }
}

Traditionally, if you sent 100 requests to this /slow endpoint concurrently, and your application was configured with a standard thread pool (say, 10 threads), you’d see requests queuing up. Each request would occupy an OS thread for its entire 5-second duration, even though most of that time is spent waiting. With virtual threads, the story is different. When TimeUnit.SECONDS.sleep(5) is called, the virtual thread unmounts from its carrier OS thread. This allows the OS thread to immediately pick up another incoming request. When the 5 seconds are up, the virtual thread remounts onto an available OS thread to resume execution. This means all 100 requests could potentially complete much faster, without needing 100 OS threads.

To enable virtual threads in Spring Boot 3, you need to configure your embedded Tomcat (or Jetty/Undertow) server to use a virtual thread-based TaskExecutor.

First, add the necessary dependency if you’re not already using Spring Boot 3.1 or later, which includes Loom support out of the box for Tomcat. For other servers or earlier versions, you might need explicit configuration.

<!-- For Maven -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <version>3.2.0</version> <!-- Or later -->
</dependency>

The core of the setup is a TaskExecutor bean that uses virtual threads. Spring Boot provides auto-configuration for Tomcat when certain conditions are met, but explicit configuration gives you more control.

import org.springframework.boot.web.embedded.tomcat.TomcatProtocolHandlerCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.AsyncTaskExecutor;
import org.springframework.core.task.support.TaskExecutorAdapter;

import java.util.concurrent.Executors;

@Configuration
public class VirtualThreadConfig {

    @Bean
    public AsyncTaskExecutor applicationTaskExecutor() {
        // This creates a TaskExecutor that uses virtual threads
        return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
    }

    // This bean configures Tomcat to use virtual threads for request handling
    // It's essential for web applications to leverage virtual threads for incoming requests.
    @Bean
    public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
        return protocolHandler -> protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
    }
}

With this configuration, any asynchronous operations managed by Spring’s TaskExecutor and incoming web requests handled by Tomcat will now utilize virtual threads. You don’t need to change your application code; blocking APIs like Thread.sleep(), JDBC calls, or CompletableFuture operations will automatically benefit from the non-blocking nature of virtual threads when they are running within this executor.

The mental model shift is crucial: instead of thinking about a fixed pool of OS threads that must be managed carefully to avoid exhaustion, you think about a vast, cheap pool of virtual threads. Each virtual thread is like a lightweight task. When it blocks, it yields the underlying OS thread back to the system. The JVM and the underlying OS scheduler then manage the resumption of these virtual threads when their blocking operations complete. This dramatically simplifies concurrency management for I/O-bound workloads.

The most surprising thing is that you don’t have to change most of your existing blocking code to make it non-blocking. The virtual thread mechanism handles the "illusion" of non-blocking for you. When a virtual thread executes a blocking API call, like InputStream.read() or HttpClient.send(), the Java runtime doesn’t block the underlying OS thread. Instead, it "unmounts" the virtual thread from the OS thread, allowing the OS thread to serve another virtual thread. When the blocking operation finishes, the virtual thread is scheduled to be "remounted" onto an available OS thread to continue its execution. This is a deep, internal mechanism of the JVM, managed by the java.util.concurrent.ForkJoinPool or Executors.newVirtualThreadPerTaskExecutor() under the hood, and it works seamlessly with your existing, familiar Java APIs.

The next challenge is understanding how to properly manage and monitor these virtual threads, especially in complex asynchronous scenarios and when interacting with libraries not yet fully optimized for Project Loom.

Want structured learning?

Take the full Spring-boot course →