Skip to main content
Articles/Java/adapting-to-virtual-threads-spring-developers
Series: Java Backend Modernizationβ€’Part 9 of 17
Scenario Article

Adapting to Virtual Threads for Spring Developers

Why Platform Threads Break Under Concurrent I/O Load

Abstract Algorithmsβ€’Apr 5, 2026
XLinkedInReddit
πŸ’‘ Executive Summary

Platform threads (one OS thread per request) max out at a few hundred concurrent I/O-bound requests.

🎯 System Scenario & Requirements

System Goal

Platform threads (one OS thread per request) max out at a few hundred concurrent I/O-bound requests.

Actors & Components

  • Application or API tier
  • Core service under design
  • Operator or SRE observer

Functional Requirements

  • TLDR: Platform threads (one OS thread per request) max out at a few hundred concurrent I/O-bound requests.
  • Virtual threads (JDK 21+) allow millions β€” with zero I/O-blocking cost.
  • Spring Boot 3.2 enables them with a single property.

Constraints & Assumptions

  • Virtual threads are a solution for I/O-bound concurrency.
  • For CPU-bound work β€” image processing, PDF generation, JSON serialization of large objects, cryptographic operations β€” the virtual thread holds its carrier thread active for the full duration.

⚑ Execution Stages & Decision Trace

1

Stage 1 β€” When 2,000 Users Break a Service That Handled 200 Fine

Action / Decision:

Your Spring Boot service is returning HTTP 503s.

Component Behavior:

You open Grafana: CPU at 22%, heap at 40%, memory healthy.

Observable Output: Then you check the thread monitor: the Tomcat thread pool is at 200/200 active threads.
Read Full Stage in Deep Dive ↓
πŸ“–

Deep Dive

Detailed Architectural Analysis

Scenario context

Platform threads (one OS thread per request) max out at a few hundred concurrent I/O-bound requests. Virtual threads (JDK 21+) allow millions β€” with zero I/O-blocking cost. Spring Boot 3.2 enables them with a single property. Avoid synchronized blocks wrapping I/O (they pin virtual threads to OS threads) and CPU-bound work inside virtual threads. For I/O-heavy Spring services, virtual threads are the most impactfu…

This walkthrough focuses on Why Platform Threads Break Under Concurrent I/O Load.

Before making a design choice, clarify:

  • Every thread managed by Tomcat, @Async , or a raw ExecutorService in classic Spring MVC maps 1:1 to an OS thread.
  • OS threads are expensive along three dimensions: - Stack memory: ~1 MB of stack space reserved per thread, regardless of whether the thread is doing work or waiting on I/O - Context-switch overhead: When a thread blocks…
  • Increase it to 1,000 and your JVM reserves ~1 GB in thread stacks alone.

Concepts in play: jvm-internals-and-garbage-collection, tree-data-structures, stack-and-queue-data-structures

Stage 1 β€” When 2,000 Users Break a Service That Handled 200 Fine

  • Your Spring Boot service is returning HTTP 503s.
  • You open Grafana: CPU at 22%, heap at 40%, memory healthy.
  • Then you check the thread monitor: the Tomcat thread pool is at 200/200 active threads.
  • Thousands of requests are queuing and timing out.

Observable outcome: Thousands of requests are queuing and timing out.

Stage 2 β€” Virtual Threads: JVM-Scheduled, Not OS-Scheduled

  • A virtual thread is a thread managed by the JVM rather than the OS.
  • Internally, the JVM runs a small fixed pool of carrier threads β€” one OS thread per CPU core.
  • Virtual threads are mounted onto a carrier thread when they have CPU work to perform.
  • The moment a virtual thread hits a blocking I/O call, the JVM unmounts it from the carrier, suspends its stack in heap memory, and frees the carrier for another virtual thread that has work ready.

Observable outcome: The moment a virtual thread hits a blocking I/O call, the JVM unmounts it from the carrier, suspends its stack in heap memory, and frees the carrier for another virtual thread that has work ready.

Stage 3 β€” How Virtual Thread Scheduling Differs from Platform Threads

  • The diagram below shows why the platform thread model exhausts at 200 concurrent requests while the virtual thread model scales to millions.
  • Read each subgraph left-to-right: in the platform model, every request arrow terminates at a blocked OS thread; in the virtual model, all virtual threads are suspended in heap while two carrier threads handle all active…
  • In the platform thread model, each of the 200 threads is blocked and idle β€” consuming memory and OS slots while waiting on I/O.
  • Requests 201–300 cannot be served.
flowchart TD
    subgraph PlatformModel[Platform Thread Model - 300 concurrent requests]
        R1[Request 1] --> T1[OS Thread 1 - blocked on JDBC]
        R2[Request 2] --> T2[OS Thread 2 - blocked on HTTP call]
        R3[Request 3] --> T3[OS Thread 3 - blocked on file I/O]
        R200[Request 200] --> T200[OS Thread 200 - blocked on JDBC]
        R201[Request 201] --> Q1[Queued - thread pool exhausted]
        R300[Request 300] --> Q2[Queued - timeout approaching]
    end
    subgraph VirtualModel[Virtual Thread Model - 300 concurrent requests]
        VR1[Request 1] --> VT1[Virtual Thread 1 - suspended in heap]
        VR2[Request 2] --> VT2[Virtual Thread 2 - suspended in heap]
        VR300[Request 300] --> VT300[Virtual Thread 300 - suspended in heap]
        CT1[Carrier Thread 1 - 1 per CPU core] -.->|mounts VT with active work| VT1
        CT2[Carrier Thread 2 - 1 per CPU core] -.->|mounts VT with active work| VT2
    end

Observable outcome: Requests 201–300 cannot be served.

Stage 4 β€” Worked Example: Thread Pool Exhaustion Before and After Virtual Threads

  • To see the benefit concretely, contrast the same OrderService in both models.
  • The code is identical β€” only the threading model beneath it changes.
  • With 200 threads and a 60ms average request latency, theoretical throughput is 200 / 0.060 β‰ˆ 3,300 req/s .
  • In practice, the moment you have 201 concurrent long-running requests, the 201st queues.
// application.properties β€” classic Tomcat platform thread configuration
server.tomcat.threads.max=200          // hard ceiling: 200 concurrent blocking requests
server.tomcat.threads.min-spare=10
server.tomcat.accept-count=100         // queue depth before rejecting new connections

// Before: OrderService β€” each method call holds a platform thread for its entire duration
@Service
public class OrderService {

    private final RestTemplate restTemplate;
    private final JdbcTemplate jdbcTemplate;

    public OrderService(RestTemplate restTemplate, JdbcTemplate jdbcTemplate) {
        this.restTemplate = restTemplate;
        this.jdbcTemplate = jdbcTemplate;
    }

    // PROBLEM: This method occupies platform thread #47 for the full ~60ms.
    // During the 10ms JDBC wait and the 50ms HTTP wait, thread #47 sits idle
    // in SocketInputStream.read() β€” holding 1MB of stack and an OS slot.
    // At 200 concurrent calls, the pool is exhausted regardless of CPU load.
    public Order processOrder(String orderId) {

        // Blocks platform thread ~10ms waiting for DB network round-trip
        Order order = jdbcTemplate.queryForObject(
            "SELECT * FROM orders WHERE id = ?",
            new Object[]{orderId},
            orderRowMapper
        );

        // Blocks platform thread ~50ms waiting for external HTTP response
        String enrichment = restTemplate.getForObject(
            "https://inventory-service/api/items/" + order.getItemId(),
            String.class
        );

        order.setEnrichment(enrichment);
        return order;
    }
}

// Before: Controller β€” Tomcat dispatches a platform thread per HTTP request.
// That thread is held until processOrder() returns (~60ms total).
@RestController
@RequestMapping("/orders")
public class OrderController {

    private final OrderService orderService;

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

    @GetMapping("/{orderId}")
    public ResponseEntity<Order> getOrder(@PathVariable String orderId) {
        return ResponseEntity.ok(orderService.processOrder(orderId));
    }
}

Observable outcome: In practice, the moment you have 201 concurrent long-running requests, the 201st queues.

Stage 5 β€” Enabling Virtual Threads in Spring Boot 3.2

  • Spring Boot 3.2 wires virtual threads automatically when this property is set and JDK 21+ is detected: Under the hood, this replaces Tomcat's fixed thread pool executor with one that creates a new virtual thread per inc…
  • There is no thread pool ceiling.
  • The OrderService code is unchanged β€” the same blocking JDBC call, the same RestTemplate call β€” they now run on virtual threads.
  • The JVM unmounts the virtual thread during each I/O wait, freeing carrier threads for other requests.
# application.yml β€” enable virtual threads (Spring Boot 3.2+, JDK 21+ required)
spring:
  threads:
    virtual:
      enabled: true
// Explicit programmatic virtual thread configuration
@Configuration
public class VirtualThreadConfig {

    // Replaces Tomcat's fixed thread pool with a virtual-thread-per-task executor.
    // Tomcat now creates one virtual thread per incoming HTTP request.
    @Bean
    public TomcatProtocolHandlerCustomizer<?> virtualThreadTomcatCustomizer() {
        return protocolHandler ->
            protocolHandler.setExecutor(
                Executors.newVirtualThreadPerTaskExecutor()  // JDK 21 API
            );
    }

    // Replace the @Async executor so background tasks also run on virtual threads.
    // SimpleAsyncTaskExecutor with virtualThreads=true is equivalent but
    // the explicit executor gives you more visibility in monitoring tools.
    @Bean
    @Primary
    public Executor virtualThreadAsyncExecutor() {
        return Executors.newVirtualThreadPerTaskExecutor();
    }
}

Observable outcome: The JVM unmounts the virtual thread during each I/O wait, freeing carrier threads for other requests.

Stage 6 β€” Under the Hood: How the JVM Parks and Resumes Virtual Threads

  • Understanding the internals helps you reason about when virtual threads help, when they hurt, and why the pinning problem exists.
  • Virtual Thread Internals: Mounting, Unmounting, and Carrier Threads When jdbcTemplate.queryForObject(...) sends a SQL query over a socket, the JVM intercepts the blocking I/O call and executes this lifecycle: 1.
  • The virtual thread calls SocketInputStream.read() 2.
  • The JVM detects this is a blocking I/O syscall via its internal socket implementation 3.
// DANGEROUS: synchronized + I/O inside the lock = virtual thread pins its carrier
@Service
public class CachedOrderService {

    private final Map<String, Order> cache = new HashMap<>();
    private final JdbcTemplate jdbcTemplate;

    public CachedOrderService(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    // BAD: synchronized holds the carrier thread for the full JDBC call duration.
    // With 8 CPU cores (8 carrier threads), only 8 concurrent cached lookups
    // can proceed β€” far worse than the 200-thread platform model.
    public synchronized Order getOrLoad(String orderId) {
        if (cache.containsKey(orderId)) {
            return cache.get(orderId);
        }
        // This JDBC call (10ms+) runs while synchronized holds the carrier thread pinned.
        Order order = jdbcTemplate.queryForObject(
            "SELECT * FROM orders WHERE id = ?",
            new Object[]{orderId}, orderRowMapper
        );
        cache.put(orderId, order);
        return order;
    }
}

// CORRECT: ReentrantLock allows the virtual thread to unmount during lock wait.
// When another virtual thread holds the lock, this one parks in heap β€” not the carrier.
@Service
public class CachedOrderServiceFixed {

    private final Map<String, Order> cache = new HashMap<>();
    private final ReentrantLock lock = new ReentrantLock();
    private final JdbcTemplate jdbcTemplate;

    public CachedOrderServiceFixed(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    // GOOD: lockInterruptibly() allows the JVM to park the virtual thread if the lock
    // is held β€” it unmounts from the carrier and frees it for other virtual threads.
    public Order getOrLoad(String orderId) throws InterruptedException {
        lock.lockInterruptibly();
        try {
            if (cache.containsKey(orderId)) {
                return cache.get(orderId);
            }
            Order order = jdbcTemplate.queryForObject(
                "SELECT * FROM orders WHERE id = ?",
                new Object[]{orderId}, orderRowMapper
            );
            cache.put(orderId, order);
            return order;
        } finally {
            lock.unlock();
        }
    }
}
java -Djdk.tracePinnedThreads=full -jar your-app.jar

Observable outcome: The JVM detects this is a blocking I/O syscall via its internal socket implementation 3.

End-to-end outcome

After working through the stages, you should be able to explain:

    • One property, measurable impact.
  • Enabling virtual threads in a Spring Boot 3.2 service handling 5,000 concurrent users dropped p99 latency by 40% with zero code change in the business logic.

Alternatives considered

Spring Migration Decision Guide

Spring Migration Decision Guide

Failure and degraded modes

Trade-offs: I/O-Bound Gains vs CPU-Bound Limits

  • Detection: Virtual threads are a solution for I/O-bound concurrency.
  • User or operator impact: Virtual threads are a solution for I/O-bound concurrency.

Share this case study

Discuss architecture trade-offs and lessons with your team.

Share:XLinkedInReddit