Course topics

By WebNest Studio

Spring Boot Tutorial

Virtual Threads in Spring Boot

For decades, Java servers used one operating-system thread per request. OS threads are expensive — each reserves about a megabyte of stack — so a server could run only a few hundred at once, and threads spent most of their time waiting for databases and HTTP calls. Reactive programming solved the scaling problem, at the cost of complex code.

Virtual threads (Java 21, Project Loom) are lightweight threads managed by the JVM: you can run millions of them, and when one blocks on I/O the JVM parks it and reuses the carrier thread. With one property Spring Boot runs Tomcat, @Async, scheduling and message listeners on virtual threads, so ordinary blocking code scales like reactive code. This lesson explains how they work, how to enable them, how to measure the benefit, and the pitfalls to avoid.

How Virtual Threads Work

A virtual thread runs on top of a small pool of carrier (platform) threads. When it performs blocking I/O — a JDBC query, an HTTP call, Thread.sleep — the JVM unmounts it from the carrier and mounts another virtual thread, then resumes the first when its data arrives. The code looks exactly like normal blocking code, stack traces and debuggers work as usual, and no callback style is needed.

Enabling in Spring Boot

On Java 21 or newer, set spring.threads.virtual.enabled=true. Spring Boot then uses virtual threads for Tomcat and Jetty request handling, the applicationTaskExecutor (@Async, MVC async), the task scheduler, Kafka and RabbitMQ listener containers, and JDK-based HTTP clients. If the application has no other non-daemon threads, set spring.main.keep-alive=true so the JVM does not exit.

Where They Help — and Where They Do Not

Virtual threads help I/O-bound workloads with many concurrent requests that spend time waiting: typical REST services calling databases and other APIs. They do not make CPU-bound work faster (image processing, heavy computation) — you still have the same number of cores. And they move the bottleneck: 10,000 concurrent requests can now all reach your database, whose connection pool of 20 becomes the limit. Keep pools sized sensibly and use @ConcurrencyLimit or semaphores to protect downstream systems.

Pitfalls

Do not pool virtual threads — create them freely. Avoid ThreadLocal caches of expensive objects (each of a million threads would get its own). Before Java 24, blocking inside synchronized blocks "pinned" the virtual thread to its carrier and reduced scalability; Java 24 (JEP 491) largely removed this problem, which is one reason to run on a current JDK such as Java 25. Monitor pinning with jdk.VirtualThreadPinned JFR events if you use older JDKs.

Examples

Enabling virtual threads

Java
# application.yml  (Java 21+)
spring:
  threads:
    virtual:
      enabled: true

@RestController
public class ThreadInfoController {

    @GetMapping("/api/thread")
    public String thread() throws InterruptedException {
        Thread.sleep(1000);                       // simulated blocking I/O
        return Thread.currentThread().toString();
    }
}
Output
curl localhost:8080/api/thread
VirtualThread[#94,tomcat-handler-3]/runnable@ForkJoinPool-1-worker-2

Measuring the difference under load

Java
# 2,000 concurrent requests to an endpoint that blocks 1 second (e.g. slow downstream API)
# using the "hey" load generator
hey -n 10000 -c 2000 http://localhost:8080/api/thread

# Run twice: spring.threads.virtual.enabled=false, then =true
Output
Platform threads (Tomcat max 200):  Requests/sec ≈ 195    p99 ≈ 10.4 s
Virtual threads:                     Requests/sec ≈ 1,950  p99 ≈ 1.1 s
(Same code; only the property changed.)

Using virtual threads directly for parallel I/O

Java
public ProductPage load(long id) throws Exception {
    try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
        Future<Product> product = executor.submit(() -> catalog.get(id));
        Future<List<Review>> reviews = executor.submit(() -> reviewsClient.forProduct(id));
        Future<Stock> stock = executor.submit(() -> inventory.stock(id));
        return new ProductPage(product.get(), reviews.get(), stock.get());
    }   // close() waits for all tasks
}
Output
catalog 180 ms, reviews 240 ms, stock 150 ms -> page built in ≈ 245 ms, plain blocking code, no reactive types

Protecting the database when concurrency jumps

Java
# The pool, not the thread count, is now the limit — size it for the database
spring:
  datasource:
    hikari:
      maximum-pool-size: 30
      connection-timeout: 3s        # fail fast instead of queuing forever

@Service
public class SearchService {

    @ConcurrencyLimit(30)            // at most 30 concurrent expensive searches
    public List<Course> fullTextSearch(String q) {
        return repository.search(q);
    }
}
Output
Under 5,000 concurrent requests: database sees ≤ 30 connections; excess requests wait briefly or time out cleanly

Common Mistakes

  • Expecting virtual threads to speed up CPU-bound work.
  • Enabling virtual threads and then overwhelming the database or downstream services with unlimited concurrency.
  • Pooling virtual threads in a fixed-size executor, which defeats their purpose.
  • Running on Java 21 with heavy synchronized blocking and not checking for pinning (upgrade to Java 24+ where possible).
  • Switching a simple MVC app to WebFlux purely for scalability when virtual threads would achieve it with less complexity.

Key Points to Remember

  • Virtual threads are cheap JVM-managed threads that unmount while blocked on I/O.
  • spring.threads.virtual.enabled=true switches Tomcat, @Async, scheduling and listeners to virtual threads.
  • They boost I/O-bound throughput with ordinary blocking code, not CPU-bound work.
  • Protect limited resources (DB pools, fragile APIs) with pool sizes and @ConcurrencyLimit.
  • Prefer Java 24+ (e.g. Java 25) to avoid synchronized pinning issues.

Practice the examples

Change an input, predict the result, then compare it with the output. Explain why the result changes.

Use your local JDK or project IDE for these examples. Codelab currently runs Python and HTML/CSS/JavaScript; framework examples may need project dependencies.