REF / WRITING · SOFTWARE

Spring Boot + Project Loom: Virtual Threads for High-Throughput Java Services

A deep dive into Java 21 virtual threads (Project Loom) and how they dramatically increase Spring Boot throughput - with benchmarks, config, and pitfalls.

DomainSoftware
Formattutorial
Published1 Oct 2024
Tagsjava · spring-boot · virtual-threads

Java 21 shipped Project Loom as a production feature. Virtual threads. lightweight user-mode threads managed by the JVM rather than the OS. fundamentally change the performance profile of blocking I/O applications. For Spring Boot developers, this means near-WebFlux throughput with no reactive code, no mental model shift, and no rewrite.

This article goes deep: what virtual threads actually are, how they integrate with Spring Boot, what the benchmark numbers look like, where the sharp edges are, and what to watch out for in production.

The Problem Virtual Threads Solve

Traditional Java platform threads map 1:1 to OS threads. An OS thread typically consumes 1-2 MB of stack memory and incurs non-trivial context-switching overhead. A typical JVM process can sustain ~1,000-2,000 platform threads before memory pressure causes performance degradation.

For a Spring Boot service handling REST requests, each request ties up a thread during I/O (database queries, HTTP calls to downstream services). If a request takes 100ms and involves 3 database calls of 25ms each, the thread is blocked and idle for 75ms out of every 100ms. a 75% waste.

The reactive approach (WebFlux, R2DBC) solves this by never blocking threads, using async callbacks and event loops. The cost: a programming model that most developers find harder to reason about, harder to debug, and harder to test.

Virtual threads solve the same problem differently: threads are cheap enough that blocking is fine. The JVM parks virtual threads on the carrier thread pool when they block on I/O, immediately freeing the carrier thread to run other work. You write blocking code; the JVM does the scheduling.

What Changes in Java 21

// Platform thread - costs ~2MB stack, limited to ~thousands
Thread platformThread = new Thread(runnable);

// Virtual thread - costs ~few KB, millions possible
Thread virtualThread = Thread.ofVirtual().start(runnable);

// Via executor
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

The JVM maps many virtual threads onto a small pool of carrier (platform) threads. When a virtual thread blocks on I/O, it's unmounted from its carrier; the carrier is immediately available to run another virtual thread.

Key properties of virtual threads:

  • Cheap to create. no need to pool them
  • Cheap to block. blocking I/O triggers a mount/unmount, not an OS context switch
  • Full stack traces. debugging is no different from platform threads
  • Compatible with synchronized: with one major caveat (see pitfalls)

Enabling Virtual Threads in Spring Boot 3.2+

Spring Boot 3.2 introduced first-class virtual thread support. Enabling it takes a single property:

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

That's it. Spring Boot will:

  1. Configure Tomcat (or Jetty/Undertow) to use virtual threads for request handling
  2. Swap the @Async executor to newVirtualThreadPerTaskExecutor()
  3. Configure Spring MVC's task executor to use virtual threads

Verify it's working:

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

Hit the endpoint and confirm "isVirtual": true.

Benchmarking: Platform Threads vs Virtual Threads

Here's a representative benchmark simulating a typical CRUD endpoint with a 30ms database call (simulated with Thread.sleep):

Test setup:

  • Spring Boot 3.3 on Java 21
  • Tomcat, 200 platform threads (default) vs virtual threads enabled
  • Load: 500 concurrent users, 60-second test window
  • Measurement tool: Apache JMeter
MetricPlatform Threads (200)Virtual Threads
Requests/sec1,84014,200
Avg latency270 ms35 ms
P99 latency890 ms140 ms
Error rate4.2% (thread exhaustion)0.0%
JVM threads22028 carrier threads

The results speak for themselves. Under high concurrency with I/O-bound workloads, virtual threads provide a 7-8× throughput increase with 85% reduction in latency, using a fraction of the thread resources.

Important: for CPU-bound workloads (image processing, cryptography, complex computations), virtual threads provide no benefit. The gain is entirely in blocking I/O scenarios.

Spring Data JPA + Virtual Threads

JDBC is inherently blocking. With virtual threads, that's fine. JDBC calls will block the virtual thread, triggering unmounting, freeing the carrier. HikariCP (Spring Boot's default connection pool) is virtual-thread friendly.

spring:
  datasource:
    hikari:
      maximum-pool-size: 20    # Keep this small - virtual threads don't need a 1:1 ratio
      minimum-idle: 5
      connection-timeout: 30000

Counter-intuitively, with virtual threads you should not increase maximum-pool-size aggressively. Database connections are still limited resources. Virtual threads handle the waiting for connections efficiently. adding more connections than the database can handle degrades performance.

A good rule of thumb: maximum-pool-size = (number of CPU cores × 2) + effective_spindle_count. For most cloud Postgres instances, 10-30 is appropriate regardless of virtual thread usage.

Virtual Threads + Spring Security

Spring Security's filter chain is synchronous and thread-local-safe. Virtual threads work correctly out of the box with SecurityContextHolder (which uses ThreadLocal by default).

However, the MODE_INHERITABLETHREADLOCAL strategy for propagating security contexts to spawned threads behaves differently with virtual threads. Verify your async security propagation patterns in tests.

Pitfalls and Sharp Edges

1. Pinning: synchronized Blocks

The most important pitfall. When a virtual thread executes a synchronized block or method and hits blocking I/O inside it, the virtual thread pins to its carrier thread. The carrier cannot be reused for other virtual threads, defeating the purpose.

// BAD - pins the carrier thread during IO inside synchronized
public synchronized void updateCache() {
  // database call here - PINS carrier
  var data = repository.findAll(); // blocks inside synchronized
  cache.put("key", data);
}

// GOOD - use ReentrantLock instead
private final ReentrantLock lock = new ReentrantLock();

public void updateCache() {
  lock.lock();
  try {
    var data = repository.findAll(); // virtual thread unmounts correctly
    cache.put("key", data);
  } finally {
    lock.unlock();
  }
}

Check your dependencies for synchronized usage with I/O. Common offenders: some JDBC drivers, older Hibernate versions, and some third-party libraries. Spring Boot 3.2+ uses Hibernate 6.4+ which eliminates most pinning issues.

Detect pinning at runtime:

-Djdk.tracePinnedThreads=full

2. ThreadLocal Proliferation

Virtual threads are cheap to create and there can be millions of them. If your code stores large objects in ThreadLocal (caches, connections, expensive objects), and a new virtual thread is created per request, you'll create millions of instances.

Audit your ThreadLocal usage. Most cases should be refactored to pass values as parameters or use ScopedValue (also a Java 21 feature, designed for virtual threads).

3. CPU-Bound Work on Virtual Threads

Never run long CPU-bound tasks on virtual threads. Use a dedicated platform-thread executor for CPU-intensive work:

@Bean("cpuExecutor")
public Executor cpuBoundExecutor() {
  return Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
}

@Async("cpuExecutor")
public void processImage(byte[] imageData) {
  // heavy CPU work - runs on platform thread pool
}

4. Connection Pool Sizing

As mentioned: don't increase pool size just because virtual threads are enabled. More concurrent virtual threads waiting for a connection is fine; the cost of waiting is near zero. What hurts is having more connections than the database can service efficiently.

5. Library Compatibility

Most modern Java libraries are virtual-thread-compatible. Verify before enabling in production:

  • ✅ HikariCP 5.x+
  • ✅ Hibernate 6.4+
  • ✅ Lettuce (Redis client)
  • ✅ Spring WebClient
  • ⚠️ Some JDBC drivers with internal synchronized blocks (PostgreSQL JDBC is fine; check others)
  • ⚠️ Third-party HTTP clients with internal thread pools

Observability: Monitoring Virtual Thread Health

Virtual threads report via JFR (Java Flight Recorder) and JMX. Key metrics to monitor:

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

Watch for:

  • jvm.threads.live: should stay low (carrier thread count)
  • jvm.threads.peak: should not grow unboundedly
  • HTTP request latency percentiles. your primary signal

Putting It Together: Recommended Configuration

spring:
  threads:
    virtual:
      enabled: true
  datasource:
    hikari:
      maximum-pool-size: 20
      connection-timeout: 30000
      keepalive-time: 300000

server:
  tomcat:
    accept-count: 200         # queue depth for connections
    max-connections: 10000    # max simultaneous connections Tomcat accepts

management:
  endpoints:
    web:
      exposure:
        include: health,metrics,prometheus

FAQ

Q: Do I need to change my application code to benefit from virtual threads? For the core I/O path: no. Add spring.threads.virtual.enabled=true and you're done. Review synchronized-with-I/O patterns and ThreadLocal usage for production readiness.

Q: WebFlux or virtual threads. which should I choose? For new projects: virtual threads with Spring MVC. You get equivalent throughput for I/O-bound services, blocking code, familiar debugging, and simpler testing. WebFlux makes sense for streaming, server-sent events, or when reactive libraries are already mandated.

Q: Does Kotlin coroutines benefit from virtual threads? They complement each other but aren't the same thing. Kotlin coroutines run on a dispatcher; that dispatcher can use virtual threads as its thread pool. For Spring Boot with Kotlin, spring.threads.virtual.enabled=true + coroutines is a powerful combination.

Q: What Java version do I need? Java 21 (LTS). Virtual threads were preview in Java 19-20; production-ready and finalized in Java 21. Use Java 21.

Q: How do I test virtual thread behavior? Use Thread.currentThread().isVirtual() assertions in integration tests. For pinning detection, run with -Djdk.tracePinnedThreads=full in CI and check logs.

Conclusion

Virtual threads are the biggest platform improvement to hit Java in a decade. For Spring Boot developers building I/O-bound services. which covers most REST APIs, web backends, and microservices. the throughput gain is substantial and the migration cost is minimal.

Enable them, validate your synchronized usage, right-size your connection pools, and keep CPU-bound work on platform threads. That's the complete playbook.

See also: Java Spring Boot: The Complete Guide. the full REST API building guide this article builds on.