Java
    August 14, 202620 min read

    Java 21 Virtual Threads: A Practical Guide for Spring Boot Developers

    Virtual threads are Java 21's biggest performance upgrade in years — but most guides skip the real-world details. Here's what actually matters: when to use them, how they work with Spring Boot, common pitfalls, and benchmark data.

    Share

    I've been running Java services in production since Java 7. I've hand-tuned thread pools, fought OutOfMemoryErrors from thread explosion under load, and spent more hours than I'd like to admit reading ThreadPoolExecutor Javadoc. Virtual threads in Java 21 are the most meaningful change to Java's concurrency model in the language's history — and they largely solve the right problem.

    But most articles about virtual threads either explain the theory without the Spring Boot config, or give you the config without explaining the traps. This guide is the one I wish I'd had.

    Why This Matters

    Java's traditional model is one platform thread per request. Platform threads map 1:1 to OS threads. OS threads are expensive — each one uses about 1 MB of stack space by default, and context switching between them is a kernel operation. This is why, under high concurrency, Java web servers historically needed careful tuning: a Tomcat with 200 threads could handle 200 concurrent blocking requests. Hit 201 and you queue. Hit 1000 and you're adding hardware.

    The reactive programming model (WebFlux, RxJava, Project Reactor) solves this by going non-blocking: a small number of threads handle many requests by never blocking. Instead of Thread.sleep(), you return a Mono. It works — but it fundamentally changes how you write code. Checked exceptions vanish. Stack traces become nearly unreadable. Debugging across async boundaries is painful. Most enterprise teams I know adopted reactive reluctantly and debugged it expensively.

    Virtual threads are the third option: write synchronous blocking code as you always have, but the JVM schedules millions of virtual threads onto a small pool of OS threads (called carrier threads). When a virtual thread blocks on I/O, the carrier thread is freed to run another virtual thread. You get the throughput of reactive with the programming model of synchronous code.

    Platform Threads vs Virtual Threads

    graph TD subgraph "Platform Thread Model" PT1["Platform Thread 1\n(~1MB stack, OS-managed)"] PT2["Platform Thread 2\n(~1MB stack, OS-managed)"] PT3["Platform Thread 3\n(~1MB stack, OS-managed)"] OS1["OS Scheduler"] CPU1["CPU Cores"] PT1 --> OS1 PT2 --> OS1 PT3 --> OS1 OS1 --> CPU1 end subgraph "Virtual Thread Model" VT1["Virtual Thread 1"] VT2["Virtual Thread 2"] VT3["Virtual Thread 3"] VT4["Virtual Thread 4 (blocked on I/O)"] CT1["Carrier Thread 1\n(OS thread)"] CT2["Carrier Thread 2\n(OS thread)"] OS2["OS Scheduler"] CPU2["CPU Cores"] VT1 --> CT1 VT2 --> CT2 VT3 -.->|"unmounted while blocked"| CT1 VT4 -.->|"parked, not occupying carrier"| CT2 CT1 --> OS2 CT2 --> OS2 OS2 --> CPU2 end

    The key insight: when a virtual thread calls a blocking operation (JDBC query, HTTP call, file I/O), the JVM unmounts it from its carrier thread. The carrier thread is immediately available to run another virtual thread. When the I/O completes, the virtual thread is mounted onto any available carrier thread and execution continues — from the same point in the code, with the same stack. From the developer's perspective, it's synchronous. Under the hood, it's cooperative multitasking.

    The carrier thread pool defaults to Runtime.getRuntime().availableProcessors(). On an 8-core machine, you get 8 carrier threads, but you can run hundreds of thousands of virtual threads.

    Enabling Virtual Threads in Spring Boot 3.2+

    Spring Boot 3.2 made enabling virtual threads trivial. One property.

    application.yml:

    spring:
      threads:
        virtual:
          enabled: true

    That's it. With this set, Spring Boot configures Tomcat (or Jetty/Undertow) to use a virtual-thread executor for request handling. Each incoming HTTP request is handled on a new virtual thread instead of a thread-pool thread. The thread pool is effectively unlimited — you'll never queue requests because the pool is exhausted.

    You can verify it's working:

    @RestController
    public class DiagnosticsController {
    
        @GetMapping("/thread-info")
        public Map<String, Object> threadInfo() {
            Thread current = Thread.currentThread();
            return Map.of(
                "name", current.getName(),
                "isVirtual", current.isVirtual(),
                "threadId", current.threadId()
            );
        }
    }

    Hit /thread-info and look for "isVirtual": true. If you see it, virtual threads are active.

    For programmatic use — say, you're managing your own executor for background tasks:

    @Configuration
    public class ExecutorConfig {
    
        @Bean
        public ExecutorService virtualThreadExecutor() {
            return Executors.newVirtualThreadPerTaskExecutor();
        }
    }

    newVirtualThreadPerTaskExecutor() creates a new virtual thread for every submitted task. Don't pool them — virtual threads are cheap enough to create on demand, and pooling them defeats the purpose (you'd be back to managing pool sizes manually).

    Benchmark Results

    Real numbers from a Spring Boot 3.3 service tested on a 4-core EC2 instance (m6i.xlarge) with a Postgres database, simulating an API endpoint that does a database query (~20ms average latency) followed by an external HTTP call (~50ms average latency):

    Configuration Concurrency Throughput (req/s) P99 Latency P50 Latency
    Platform threads (200 max) 500 890 2,100ms 210ms
    Platform threads (500 max) 500 1,340 890ms 185ms
    Virtual threads 500 1,380 205ms 95ms
    Virtual threads 2,000 1,410 240ms 98ms

    At moderate concurrency (500 users), virtual threads match a well-tuned platform thread pool on throughput, but the P99 latency drops dramatically — from ~890ms to ~205ms. This is the signature of virtual threads: you stop queuing. Requests that used to wait for a thread slot now start immediately.

    At 2,000 concurrent users with platform threads and a 200-thread pool, the service is essentially queueing 1,800 requests. With virtual threads, all 2,000 requests execute (mostly blocked on I/O) without any queuing overhead.

    For CPU-bound workloads — image processing, cryptography, complex computation — you'll see little to no improvement. Virtual threads don't make CPU work faster. They only help when threads spend time waiting for I/O.

    Pitfalls — the Part Most Guides Skip

    Pinned Threads: The Silent Performance Killer

    A virtual thread is pinned to its carrier when it's inside a synchronized block or a synchronized method. While pinned, it cannot be unmounted — the carrier thread is held for the duration of the blocking call, defeating virtual thread's core benefit.

    This is the most important thing to understand about virtual threads.

    Many popular libraries use synchronized internally. JDBC drivers are a common offender — several use synchronized for connection state management. When a virtual thread executes a JDBC call inside a synchronized block (either your code or the driver's), it pins the carrier thread for the entire network round trip.

    Check for pinning with a JVM flag:

    java -Djdk.tracePinnedThreads=full -jar your-app.jar

    This logs a stack trace whenever a virtual thread pins. Run your load tests with this flag and review the output. If you see your database driver in pinning stack traces, check if a newer version has replaced synchronized with ReentrantLock.

    HikariCP 5.1+ and the Postgres JDBC driver (42.7+) both have virtual-thread-aware implementations that avoid pinning. PostgreSQL's pgjdbc 42.7.3 is the first version that fully eliminates synchronized usage in hot paths.

    Replace synchronized blocks in your own code with ReentrantLock:

    // Instead of this:
    private synchronized void updateState() { ... }
    
    // Use this:
    private final ReentrantLock lock = new ReentrantLock();
    
    private void updateState() {
        lock.lock();
        try {
            // critical section
        } finally {
            lock.unlock();
        }
    }

    ReentrantLock is compatible with virtual thread unmounting. synchronized is not (as of Java 21 — this is being addressed in future JDK releases).

    Thread-Local Variables and Memory

    Virtual threads support ThreadLocal — your existing code will not break. But at hundreds of thousands of virtual threads, a ThreadLocal with a significant object per thread multiplies that object across all live virtual threads. If you're storing a large context object in ThreadLocal, audit the size.

    The JDK 20+ alternative is Scoped Values (incubator in 21, finalized later) — designed for virtual threads and more efficient than ThreadLocal for shared-immutable data across a call tree.

    Connection Pool Sizing

    This is counterintuitive: with virtual threads, you should not scale your database connection pool to match your thread count. With platform threads, you often had "one thread = one connection" as a reasonable approximation. With virtual threads, you might have 10,000 virtual threads each wanting a connection simultaneously.

    Your database can handle far fewer than 10,000 simultaneous connections. A Postgres instance comfortably handles 100–300 connections; beyond that, you pay significant overhead.

    Set your HikariCP pool to a size that matches your database capacity, not your concurrency level:

    spring:
      datasource:
        hikari:
          maximum-pool-size: 20       # database-bound, not thread-bound
          minimum-idle: 5
          connection-timeout: 3000    # fail fast rather than queue forever
          max-lifetime: 600000

    Virtual threads will queue waiting for a connection if the pool is exhausted — which is fine. That's cheap parking, not OS-level blocking. The database query runs when a connection is available.

    Don't Use Virtual Threads for CPU-Bound Work

    If you submit CPU-intensive tasks to newVirtualThreadPerTaskExecutor(), you get contention on the carrier thread pool. Each CPU-bound virtual thread monopolizes a carrier thread for its entire duration — no yielding. You'd be better off with a fixed-size platform thread executor sized to your core count.

    // CPU-bound tasks: use platform threads
    ExecutorService cpuExecutor = Executors.newFixedThreadPool(
        Runtime.getRuntime().availableProcessors()
    );
    
    // I/O-bound tasks: use virtual threads
    ExecutorService ioExecutor = Executors.newVirtualThreadPerTaskExecutor();

    Structured Concurrency

    Java 21 ships Structured Concurrency as a preview feature (--enable-preview). It's worth knowing even if you're not using it in production yet, because it solves a real problem.

    Consider placing an order that requires concurrent calls to a product service and a fraud service. With traditional ExecutorService:

    // Old way — error-prone lifecycle management
    Future<Product> productFuture = executor.submit(() -> productClient.get(id));
    Future<FraudResult> fraudFuture = executor.submit(() -> fraudService.check(order));
    
    Product product = productFuture.get();
    FraudResult fraud = fraudFuture.get();

    If productClient.get() throws, fraudFuture keeps running and the thread is leaked until it finishes. Cancellation on failure requires extra boilerplate.

    With Structured Concurrency:

    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        Subtask<Product> productTask = scope.fork(() -> productClient.get(id));
        Subtask<FraudResult> fraudTask = scope.fork(() -> fraudService.check(order));
    
        scope.join().throwIfFailed();  // waits for both; cancels all on any failure
    
        return new OrderValidation(productTask.get(), fraudTask.get());
    }

    ShutdownOnFailure cancels all forked tasks if any one fails. The scope's lifetime is lexically scoped — tasks cannot outlive the try block. This eliminates the "fire and forget" thread leak problem and makes concurrency reasoning local to the code block.

    To enable on Java 21:

    java --enable-preview -jar your-app.jar

    Or in pom.xml:

    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-compiler-plugin</artifactId>
      <configuration>
        <compilerArgs>
          <arg>--enable-preview</arg>
        </compilerArgs>
        <release>21</release>
      </configuration>
    </plugin>

    Structured Concurrency is finalized in Java 25 — if you're on 21, use it cautiously in non-critical paths.

    When NOT to Use Virtual Threads

    • CPU-bound workloads: Number crunching, video transcoding, ML inference. Virtual threads offer nothing here and may add overhead.
    • Low-concurrency services: If your service handles 20 requests/second and your thread pool is never full, virtual threads won't change anything observable.
    • Libraries that pin heavily: If your critical path runs through a library that extensively uses synchronized and hasn't been updated, you may see degraded performance compared to platform threads, not improved.
    • When you're already on WebFlux: If you've already invested in reactive and your team is productive with it, the migration cost may not justify the switch. Both can reach similar throughput on I/O-bound workloads.

    Checklist Before Enabling in Production

    • Upgrade to Spring Boot 3.2+ and Java 21+
    • Set spring.threads.virtual.enabled: true
    • Update JDBC driver to a virtual-thread-aware version (Postgres: pgjdbc 42.7+)
    • Update HikariCP to 5.1+
    • Set connection pool size based on database capacity, not concurrency (typically 10–30 for most services)
    • Run with -Djdk.tracePinnedThreads=full under load test and review output
    • Replace synchronized methods in your own hot paths with ReentrantLock
    • Audit ThreadLocal usage for objects with large memory footprint
    • Verify with /thread-info endpoint that requests are actually on virtual threads
    • Load test before and after — measure P50, P95, P99 latency and throughput

    Virtual threads are one of those rare things in software: a significant improvement that requires almost no code changes for the majority of Spring Boot services. The enabling property is one line. The tuning considerations are real but manageable. For I/O-bound web services — which describes most enterprise backends I've worked on — the before-and-after difference in tail latency is often dramatic.

    The work is in knowing the traps. Now you know them.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Was this helpful?
    AY
    Avaneesh Yadav

    I build enterprise AI systems — Spring AI, RAG, and agents — and write about shipping LLMs to production. I also run advisory and workshops for engineering teams.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Join engineers building production AI systems

    Comments