How to Use Virtual Threads in Java (Project Loom) with Spring Boot

Virtual threads are one of the most exciting additions to modern Java. They make it much easier to write highly concurrent applications without the complexity of managing large thread pools, callbacks, or reactive pipelines.

If you build applications with Spring Boot, virtual threads can help your app handle many more concurrent tasks with simpler code.

In this post, we’ll cover:

  • What virtual threads are
  • Why they matter
  • When to use them
  • How to enable them in Spring Boot
  • A few important best practices and warnings

What are virtual threads?

Traditional Java threads are often called platform threads. They are mapped closely to operating system threads. That means they are relatively expensive in terms of memory and scheduling.

Virtual threads are lightweight threads managed by the JVM, not directly by the operating system. They are designed to make blocking code cheap again.

In simple terms:

  • Platform threads = heavier, fewer, more expensive
  • Virtual threads = lightweight, many more, cheaper to create

This means you can write code in the usual imperative style, but still support high concurrency.


Why virtual threads matter

For years, Java developers had two main choices for handling concurrency:

  1. Use thread pools and blocking code
  2. Use asynchronous/reactive programming

Virtual threads give you a third option:

  • Keep the simple blocking style
  • Avoid the complexity of reactive code
  • Scale better under lots of concurrent I/O operations

This is especially useful for applications that spend a lot of time waiting on:

  • Database calls
  • HTTP requests
  • File I/O
  • Remote service calls

If your application is mostly I/O-bound, virtual threads can be a great fit.


Virtual threads vs platform threads

Here’s a simple comparison:

Feature Platform Threads Virtual Threads
Cost to create Higher Very low
Memory usage Higher Lower
Number you can run Limited Much larger
Good for blocking code Yes, but expensive Yes, and efficient
Managed by OS Yes Mostly by JVM

The biggest win is that virtual threads let you run many blocking tasks concurrently without exhausting thread resources as quickly.


When should you use virtual threads?

Virtual threads are a strong choice when your app does lots of blocking I/O and you want simple code.

Good use cases:

  • REST APIs with many simultaneous requests
  • Service-to-service communication
  • Database-heavy applications
  • Background jobs that wait on I/O
  • File processing or batch operations

Less ideal use cases:

  • CPU-intensive tasks
  • Work that depends heavily on thread-local assumptions
  • Libraries that block while holding locks in a problematic way

Virtual threads do not magically make CPU-bound code faster. They help most when threads spend time waiting.


Project Loom in Java

Virtual threads are part of Project Loom, a long-running effort to modernize concurrency in Java.

Loom also introduced other improvements around structured concurrency and scoped values, but the headline feature most developers use today is virtual threads.

Virtual threads became a standard Java feature in recent releases, so you no longer need special preview settings in modern Java versions.


How Spring Boot supports virtual threads

Spring Boot has first-class support for virtual threads.

If you are using Spring Boot 3.2+, you can usually enable them with a simple configuration property:

spring.threads.virtual.enabled=true

That tells Spring Boot to use virtual threads in places where it manages request handling and task execution.

This is one of the nicest parts: in many cases, you can get the benefits of virtual threads with very little code change.


Basic setup in Spring Boot

1. Use a recent Java version

Virtual threads require a modern JDK, for example Java 25.

2. Use a recent Spring Boot version

Make sure your Spring Boot version supports virtual threads well. Spring Boot 3.2 or later is recommended.

3. Enable virtual threads

Add this to your application configuration:

spring.threads.virtual.enabled=true

Or in YAML:

spring:
  threads:
    virtual:
      enabled: true

That’s often enough for web applications.


What happens when you enable them?

When virtual threads are enabled, Spring can use them for tasks such as:

  • Handling incoming HTTP requests
  • Running @Async methods
  • Executing some scheduled or background tasks

The exact behavior depends on the Spring component and configuration, but the overall idea is that work can run on virtual threads instead of a small pool of platform threads.


Example: a Spring Boot REST controller

Here is a simple example of how your code can stay clean and blocking while still benefiting from virtual threads.

package com.example.demo;

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

@RestController
public class DemoController {

    private final DemoService demoService;

    public DemoController(DemoService demoService) {
        this.demoService = demoService;
    }

    @GetMapping("/hello")
    public String hello() {
        return demoService.fetchMessage();
    }
}
package com.example.demo;

import org.springframework.stereotype.Service;

@Service
public class DemoService {

    public String fetchMessage() {
        // Simulate a blocking operation
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return "Hello from a virtual thread!";
    }
}

With virtual threads enabled, each request can be handled with a lightweight thread, even though the service method blocks.


Example: using virtual threads for background tasks

You can also create virtual threads manually when needed.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class VirtualThreadExample {

    public static void main(String[] args) {
        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            executor.submit(() -> System.out.println("Task 1 on virtual thread"));
            executor.submit(() -> System.out.println("Task 2 on another virtual thread"));
        }
    }
}

This is useful when you want a simple executor that creates a new virtual thread for each task.


Using @Async with virtual threads

Spring’s @Async support can also benefit from virtual threads.

For example:

package com.example.demo;

import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

import java.util.concurrent.CompletableFuture;

@Service
public class EmailService {

    @Async
    public CompletableFuture<String> sendEmail() {
        // Simulate work
        return CompletableFuture.completedFuture("Email sent");
    }
}

If Spring is configured to use virtual threads for task execution, these async methods can run on virtual threads, making blocking work much cheaper.


Virtual threads do not replace everything

It’s important not to oversell virtual threads. They solve one problem very well: cheap blocking concurrency.

They do not replace:

  • Proper database indexing
  • Efficient network design
  • Good application architecture
  • Caching
  • Load balancing
  • Performance tuning

Virtual threads are a concurrency tool, not a performance silver bullet.


Best practices when using virtual threads

1. Keep code simple

One of the main advantages of virtual threads is that you can keep using normal blocking code. Don’t add unnecessary complexity.

2. Avoid long synchronized blocks

Virtual threads can behave poorly if they spend too much time blocked inside synchronized sections. Prefer shorter critical sections and consider alternatives where appropriate.

3. Watch out for thread-local usage

Some older code relies heavily on ThreadLocal. Virtual threads support it, but large-scale usage can still create complexity and memory overhead.

4. Test your dependencies

Most modern libraries work well, but some older libraries may assume platform-thread behavior or may not play nicely with high concurrency.

5. Don’t use virtual threads for CPU-heavy work expecting miracles

If your bottleneck is CPU, virtual threads will not fix it. For CPU-bound tasks, focus on algorithms, parallelism, and profiling.


Common questions

Are virtual threads faster?

Not always in a direct “single task runs faster” sense. Their big advantage is that they allow more concurrency with less overhead, especially for blocking I/O.

Do I need reactive programming anymore?

Not necessarily. Virtual threads reduce the need for reactive programming in many applications, especially if you prefer imperative code.

Can I use virtual threads with Spring MVC?

Yes. Spring MVC is a great fit because it already uses a blocking request model, which maps naturally to virtual threads.

Can I use them with Spring WebFlux?

You can, but WebFlux is built around a reactive model. Virtual threads are often more valuable in traditional blocking stacks like Spring MVC.


When virtual threads are a great fit in Spring Boot

Virtual threads are especially attractive if:

  • You already have a Spring MVC application
  • You use JDBC and blocking database access
  • Your codebase is imperative and you want to keep it that way
  • You want to handle more concurrent requests with less thread-pool tuning

In many cases, virtual threads let you modernize your app without rewriting it.


A practical migration strategy

If you want to adopt virtual threads in an existing Spring Boot app, here’s a simple approach:

  1. Upgrade to a compatible Java and Spring Boot version
  2. Enable virtual threads in configuration
  3. Test your main request paths
  4. Watch application metrics under load
  5. Check library compatibility
  6. Tune only where needed

You usually do not need to rewrite your whole application.


Final thoughts

Virtual threads are a major step forward for Java concurrency. They make it possible to write straightforward, blocking-style code while still supporting high throughput and scalability.

For Spring Boot developers, this is especially valuable because it means:

  • Less concurrency boilerplate
  • Easier-to-read code
  • Better scaling for I/O-heavy workloads
  • A smoother path than fully reactive programming for many apps

If your application spends a lot of time waiting on I/O, virtual threads are absolutely worth trying.


Summary

Use virtual threads in Spring Boot when you want:

  • Simple blocking code
  • High concurrency
  • Less thread-pool management
  • Better scalability for I/O-bound workloads

Enable them with:

spring.threads.virtual.enabled=true

Then test, measure, and enjoy a much simpler concurrency model in Java.

How do I map a request to a controller in Spring MVC?

In Spring MVC, you can map a request to a controller using the @RequestMapping (or aliases like @GetMapping, @PostMapping, etc.) annotation on a method. These annotations define a specific URL path and HTTP method that the method will handle. Here’s how you can do it:

Step-by-Step Instructions:

  1. Annotate the Class as a Controller: Use the @Controller annotation (or @RestController for REST APIs) on the class to indicate that it is a controller in your Spring application.
  2. Map URLs with @RequestMapping: Use the @RequestMapping annotation on methods to specify the URL patterns you want to map HTTP requests to. You can also use HTTP method-specific annotations, such as @GetMapping and @PostMapping.

Example

package org.kodejava.app;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
@RequestMapping("/my-controller") // Base path for all endpoints in the controller
public class MyController {

    // Handles GET requests to /my-controller/hello
    @GetMapping("/hello")
    @ResponseBody
    public String sayHello() {
        return "Hello, World!";
    }

    // Handles POST requests to /my-controller/data
    @PostMapping("/data")
    @ResponseBody
    public String handleDataSubmission() {
        return "Data submitted successfully!";
    }
}

Key Annotations:

  1. @Controller: Marks the class as a Spring MVC controller.
  2. @RequestMapping: Used to map web requests onto specific methods or classes. It can be used with a combination of HTTP methods, URL patterns, and request parameters.
    @RequestMapping(value = "/example", method = RequestMethod.GET)
    
  3. HTTP Method-Specific Annotations: These are shorthand annotations for specific HTTP methods:
    • @GetMapping for GET requests
    • @PostMapping for POST requests
    • @PutMapping for PUT requests
    • @DeleteMapping for DELETE requests
    • @PatchMapping for PATCH requests

Notes:

  1. Class-Level @RequestMapping: If you specify a base path with @RequestMapping at the class level, all methods will inherit this as part of their path.
  2. Return Types:
    • Use @ResponseBody to return plain text, JSON, or XML directly in the HTTP response body.
    • If you are returning a view name, you don’t need @ResponseBody.
  3. Path Variables and Query Parameters: You can also handle dynamic path variables using the @PathVariable and request parameters using the @RequestParam annotations.

Example with Path Variables and Request Parameters:

package org.kodejava.app;

import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/users")
public class UserController {

    // Dynamic path variable
    @GetMapping("/{id}")
    public String getUserById(@PathVariable("id") Long id) {
        return "User ID: " + id;
    }

    // Query parameter
    @GetMapping("/search")
    public String searchUsers(@RequestParam("name") String name) {
        return "Searching for user: " + name;
    }
}

By following this pattern, you can map requests to specific controller methods and build robust web applications with Spring MVC.

Testing Spring Boot Applications with JUnit and Mockito

Testing Spring Boot applications with JUnit and Mockito is a good practice to ensure the correctness and reliability of your application. Below, I’ll present a brief overview of how to write such test cases with explanations and examples.

1. JUnit Basics in Spring Boot

Spring Boot provides out-of-the-box support for JUnit through the spring-boot-starter-test dependency, which includes:

  • JUnit 5 (Jupiter) for writing test cases.
  • Mockito for mocking dependencies.
  • Additional libraries like Hamcrest and AssertJ for assertions.

Add the dependency in your pom.xml (if it’s not already present):

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

2. Structural Overview

Testing can be divided into:

  • Unit Tests: Independent and isolated tests of individual classes, written using JUnit + Mockito.
  • Integration Tests: Testing multiple layers/classes, typically with Spring’s @SpringBootTest.

3. Writing Unit Tests with JUnit and Mockito

Below is an example of unit testing a Service class.

Example Scenario:

We have a class that depends on UserRepository. UserService
UserService.java:

@Service
public class UserService {

    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public User findUserById(Long id) {
        return userRepository.findById(id).orElseThrow(() -> new RuntimeException("User not found"));
    }
}

UserRepository.java:

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
}

Corresponding Test Case

@ExtendWith(MockitoExtension.class) // Enables Mockito in JUnit 5
class UserServiceTest {

    @Mock // Creates a mock instance of UserRepository
    private UserRepository userRepository;

    @InjectMocks // Creates UserService and injects the mock UserRepository
    private UserService userService;

    @Test
    void testFindUserById_UserExists() {
        // Arrange
        Long userId = 1L;
        User mockUser = new User(userId, "John Doe", "[email protected]");

        // Mock the behavior of userRepository
        Mockito.when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser));

        // Act
        User result = userService.findUserById(userId);

        // Assert
        assertNotNull(result);
        assertEquals(mockUser.getName(), result.getName());
        Mockito.verify(userRepository).findById(userId); // Verify method call
    }

    @Test
    void testFindUserById_UserNotFound() {
        // Arrange
        Long userId = 1L;

        // Mock the behavior of userRepository
        Mockito.when(userRepository.findById(userId)).thenReturn(Optional.empty());

        // Act & Assert
        Exception exception = assertThrows(RuntimeException.class, () -> userService.findUserById(userId));
        assertEquals("User not found", exception.getMessage());
    }
}

4. Integration Tests with @SpringBootTest

Integration tests are used to test the entire Spring application context.

Testing UserController

UserController.java:

@RestController
@RequestMapping("/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/{id}")
    public ResponseEntity<User> getUserById(@PathVariable Long id) {
        return ResponseEntity.ok(userService.findUserById(id));
    }
}

Test Case for UserController

Use @SpringBootTest with for integration-like testing. MockMvc

@SpringBootTest
@AutoConfigureMockMvc // Configures MockMvc
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean // Mock a bean in the Spring context
    private UserService userService;

    @Test
    void testGetUserById() throws Exception {
        // Arrange
        Long userId = 1L;
        User mockUser = new User(userId, "John Doe", "[email protected]");

        Mockito.when(userService.findUserById(userId)).thenReturn(mockUser);

        // Act and Assert
        mockMvc.perform(get("/users/{id}", userId))
               .andExpect(status().isOk())
               .andExpect(jsonPath("$.name").value("John Doe"))
               .andExpect(jsonPath("$.email").value("[email protected]"));

        Mockito.verify(userService).findUserById(userId);
    }
}

5. Tips for Effective Testing

  1. Mock dependencies: Mock all external dependencies to isolate the unit being tested.
  2. Use Assertions effectively: Leverage libraries like AssertJ or Hamcrest for expressive assertions.
  3. Test exceptions: Always test boundary cases and exceptions.
  4. Verify mock behavior: Use Mockito.verify() to ensure mocked methods were invoked correctly.
  5. Spring utilities: @MockBean is useful for overriding beans in the application context for integration testing.

Summary

Spring Boot testing with JUnit and Mockito allows you to:

  • Write isolated unit tests for business logic.
  • Write integration tests to validate Spring components working together.

How do I configure Spring MVC using Java-based configuration?

To configure Spring MVC using a Java-based configuration, you can follow these steps:

  1. Enable Spring MVC support by using the @EnableWebMvc annotation.
  2. Create a Spring configuration class annotated with @Configuration.
  3. Configure the component scanning to detect controllers, services, and other components.
  4. Define a ViewResolver bean to map view names to actual views (e.g., JSPs, Thymeleaf templates, etc.).
  5. Set up other essential configurations like static resource handling, CORS, or message converters if necessary.

Here’s an example configuration class:

package org.kodejava.demo;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.view.InternalResourceViewResolver;

@Configuration
@EnableWebMvc
@ComponentScan(basePackages = "org.kodejava.demo") // Specify your base package where controllers are located
public class WebConfig implements WebMvcConfigurer {

    // Define a ViewResolver bean
    @Bean
    public InternalResourceViewResolver viewResolver() {
        InternalResourceViewResolver viewResolver = new InternalResourceViewResolver();
        viewResolver.setPrefix("/WEB-INF/views/"); // Path to your view templates
        viewResolver.setSuffix(".jsp"); // View file extension (e.g., .jsp or .html)
        return viewResolver;
    }

    // Static resource handling (e.g., for serving CSS, JS, images, etc.)
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/resources/**")
                .addResourceLocations("/resources/");
    }
}

Step-by-step Explanation:

  1. @EnableWebMvc:
    • This annotation imports Spring MVC configuration from WebMvcConfigurationSupport, which enables features like DispatcherServlet, handler mappings, and more.
  2. @ComponentScan:
    • This annotation configures Spring to scan the specified package(s) for components, such as controllers, services, and repositories.
  3. Define a ViewResolver:
    • In the example, InternalResourceViewResolver maps view names to JSP files located under /WEB-INF/views/ with the .jsp extension.
  4. Static Resources:
    • The addResourceHandlers method maps requests for static resources (e.g., CSS, JS, images) to physical locations.

Setting Up the DispatcherServlet

You’ll also need to configure the DispatcherServlet in your file (if you’re using a traditional deployment structure) or via a programmatic initializer (preferred in modern setups). web.xml
Here’s an example of a Java-based initializer:

import org.springframework.web.servlet.support.AbstractAnnotationConfigDispatcherServletInitializer;

public class WebAppInitializer extends AbstractAnnotationConfigDispatcherServletInitializer {

    @Override
    protected Class<?>[] getRootConfigClasses() {
        return new Class[] { RootConfig.class }; // Configuration for application-wide beans (e.g., data sources)
    }

    @Override
    protected Class<?>[] getServletConfigClasses() {
        return new Class[] { WebConfig.class }; // Configuration for Spring MVC
    }

    @Override
    protected String[] getServletMappings() {
        return new String[] { "/" }; // Map all requests to DispatcherServlet
    }
}

Explanation of the Java-Based Initializer:

  • getRootConfigClasses:
    • Specifies configuration for the root application context, such as services, persistence, or security.
  • getServletConfigClasses:
    • Specifies configuration for the Spring MVC child context (e.g., controllers, view resolvers).
  • getServletMappings:
    • Maps the DispatcherServlet to handle requests starting at . /

With these steps, you’ll have a fully functional Spring MVC configuration using Java-based configuration.

Implementing Global Exception Handling with @ControllerAdvice

To implement global exception handling in a Spring application, the @ControllerAdvice annotation is used. It allows you to centralize exception handling across multiple controllers.

Below is an example of how you can implement global exception handling with @ControllerAdvice in your application:


1. Define a Global Exception Handler

Create a class with the @ControllerAdvice annotation to handle exceptions globally. Within this class, use the @ExceptionHandler annotation on methods to define specific exception handling logic.

package org.kodejava;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;

// Marks this class as a global exception handler
@ControllerAdvice
public class GlobalExceptionHandler {

    // Handle specific exceptions
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<?> handleResourceNotFoundException(ResourceNotFoundException ex, WebRequest request) {
        ErrorResponse errorResponse = new ErrorResponse(
                HttpStatus.NOT_FOUND.value(),
                ex.getMessage(),
                request.getDescription(false));
        return new ResponseEntity<>(errorResponse, HttpStatus.NOT_FOUND);
    }

    // Handle global exceptions (for all other exceptions)
    @ExceptionHandler(Exception.class)
    public ResponseEntity<?> handleGlobalException(Exception ex, WebRequest request) {
        ErrorResponse errorResponse = new ErrorResponse(
                HttpStatus.INTERNAL_SERVER_ERROR.value(),
                "An unexpected error occurred",
                request.getDescription(false));
        return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR);
    }
}

2. Create a Custom Exception Class (Optional)

Define specific exception classes for your business use cases. For example, a ResourceNotFoundException for handling “not found” errors.

package org.kodejava;

public class ResourceNotFoundException extends RuntimeException {

    private static final long serialVersionUID = 1L;

    public ResourceNotFoundException(String message) {
        super(message);
    }
}

3. Create an Error Response Model

Create a class to structure the error response data consistently.

package org.kodejava;

public class ErrorResponse {

    private int statusCode;
    private String message;
    private String details;

    public ErrorResponse(int statusCode, String message, String details) {
        this.statusCode = statusCode;
        this.message = message;
        this.details = details;
    }

    // Getters and Setters
    public int getStatusCode() {
        return statusCode;
    }

    public void setStatusCode(int statusCode) {
        this.statusCode = statusCode;
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }

    public String getDetails() {
        return details;
    }

    public void setDetails(String details) {
        this.details = details;
    }
}

4. Throw Custom Exceptions in Your Controller

You can now throw the ResourceNotFoundException or other exceptions in your controllers, and let the global exception handler process them.

package org.kodejava;

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

@RestController
@RequestMapping("/api")
public class TestController {

    @GetMapping("/resource")
    public String getResource() {
        throw new ResourceNotFoundException("Resource not found with ID");
    }
}

5. Test the Application

When you access the /api/resource endpoint, the global exception handler will catch the ResourceNotFoundException and return a structured error response. Example response:

{
  "statusCode": 404,
  "message": "Resource not found with ID",
  "details": "uri=/api/resource"
}

Advantages of Using @ControllerAdvice

  1. Centralized Exception Handling: Removes the need to write exception handling in multiple controllers.
  2. Improved Readability: Controllers are cleaner as they no longer handle exceptions.
  3. Reusability: Reuse the exception handler for different types of exceptions across the application.
  4. Custom Responses: Provides flexibility to return consistent error responses.

This implementation ensures your application has a robust and maintainable error-handling mechanism using Spring’s @ControllerAdvice.