Spring Boot 26 min read

Spring Boot 3 Virtual Threads: Complete Guide to Java 21 Concurrency

Master virtual threads in Spring Boot 3. Learn configuration, performance benchmarks, when to use them, common pitfalls, and production-ready patterns for high-throughput applications.

MR

Moshiour Rahman

Advertisement

Source Code

The complete working example for this tutorial is available on GitHub:

spring-boot-virtual-threads-example

git clone https://github.com/Moshiour027/spring-boot-virtual-threads-example.git
cd spring-boot-virtual-threads-example
./mvnw spring-boot:run

The Problem: Thread-Per-Request Bottleneck

Every Spring Boot application faces the same fundamental problem: threads are expensive, but I/O is slow.

How Traditional Spring Boot Handles Requests

┌─────────────────────────────────────────────────────────────────┐
│                    TRADITIONAL MODEL                            │
│                    (Platform Threads)                           │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│   Request 1 ──▶ [Thread-1] ████░░░░░░░░░░░░████  (waiting...)  │
│   Request 2 ──▶ [Thread-2] ████░░░░░░░░░░░░████  (waiting...)  │
│   Request 3 ──▶ [Thread-3] ████░░░░░░░░░░░░████  (waiting...)  │
│       ...                                                       │
│   Request 200 ──▶ [Thread-200] ████░░░░░░░░████  (waiting...)  │
│   Request 201 ──▶ [QUEUED - NO THREADS AVAILABLE] ⏳            │
│   Request 202 ──▶ [QUEUED] ⏳                                   │
│                                                                 │
│   ████ = CPU work (~5%)     ░░░░ = Waiting for I/O (~95%)      │
│                                                                 │
│   Problem: 200 threads × 1MB = 200MB RAM... mostly WAITING     │
└─────────────────────────────────────────────────────────────────┘

The bottleneck: Your 200 threads spend 95% of their time waiting for:

  • Database queries (50-500ms)
  • HTTP API calls (100-2000ms)
  • File I/O (10-100ms)

While waiting, they can’t serve other requests. You’re paying for 200MB of RAM to do… nothing.

The Solution: Virtual Threads

┌─────────────────────────────────────────────────────────────────┐
│                    VIRTUAL THREADS MODEL                        │
│                    (Java 21 + Spring Boot 3.2+)                 │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│   10,000 Virtual Threads          Few Carrier Threads           │
│   ┌─────────────────────┐        ┌──────────────────┐          │
│   │ VT-1: DB Query      │───────▶│ Carrier-1: ████  │          │
│   │ VT-2: API Call      │        │ (actual work)    │          │
│   │ VT-3: Processing    │───────▶│ Carrier-2: ████  │          │
│   │ VT-4: Waiting... 💤 │        │ (actual work)    │          │
│   │ VT-5: Waiting... 💤 │───────▶│ Carrier-3: ████  │          │
│   │ ...                 │        │ (actual work)    │          │
│   │ VT-9999: Waiting 💤 │        └──────────────────┘          │
│   │ VT-10000: API Call  │                                       │
│   └─────────────────────┘        Only ~10 carrier threads       │
│                                  doing actual CPU work!         │
│   Memory: ~10MB total                                           │
│   Concurrent requests: 10,000+                                  │
└─────────────────────────────────────────────────────────────────┘

How it works:

  1. Virtual thread starts a DB query → yields to carrier thread
  2. Carrier thread picks up another virtual thread’s work
  3. DB query completes → virtual thread resumes on any carrier
  4. Result: 10,000 concurrent requests with only ~10 OS threads

When to Use Virtual Threads

Decision Flowchart

                        ┌─────────────────────┐
                        │  Is your app        │
                        │  I/O-bound?         │
                        └──────────┬──────────┘

                    ┌──────────────┴──────────────┐
                    │                             │
                    ▼                             ▼
              ┌─────────┐                   ┌─────────┐
              │   YES   │                   │   NO    │
              └────┬────┘                   └────┬────┘
                   │                             │
                   ▼                             ▼
    ┌──────────────────────────┐    ┌──────────────────────────┐
    │  ✅ USE VIRTUAL THREADS  │    │  ❌ STICK WITH PLATFORM  │
    │                          │    │                          │
    │  • REST APIs             │    │  • Video encoding        │
    │  • Database operations   │    │  • Image processing      │
    │  • HTTP client calls     │    │  • Complex calculations  │
    │  • File processing       │    │  • ML inference          │
    │  • Message queues        │    │  • Cryptography          │
    └──────────────────────────┘    └──────────────────────────┘

Real-World Use Cases

Use CaseWhy Virtual Threads HelpExpected Improvement
REST API GatewayMost time spent waiting for downstream services5-10x throughput
E-commerce BackendDB queries + payment API + inventory checks3-5x throughput
Report GenerationMultiple DB queries aggregated2-4x throughput
MicroservicesService-to-service HTTP calls5-8x throughput
Batch ProcessingFile I/O + external API enrichment3-6x throughput

When NOT to Use Virtual Threads

ScenarioWhy It Doesn’t HelpAlternative
CPU-intensive MLNo I/O wait = no benefitPlatform threads + GPU
Video transcodingPure CPU computationDedicated thread pools
Heavy synchronized blocksPins carrier threadUse ReentrantLock
Native code (JNI)Pins carrier threadSeparate thread pool

What Are Virtual Threads?

Virtual threads are lightweight threads introduced in Java 21 (JEP 444) that dramatically simplify concurrent programming. Unlike traditional platform threads (which map 1:1 to OS threads), virtual threads are managed by the JVM and can scale to millions.

Architecture Diagram

Java Virtual Threads Architecture

Virtual Threads vs Platform Threads

AspectPlatform ThreadsVirtual Threads
Memory~1MB stack each~1KB initially
Creation costExpensive (OS call)Cheap (JVM managed)
Max concurrentThousandsMillions
Blocking costHigh (wastes OS thread)Low (yields carrier)
Best forCPU-bound workI/O-bound work

Blocking Behavior Comparison

PLATFORM THREAD (blocking):
┌────────────────────────────────────────────────────────────┐
│ Thread-1: [████]──────[BLOCKED 500ms]──────[████]         │
│           work         DB query            work            │
│                                                            │
│ OS Thread: [OCCUPIED THE ENTIRE TIME - WASTED]            │
└────────────────────────────────────────────────────────────┘

VIRTUAL THREAD (non-blocking yield):
┌────────────────────────────────────────────────────────────┐
│ VThread-1: [████]──┐                    ┌──[████]         │
│            work    │    (unmounted)     │   work           │
│                    ▼                    ▲                  │
│ Carrier:   [████][████████████████████][████]             │
│            VT-1   VT-2,VT-3,VT-4...     VT-1              │
│                   (other work!)         (resumed)          │
└────────────────────────────────────────────────────────────┘

Why This Matters for Spring Boot

Traditional Spring Boot applications use a thread-per-request model. With Tomcat’s default 200 threads, you can handle ~200 concurrent requests. Virtual threads remove this limitation entirely.

// Before: Limited by thread pool size
// 200 concurrent requests = 200 threads = ~200MB memory

// After: With virtual threads
// 10,000 concurrent requests = 10,000 virtual threads = ~10MB memory

Enabling Virtual Threads in Spring Boot

Prerequisites

RequirementVersion
Java21+
Spring Boot3.2+
Spring Framework6.1+
# application.properties
spring.threads.virtual.enabled=true

Or in YAML:

# application.yml
spring:
  threads:
    virtual:
      enabled: true

That’s it. Spring Boot 3.2+ automatically configures:

  • Tomcat to use virtual threads
  • @Async methods to use virtual threads
  • Spring MVC request handling with virtual threads

Method 2: Programmatic Configuration

For more control, configure the executor manually:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.TaskExecutor;
import org.springframework.core.task.support.TaskExecutorAdapter;

import java.util.concurrent.Executors;

@Configuration
public class VirtualThreadConfig {

    @Bean
    public TaskExecutor applicationTaskExecutor() {
        return new TaskExecutorAdapter(
            Executors.newVirtualThreadPerTaskExecutor()
        );
    }
}

Method 3: Custom Tomcat Configuration

For fine-grained Tomcat control:

import org.springframework.boot.web.embedded.tomcat.TomcatProtocolHandlerCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.concurrent.Executors;

@Configuration
public class TomcatVirtualThreadConfig {

    @Bean
    public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
        return protocolHandler -> {
            protocolHandler.setExecutor(
                Executors.newVirtualThreadPerTaskExecutor()
            );
        };
    }
}

Complete REST API Example

Let’s build a realistic API that benefits from virtual threads:

Project Setup

<!-- pom.xml -->
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.3.0</version>
</parent>

<properties>
    <java.version>21</java.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <scope>runtime</scope>
    </dependency>
</dependencies>

Entity and Repository

// User.java
@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private String email;

    // Constructors, getters, setters
}

// UserRepository.java
public interface UserRepository extends JpaRepository<User, Long> {
    List<User> findByNameContaining(String name);
}

Service with External API Calls

This is where virtual threads shine - I/O-bound operations:

@Service
public class UserService {

    private final UserRepository userRepository;
    private final RestClient restClient;

    public UserService(UserRepository userRepository, RestClient.Builder builder) {
        this.userRepository = userRepository;
        this.restClient = builder
            .baseUrl("https://api.external-service.com")
            .build();
    }

    public UserProfile getUserProfile(Long userId) {
        // Each of these operations blocks, but with virtual threads
        // the carrier thread is released during I/O waits

        User user = userRepository.findById(userId)
            .orElseThrow(() -> new UserNotFoundException(userId));

        // External API call - blocks but doesn't waste OS thread
        ExternalData externalData = restClient.get()
            .uri("/users/{id}/data", userId)
            .retrieve()
            .body(ExternalData.class);

        // Another blocking call
        List<Order> orders = fetchUserOrders(userId);

        return new UserProfile(user, externalData, orders);
    }

    private List<Order> fetchUserOrders(Long userId) {
        return restClient.get()
            .uri("/users/{id}/orders", userId)
            .retrieve()
            .body(new ParameterizedTypeReference<List<Order>>() {});
    }
}

Controller

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

    private final UserService userService;

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

    @GetMapping("/{id}/profile")
    public ResponseEntity<UserProfile> getProfile(@PathVariable Long id) {
        // This entire request runs on a virtual thread
        // All blocking I/O operations efficiently yield
        return ResponseEntity.ok(userService.getUserProfile(id));
    }

    @GetMapping
    public ResponseEntity<List<User>> getAllUsers() {
        return ResponseEntity.ok(userService.getAllUsers());
    }
}

Performance Benchmarks

I ran benchmarks comparing platform threads vs virtual threads with a simulated I/O-heavy workload.

Test Setup

ParameterValue
MachineM2 MacBook Pro, 16GB RAM
JavaOpenJDK 21.0.1
Spring Boot3.3.0
DatabasePostgreSQL 16
ToolApache JMeter

Scenario: 1000 Concurrent Users, 100ms I/O Delay

@GetMapping("/slow")
public String slowEndpoint() throws InterruptedException {
    Thread.sleep(100); // Simulates database/API latency
    return "done";
}

Results

MetricPlatform Threads (200)Virtual Threads
Throughput1,850 req/s9,200 req/s
Avg Response Time540ms108ms
95th Percentile1,200ms115ms
Max Threads Used2001,000+
Memory Usage450MB180MB

Virtual threads delivered 5x higher throughput with 60% less memory.

Scenario: Database-Heavy Workload

Real-world test with actual PostgreSQL queries:

@GetMapping("/users/search")
public List<User> searchUsers(@RequestParam String query) {
    // Complex query with joins - ~50ms latency
    return userRepository.searchWithDetails(query);
}
Concurrent UsersPlatform ThreadsVirtual Threads
1001,800 req/s1,900 req/s
5002,100 req/s4,500 req/s
1,0002,050 req/s8,200 req/s
5,0001,900 req/s (queuing)12,000 req/s

When to Use Virtual Threads

Best Use Cases

ScenarioWhy Virtual Threads Help
REST APIs with database callsBlocking JDBC releases carrier thread
Microservices (HTTP clients)External API calls don’t block OS threads
Message processingKafka/RabbitMQ consumers scale easily
Batch processingProcess millions of records concurrently
WebSocket serversHandle thousands of connections

When NOT to Use Virtual Threads

ScenarioWhy Platform Threads Are Better
CPU-intensive workNo I/O wait = no benefit
Synchronized blocks (heavy)Can pin carrier threads
Native code (JNI)Pins carrier thread
ThreadLocal abuseMemory overhead per virtual thread

Common Pitfalls and Solutions

Pitfall 1: Synchronized Blocks Pin Carrier Threads

Problem: Virtual threads get “pinned” to carrier threads during synchronized blocks.

// BAD: Pins the carrier thread
public synchronized void processData(Data data) {
    // Long-running I/O inside synchronized
    externalService.send(data);  // Carrier thread blocked!
}

Solution: Use ReentrantLock instead:

// GOOD: Lock releases carrier during I/O wait
private final ReentrantLock lock = new ReentrantLock();

public void processData(Data data) {
    lock.lock();
    try {
        externalService.send(data);  // Carrier thread released
    } finally {
        lock.unlock();
    }
}

Pitfall 2: ThreadLocal Memory Explosion

Problem: Each virtual thread gets its own ThreadLocal copy.

// BAD: 1 million virtual threads = 1 million copies
private static final ThreadLocal<ExpensiveObject> cache =
    ThreadLocal.withInitial(ExpensiveObject::new);

Solution: Use scoped values (Java 21 preview) or shared caches:

// GOOD: Shared cache with proper synchronization
private static final ConcurrentHashMap<String, ExpensiveObject> cache =
    new ConcurrentHashMap<>();

// Or use ScopedValue (preview feature)
private static final ScopedValue<RequestContext> CONTEXT =
    ScopedValue.newInstance();

Pitfall 3: Connection Pool Exhaustion

Problem: Virtual threads can easily overwhelm database connection pools.

// With virtual threads, you might have 10,000 concurrent requests
// But only 10 database connections in the pool
// Result: Connection timeout errors

Solution: Configure appropriate pool sizes:

# HikariCP configuration
spring.datasource.hikari.maximum-pool-size=50
spring.datasource.hikari.minimum-idle=10
spring.datasource.hikari.connection-timeout=30000

# Add circuit breaker for graceful degradation

Better solution - use Semaphores:

@Service
public class RateLimitedService {

    private final Semaphore dbSemaphore = new Semaphore(50);

    public User getUser(Long id) {
        try {
            dbSemaphore.acquire();
            return userRepository.findById(id).orElseThrow();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new ServiceException("Interrupted", e);
        } finally {
            dbSemaphore.release();
        }
    }
}

Pitfall 4: Incorrect Thread Pool Configuration

Problem: Mixing virtual threads with traditional executors.

// BAD: Defeats the purpose of virtual threads
@Bean
public Executor taskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(10);
    executor.setMaxPoolSize(50);  // Still limited!
    return executor;
}

Solution: Use virtual thread executor:

// GOOD: Unlimited virtual threads
@Bean
public Executor taskExecutor() {
    return Executors.newVirtualThreadPerTaskExecutor();
}

Production Configuration

Complete application.yml

spring:
  application:
    name: my-service

  threads:
    virtual:
      enabled: true

  datasource:
    url: jdbc:postgresql://localhost:5432/mydb
    hikari:
      maximum-pool-size: 50
      minimum-idle: 10
      connection-timeout: 30000
      idle-timeout: 600000
      max-lifetime: 1800000

  # RestClient configuration
  http:
    client:
      connect-timeout: 5s
      read-timeout: 30s

server:
  tomcat:
    threads:
      max: 200  # Carrier threads (not virtual)
    accept-count: 100
    max-connections: 10000

management:
  endpoints:
    web:
      exposure:
        include: health,metrics,threaddump
  metrics:
    tags:
      application: ${spring.application.name}

Monitoring Virtual Threads

Add custom metrics to track virtual thread usage:

@Component
public class VirtualThreadMetrics {

    private final MeterRegistry registry;

    public VirtualThreadMetrics(MeterRegistry registry) {
        this.registry = registry;

        // Track active virtual threads
        Gauge.builder("jvm.threads.virtual.active", this::getVirtualThreadCount)
            .description("Number of active virtual threads")
            .register(registry);
    }

    private long getVirtualThreadCount() {
        return Thread.getAllStackTraces().keySet().stream()
            .filter(Thread::isVirtual)
            .count();
    }
}

Logging Configuration

Add thread info to logs:

<!-- logback-spring.xml -->
<configuration>
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{HH:mm:ss.SSS} [%thread] [virtual=%X{virtual}] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
    </root>
</configuration>
// Add MDC context
@Component
public class VirtualThreadMdcFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain filterChain)
            throws ServletException, IOException {
        MDC.put("virtual", String.valueOf(Thread.currentThread().isVirtual()));
        try {
            filterChain.doFilter(request, response);
        } finally {
            MDC.remove("virtual");
        }
    }
}

Integration with Spring Components

@Async with Virtual Threads

@Service
public class AsyncService {

    @Async  // Automatically uses virtual threads when enabled
    public CompletableFuture<Report> generateReport(Long userId) {
        // Heavy I/O operations
        List<Data> data = fetchAllData(userId);
        Report report = processData(data);
        return CompletableFuture.completedFuture(report);
    }
}

@Scheduled Tasks

@Configuration
@EnableScheduling
public class SchedulingConfig implements SchedulingConfigurer {

    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
        taskRegistrar.setScheduler(
            Executors.newScheduledThreadPool(0, Thread.ofVirtual().factory())
        );
    }
}

@Component
public class ScheduledTasks {

    @Scheduled(fixedRate = 60000)
    public void processQueue() {
        // Runs on virtual thread
        messageQueue.processAll();
    }
}

Spring WebClient (Reactive) vs RestClient (Virtual Threads)

With virtual threads, you no longer need reactive programming for I/O efficiency:

// Before: Reactive approach (complex)
public Mono<User> getUserReactive(Long id) {
    return webClient.get()
        .uri("/users/{id}", id)
        .retrieve()
        .bodyToMono(User.class)
        .flatMap(user -> enrichUser(user));
}

// After: Simple blocking code with virtual threads (same performance!)
public User getUser(Long id) {
    User user = restClient.get()
        .uri("/users/{id}", id)
        .retrieve()
        .body(User.class);
    return enrichUser(user);
}

Real-World Example: E-Commerce Order Service

Let’s build a complete e-commerce order processing service that demonstrates virtual threads in action.

System Architecture

┌─────────────────────────────────────────────────────────────────┐
│                     ORDER SERVICE                                │
│                   (Virtual Threads)                              │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│   POST /api/orders                                               │
│         │                                                        │
│         ▼                                                        │
│   ┌─────────────┐    ┌─────────────┐    ┌─────────────┐        │
│   │   Validate  │───▶│   Check     │───▶│   Reserve   │        │
│   │   Customer  │    │   Inventory │    │   Stock     │        │
│   │   (DB: 20ms)│    │   (API:50ms)│    │   (DB:30ms) │        │
│   └─────────────┘    └─────────────┘    └─────────────┘        │
│                                                │                 │
│                                                ▼                 │
│   ┌─────────────┐    ┌─────────────┐    ┌─────────────┐        │
│   │   Send      │◀───│   Create    │◀───│   Process   │        │
│   │   Email     │    │   Order     │    │   Payment   │        │
│   │  (API:100ms)│    │   (DB:40ms) │    │  (API:200ms)│        │
│   └─────────────┘    └─────────────┘    └─────────────┘        │
│                                                                  │
│   Total I/O wait: ~440ms per request                            │
│   With 200 platform threads: max 454 orders/sec                 │
│   With virtual threads: 2000+ orders/sec                        │
└─────────────────────────────────────────────────────────────────┘

Complete Project Structure

src/main/java/com/example/orders/
├── OrderApplication.java
├── config/
│   ├── VirtualThreadConfig.java
│   └── RestClientConfig.java
├── controller/
│   └── OrderController.java
├── service/
│   ├── OrderService.java
│   ├── InventoryService.java
│   ├── PaymentService.java
│   └── NotificationService.java
├── repository/
│   ├── OrderRepository.java
│   └── CustomerRepository.java
├── model/
│   ├── Order.java
│   ├── OrderItem.java
│   ├── Customer.java
│   └── dto/
│       ├── CreateOrderRequest.java
│       ├── OrderResponse.java
│       └── PaymentResult.java
└── exception/
    ├── OrderException.java
    └── GlobalExceptionHandler.java

Domain Models

// Order.java
@Entity
@Table(name = "orders")
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private Long customerId;

    @Column(nullable = false)
    private String status;

    @Column(nullable = false)
    private BigDecimal totalAmount;

    private String paymentId;

    @Column(nullable = false)
    private LocalDateTime createdAt;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    private List<OrderItem> items = new ArrayList<>();

    @PrePersist
    public void prePersist() {
        this.createdAt = LocalDateTime.now();
        this.status = "PENDING";
    }

    // Getters and setters
}

// OrderItem.java
@Entity
@Table(name = "order_items")
public class OrderItem {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id")
    private Order order;

    @Column(nullable = false)
    private String productId;

    @Column(nullable = false)
    private String productName;

    @Column(nullable = false)
    private Integer quantity;

    @Column(nullable = false)
    private BigDecimal unitPrice;

    // Getters and setters
}

// Customer.java
@Entity
@Table(name = "customers")
public class Customer {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String email;

    @Column(nullable = false)
    private String name;

    private String phone;

    @Column(nullable = false)
    private boolean active = true;

    // Getters and setters
}

DTOs

// CreateOrderRequest.java
public record CreateOrderRequest(
    Long customerId,
    List<OrderItemRequest> items,
    PaymentInfo paymentInfo
) {}

public record OrderItemRequest(
    String productId,
    Integer quantity
) {}

public record PaymentInfo(
    String cardToken,
    String billingAddress
) {}

// OrderResponse.java
public record OrderResponse(
    Long orderId,
    String status,
    BigDecimal totalAmount,
    String paymentId,
    LocalDateTime createdAt,
    List<OrderItemResponse> items
) {
    public static OrderResponse from(Order order) {
        return new OrderResponse(
            order.getId(),
            order.getStatus(),
            order.getTotalAmount(),
            order.getPaymentId(),
            order.getCreatedAt(),
            order.getItems().stream()
                .map(OrderItemResponse::from)
                .toList()
        );
    }
}

public record OrderItemResponse(
    String productId,
    String productName,
    Integer quantity,
    BigDecimal unitPrice
) {
    public static OrderItemResponse from(OrderItem item) {
        return new OrderItemResponse(
            item.getProductId(),
            item.getProductName(),
            item.getQuantity(),
            item.getUnitPrice()
        );
    }
}

External Service Clients

// InventoryService.java - Calls external inventory API
@Service
public class InventoryService {

    private final RestClient restClient;

    public InventoryService(RestClient.Builder builder,
                            @Value("${inventory.service.url}") String baseUrl) {
        this.restClient = builder.baseUrl(baseUrl).build();
    }

    public InventoryCheckResult checkAvailability(List<OrderItemRequest> items) {
        // This blocks, but virtual thread yields to carrier
        return restClient.post()
            .uri("/api/inventory/check")
            .body(new InventoryCheckRequest(items))
            .retrieve()
            .body(InventoryCheckResult.class);
    }

    public void reserveStock(Long orderId, List<OrderItemRequest> items) {
        restClient.post()
            .uri("/api/inventory/reserve")
            .body(new ReserveStockRequest(orderId, items))
            .retrieve()
            .toBodilessEntity();
    }

    public void releaseStock(Long orderId) {
        restClient.post()
            .uri("/api/inventory/release/{orderId}", orderId)
            .retrieve()
            .toBodilessEntity();
    }
}

// PaymentService.java - Calls payment gateway
@Service
public class PaymentService {

    private final RestClient restClient;

    public PaymentService(RestClient.Builder builder,
                          @Value("${payment.gateway.url}") String baseUrl,
                          @Value("${payment.gateway.api-key}") String apiKey) {
        this.restClient = builder
            .baseUrl(baseUrl)
            .defaultHeader("X-API-Key", apiKey)
            .build();
    }

    public PaymentResult processPayment(PaymentRequest request) {
        try {
            return restClient.post()
                .uri("/api/payments/charge")
                .body(request)
                .retrieve()
                .body(PaymentResult.class);
        } catch (RestClientException e) {
            return new PaymentResult(false, null, e.getMessage());
        }
    }

    public void refundPayment(String paymentId) {
        restClient.post()
            .uri("/api/payments/refund/{paymentId}", paymentId)
            .retrieve()
            .toBodilessEntity();
    }
}

// NotificationService.java - Sends emails/SMS
@Service
public class NotificationService {

    private final RestClient restClient;

    public NotificationService(RestClient.Builder builder,
                               @Value("${notification.service.url}") String baseUrl) {
        this.restClient = builder.baseUrl(baseUrl).build();
    }

    @Async  // Runs on separate virtual thread
    public CompletableFuture<Void> sendOrderConfirmation(Order order, Customer customer) {
        EmailRequest email = new EmailRequest(
            customer.getEmail(),
            "Order Confirmation #" + order.getId(),
            buildOrderEmailBody(order)
        );

        restClient.post()
            .uri("/api/notifications/email")
            .body(email)
            .retrieve()
            .toBodilessEntity();

        return CompletableFuture.completedFuture(null);
    }

    private String buildOrderEmailBody(Order order) {
        return String.format("""
            Thank you for your order!

            Order ID: %d
            Total: $%.2f
            Status: %s

            We'll notify you when your order ships.
            """, order.getId(), order.getTotalAmount(), order.getStatus());
    }
}

Main Order Service (Orchestrates Everything)

@Service
@Transactional
public class OrderService {

    private static final Logger log = LoggerFactory.getLogger(OrderService.class);

    private final OrderRepository orderRepository;
    private final CustomerRepository customerRepository;
    private final InventoryService inventoryService;
    private final PaymentService paymentService;
    private final NotificationService notificationService;

    public OrderService(OrderRepository orderRepository,
                        CustomerRepository customerRepository,
                        InventoryService inventoryService,
                        PaymentService paymentService,
                        NotificationService notificationService) {
        this.orderRepository = orderRepository;
        this.customerRepository = customerRepository;
        this.inventoryService = inventoryService;
        this.paymentService = paymentService;
        this.notificationService = notificationService;
    }

    public OrderResponse createOrder(CreateOrderRequest request) {
        log.info("Processing order for customer {}", request.customerId());

        // Step 1: Validate customer (DB call - blocks, yields)
        Customer customer = customerRepository.findById(request.customerId())
            .filter(Customer::isActive)
            .orElseThrow(() -> new OrderException("Customer not found or inactive"));

        // Step 2: Check inventory (External API - blocks, yields)
        InventoryCheckResult inventoryResult = inventoryService.checkAvailability(request.items());
        if (!inventoryResult.allAvailable()) {
            throw new OrderException("Some items are out of stock: " + inventoryResult.unavailableItems());
        }

        // Step 3: Calculate total
        BigDecimal totalAmount = calculateTotal(inventoryResult.itemPrices(), request.items());

        // Step 4: Reserve stock (External API - blocks, yields)
        Order order = createPendingOrder(customer.getId(), request.items(), totalAmount, inventoryResult);
        order = orderRepository.save(order);

        try {
            inventoryService.reserveStock(order.getId(), request.items());

            // Step 5: Process payment (External API - blocks, yields)
            PaymentResult paymentResult = paymentService.processPayment(
                new PaymentRequest(
                    request.paymentInfo().cardToken(),
                    totalAmount,
                    order.getId().toString()
                )
            );

            if (!paymentResult.success()) {
                inventoryService.releaseStock(order.getId());
                throw new OrderException("Payment failed: " + paymentResult.errorMessage());
            }

            // Step 6: Update order status
            order.setPaymentId(paymentResult.transactionId());
            order.setStatus("CONFIRMED");
            order = orderRepository.save(order);

            // Step 7: Send confirmation (async - doesn't block response)
            notificationService.sendOrderConfirmation(order, customer);

            log.info("Order {} created successfully", order.getId());
            return OrderResponse.from(order);

        } catch (Exception e) {
            // Compensation logic
            order.setStatus("FAILED");
            orderRepository.save(order);
            throw e;
        }
    }

    private Order createPendingOrder(Long customerId, List<OrderItemRequest> items,
                                      BigDecimal total, InventoryCheckResult inventory) {
        Order order = new Order();
        order.setCustomerId(customerId);
        order.setTotalAmount(total);

        for (OrderItemRequest item : items) {
            OrderItem orderItem = new OrderItem();
            orderItem.setOrder(order);
            orderItem.setProductId(item.productId());
            orderItem.setQuantity(item.quantity());
            orderItem.setProductName(inventory.getProductName(item.productId()));
            orderItem.setUnitPrice(inventory.getPrice(item.productId()));
            order.getItems().add(orderItem);
        }

        return order;
    }

    private BigDecimal calculateTotal(Map<String, BigDecimal> prices, List<OrderItemRequest> items) {
        return items.stream()
            .map(item -> prices.get(item.productId()).multiply(BigDecimal.valueOf(item.quantity())))
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}

Controller with Validation

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

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping
    public ResponseEntity<OrderResponse> createOrder(@Valid @RequestBody CreateOrderRequest request) {
        OrderResponse response = orderService.createOrder(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }

    @GetMapping("/{id}")
    public ResponseEntity<OrderResponse> getOrder(@PathVariable Long id) {
        return orderService.findById(id)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }

    @GetMapping("/customer/{customerId}")
    public ResponseEntity<List<OrderResponse>> getCustomerOrders(@PathVariable Long customerId) {
        List<OrderResponse> orders = orderService.findByCustomerId(customerId);
        return ResponseEntity.ok(orders);
    }
}

Global Exception Handler

@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(OrderException.class)
    public ResponseEntity<ErrorResponse> handleOrderException(OrderException e) {
        log.warn("Order processing failed: {}", e.getMessage());
        return ResponseEntity.badRequest()
            .body(new ErrorResponse("ORDER_ERROR", e.getMessage()));
    }

    @ExceptionHandler(RestClientException.class)
    public ResponseEntity<ErrorResponse> handleExternalServiceError(RestClientException e) {
        log.error("External service error", e);
        return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
            .body(new ErrorResponse("SERVICE_UNAVAILABLE", "External service temporarily unavailable"));
    }

    public record ErrorResponse(String code, String message) {}
}

Example: Parallel Data Aggregation

A common pattern is fetching data from multiple sources and combining them:

Sequential vs Parallel Comparison

@Service
public class DashboardService {

    private final UserService userService;
    private final OrderService orderService;
    private final AnalyticsService analyticsService;
    private final RecommendationService recommendationService;

    // SEQUENTIAL: Each call waits for the previous one
    // Total time: 50ms + 100ms + 80ms + 120ms = 350ms
    public Dashboard getDashboardSequential(Long userId) {
        User user = userService.getUser(userId);           // 50ms
        List<Order> orders = orderService.getRecent(userId); // 100ms
        Analytics analytics = analyticsService.get(userId);  // 80ms
        List<Product> recommended = recommendationService.get(userId); // 120ms

        return new Dashboard(user, orders, analytics, recommended);
    }

    // PARALLEL with Virtual Threads: All calls run concurrently
    // Total time: max(50ms, 100ms, 80ms, 120ms) = 120ms
    public Dashboard getDashboardParallel(Long userId) {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {

            Future<User> userFuture = executor.submit(() ->
                userService.getUser(userId));

            Future<List<Order>> ordersFuture = executor.submit(() ->
                orderService.getRecent(userId));

            Future<Analytics> analyticsFuture = executor.submit(() ->
                analyticsService.get(userId));

            Future<List<Product>> recommendedFuture = executor.submit(() ->
                recommendationService.get(userId));

            return new Dashboard(
                userFuture.get(),
                ordersFuture.get(),
                analyticsFuture.get(),
                recommendedFuture.get()
            );
        } catch (ExecutionException | InterruptedException e) {
            throw new DashboardException("Failed to load dashboard", e);
        }
    }

    // PARALLEL with StructuredTaskScope (Java 21 Preview - cleanest approach)
    public Dashboard getDashboardStructured(Long userId) {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {

            Supplier<User> user = scope.fork(() ->
                userService.getUser(userId));

            Supplier<List<Order>> orders = scope.fork(() ->
                orderService.getRecent(userId));

            Supplier<Analytics> analytics = scope.fork(() ->
                analyticsService.get(userId));

            Supplier<List<Product>> recommended = scope.fork(() ->
                recommendationService.get(userId));

            scope.join();           // Wait for all tasks
            scope.throwIfFailed();  // Propagate any exceptions

            return new Dashboard(
                user.get(),
                orders.get(),
                analytics.get(),
                recommended.get()
            );
        } catch (InterruptedException e) {
            throw new DashboardException("Interrupted", e);
        }
    }
}

Performance Comparison

┌────────────────────────────────────────────────────────────────┐
│                    SEQUENTIAL EXECUTION                        │
├────────────────────────────────────────────────────────────────┤
│                                                                │
│ Time: 0ms    50ms    150ms    230ms    350ms                  │
│       │──────│───────│────────│────────│                      │
│       │ User │Orders │Analytics│ Recs  │                      │
│       │ 50ms │100ms  │  80ms  │ 120ms │                      │
│                                                                │
│ Total: 350ms                                                   │
└────────────────────────────────────────────────────────────────┘

┌────────────────────────────────────────────────────────────────┐
│                    PARALLEL EXECUTION                          │
│                   (Virtual Threads)                            │
├────────────────────────────────────────────────────────────────┤
│                                                                │
│ Time: 0ms              120ms                                   │
│       │────────────────│                                       │
│       │ User (50ms)    │                                       │
│       │ Orders (100ms) │                                       │
│       │ Analytics(80ms)│                                       │
│       │ Recs (120ms)   │ ◀── Longest task determines total    │
│                                                                │
│ Total: 120ms (65% faster!)                                     │
└────────────────────────────────────────────────────────────────┘

Example: Batch Processing with Virtual Threads

Process millions of records efficiently:

@Service
public class BatchProcessingService {

    private final DataRepository dataRepository;
    private final EnrichmentService enrichmentService;
    private final Semaphore rateLimiter = new Semaphore(100); // Limit concurrent API calls

    /**
     * Process records in parallel using virtual threads
     * Each record involves: DB read + API call + DB write
     */
    public BatchResult processBatch(List<Long> recordIds) {
        AtomicInteger successCount = new AtomicInteger(0);
        AtomicInteger failCount = new AtomicInteger(0);

        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<?>> futures = recordIds.stream()
                .map(id -> executor.submit(() -> {
                    try {
                        rateLimiter.acquire(); // Prevent API overload
                        try {
                            processRecord(id);
                            successCount.incrementAndGet();
                        } finally {
                            rateLimiter.release();
                        }
                    } catch (Exception e) {
                        failCount.incrementAndGet();
                        log.error("Failed to process record {}", id, e);
                    }
                }))
                .toList();

            // Wait for all to complete
            for (Future<?> future : futures) {
                try {
                    future.get();
                } catch (Exception e) {
                    // Already logged above
                }
            }
        }

        return new BatchResult(successCount.get(), failCount.get());
    }

    private void processRecord(Long id) {
        // Step 1: Load from DB (blocks, yields)
        Record record = dataRepository.findById(id)
            .orElseThrow(() -> new RecordNotFoundException(id));

        // Step 2: Enrich from external API (blocks, yields)
        EnrichmentData enrichment = enrichmentService.enrich(record);

        // Step 3: Update record (blocks, yields)
        record.setEnrichedData(enrichment);
        record.setProcessedAt(LocalDateTime.now());
        dataRepository.save(record);
    }

    /**
     * Stream large datasets efficiently
     */
    @Transactional(readOnly = true)
    public void processLargeDataset() {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor();
             Stream<Record> records = dataRepository.streamUnprocessed()) {

            records
                .map(record -> executor.submit(() -> processAndSave(record)))
                .forEach(future -> {
                    try {
                        future.get(30, TimeUnit.SECONDS);
                    } catch (TimeoutException e) {
                        future.cancel(true);
                    } catch (Exception e) {
                        log.error("Processing failed", e);
                    }
                });
        }
    }
}

Example: Kafka Consumer with Virtual Threads

@Configuration
@EnableKafka
public class KafkaConfig {

    @Bean
    public ConcurrentKafkaListenerContainerFactory<String, String> kafkaListenerContainerFactory(
            ConsumerFactory<String, String> consumerFactory) {

        ConcurrentKafkaListenerContainerFactory<String, String> factory =
            new ConcurrentKafkaListenerContainerFactory<>();

        factory.setConsumerFactory(consumerFactory);
        factory.setConcurrency(3); // 3 consumer threads

        // Use virtual threads for message processing
        factory.getContainerProperties().setListenerTaskExecutor(
            new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor())
        );

        return factory;
    }
}

@Service
public class OrderEventConsumer {

    private final OrderService orderService;
    private final NotificationService notificationService;

    @KafkaListener(topics = "order-events", groupId = "order-processor")
    public void handleOrderEvent(OrderEvent event) {
        // Each message processed on a virtual thread
        log.info("Processing order event: {}", event.orderId());

        switch (event.type()) {
            case CREATED -> handleOrderCreated(event);
            case SHIPPED -> handleOrderShipped(event);
            case DELIVERED -> handleOrderDelivered(event);
            case CANCELLED -> handleOrderCancelled(event);
        }
    }

    private void handleOrderShipped(OrderEvent event) {
        // Multiple blocking I/O operations - all benefit from virtual threads
        Order order = orderService.findById(event.orderId());
        Customer customer = customerService.findById(order.getCustomerId());

        // Update tracking info (DB call)
        orderService.updateTracking(order.getId(), event.trackingNumber());

        // Send notification (API call)
        notificationService.sendShippingNotification(customer, order, event.trackingNumber());

        // Update analytics (API call)
        analyticsService.recordShipment(order);
    }
}

Testing Virtual Threads

Unit Testing

@SpringBootTest
class OrderServiceTest {

    @Autowired
    private OrderService orderService;

    @MockBean
    private InventoryService inventoryService;

    @MockBean
    private PaymentService paymentService;

    @Test
    void shouldCreateOrderSuccessfully() {
        // Given
        when(inventoryService.checkAvailability(any()))
            .thenReturn(new InventoryCheckResult(true, Map.of("PROD-1", BigDecimal.TEN)));
        when(paymentService.processPayment(any()))
            .thenReturn(new PaymentResult(true, "txn-123", null));

        CreateOrderRequest request = new CreateOrderRequest(
            1L,
            List.of(new OrderItemRequest("PROD-1", 2)),
            new PaymentInfo("card-token", "123 Main St")
        );

        // When
        OrderResponse response = orderService.createOrder(request);

        // Then
        assertThat(response.status()).isEqualTo("CONFIRMED");
        assertThat(response.paymentId()).isEqualTo("txn-123");
    }

    @Test
    void shouldVerifyVirtualThreadUsage() {
        // Verify we're running on virtual threads
        assertTrue(Thread.currentThread().isVirtual(),
            "Test should run on virtual thread when spring.threads.virtual.enabled=true");
    }
}

Load Testing

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class VirtualThreadLoadTest {

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void shouldHandleHighConcurrency() throws InterruptedException {
        int concurrentRequests = 1000;
        CountDownLatch latch = new CountDownLatch(concurrentRequests);
        AtomicInteger successCount = new AtomicInteger(0);
        AtomicInteger errorCount = new AtomicInteger(0);

        long startTime = System.currentTimeMillis();

        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < concurrentRequests; i++) {
                final int requestNum = i;
                executor.submit(() -> {
                    try {
                        ResponseEntity<String> response = restTemplate.getForEntity(
                            "/api/users/" + (requestNum % 100),
                            String.class
                        );
                        if (response.getStatusCode().is2xxSuccessful()) {
                            successCount.incrementAndGet();
                        } else {
                            errorCount.incrementAndGet();
                        }
                    } catch (Exception e) {
                        errorCount.incrementAndGet();
                    } finally {
                        latch.countDown();
                    }
                });
            }
        }

        latch.await(60, TimeUnit.SECONDS);
        long duration = System.currentTimeMillis() - startTime;

        System.out.printf("""
            Load Test Results:
            - Total requests: %d
            - Successful: %d
            - Failed: %d
            - Duration: %dms
            - Throughput: %.2f req/sec
            """,
            concurrentRequests,
            successCount.get(),
            errorCount.get(),
            duration,
            (double) concurrentRequests / duration * 1000
        );

        assertThat(successCount.get()).isGreaterThan(concurrentRequests * 0.95);
    }
}

Debugging Virtual Threads

@Component
public class VirtualThreadDebugger {

    private static final Logger log = LoggerFactory.getLogger(VirtualThreadDebugger.class);

    /**
     * Log virtual thread info for debugging
     */
    public static void logThreadInfo(String operation) {
        Thread current = Thread.currentThread();
        log.debug("""
            Thread Info for '{}':
            - Name: {}
            - Virtual: {}
            - State: {}
            - ID: {}
            """,
            operation,
            current.getName(),
            current.isVirtual(),
            current.getState(),
            current.threadId()
        );
    }

    /**
     * Detect carrier thread pinning
     */
    @EventListener
    public void onPinningDetected(PinningEvent event) {
        log.warn("Virtual thread pinned! Stack trace: {}", event.stackTrace());
    }
}

// Enable pinning detection in application.properties:
// -Djdk.tracePinnedThreads=full

Docker & Kubernetes Deployment

Dockerfile

FROM eclipse-temurin:21-jre-alpine

# Virtual threads work best with these JVM options
ENV JAVA_OPTS="-XX:+UseZGC -XX:+ZGenerational \
    -Djdk.virtualThreadScheduler.parallelism=0 \
    -Djdk.virtualThreadScheduler.maxPoolSize=256"

WORKDIR /app
COPY target/*.jar app.jar

EXPOSE 8080

ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

Kubernetes Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
spec:
  replicas: 3
  selector:
    matchLabels:
      app: order-service
  template:
    metadata:
      labels:
        app: order-service
    spec:
      containers:
      - name: order-service
        image: order-service:latest
        ports:
        - containerPort: 8080
        env:
        - name: JAVA_OPTS
          value: "-XX:+UseZGC -Xmx512m"
        - name: SPRING_THREADS_VIRTUAL_ENABLED
          value: "true"
        resources:
          requests:
            # Virtual threads need less memory!
            memory: "256Mi"
            cpu: "250m"
          limits:
            memory: "512Mi"
            cpu: "1000m"
        readinessProbe:
          httpGet:
            path: /actuator/health/readiness
            port: 8080
          initialDelaySeconds: 10
        livenessProbe:
          httpGet:
            path: /actuator/health/liveness
            port: 8080
          initialDelaySeconds: 30
---
apiVersion: v1
kind: Service
metadata:
  name: order-service
spec:
  selector:
    app: order-service
  ports:
  - port: 80
    targetPort: 8080
  type: ClusterIP

HorizontalPodAutoscaler

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: order-service-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: order-service
  minReplicas: 2
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70
  # With virtual threads, you can handle more load per pod
  # so you may need fewer replicas

Migration Guide

Step 1: Update Dependencies

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.3.0</version>
</parent>

<properties>
    <java.version>21</java.version>
</properties>

Step 2: Enable Virtual Threads

spring.threads.virtual.enabled=true

Step 3: Audit Synchronized Blocks

# Find synchronized usage in your codebase
grep -r "synchronized" src/main/java/

Replace with ReentrantLock where appropriate.

Step 4: Review ThreadLocal Usage

grep -r "ThreadLocal" src/main/java/

Consider alternatives for high-cardinality scenarios.

Step 5: Adjust Connection Pools

Increase database and HTTP connection pools to handle more concurrent requests.

Step 6: Load Test

# Using Apache Bench
ab -n 10000 -c 1000 http://localhost:8080/api/users/1

# Using hey
hey -n 10000 -c 1000 http://localhost:8080/api/users/1

Summary

TopicKey Takeaway
Enablespring.threads.virtual.enabled=true
Best forI/O-bound workloads (APIs, databases, HTTP clients)
AvoidCPU-bound work, synchronized blocks with I/O
Performance5x+ throughput for I/O-heavy applications
Memory60%+ reduction in thread memory
MigrationMostly transparent, audit synchronized/ThreadLocal

Virtual threads are a game-changer for Spring Boot applications. They let you write simple, blocking code while achieving the scalability of reactive programming - without the complexity.

Start with spring.threads.virtual.enabled=true and measure the difference in your application.

References

Advertisement

MR

Moshiour Rahman

Software Architect & AI Engineer

Share:
MR

Moshiour Rahman

Software Architect & AI Engineer

Enterprise software architect with deep expertise in financial systems, distributed architecture, and AI-powered applications. Building large-scale systems at Fortune 500 companies. Specializing in LLM orchestration, multi-agent systems, and cloud-native solutions. I share battle-tested patterns from real enterprise projects.

Related Articles

Comments

Comments are powered by GitHub Discussions.

Configure Giscus at giscus.app to enable comments.