Back to Blog
java-21virtual-threadsspring-bootconcurrencyperformanceloom

Java 21 Virtual Threads in Production: What Changed and What Didn't (2026)

Virtual threads are in production at scale. Here's what actually changed for Spring Boot apps, what pitfalls teams hit in their first year, and when virtual threads genuinely help.

J

JOptimize Team

May 29, 2026· 10 min read

Virtual threads shipped in Java 21 as a production-ready feature in September 2023. By 2026, a significant portion of production Java deployments run on Java 21 or later, and many of them have virtual threads enabled. Enough time has passed that we can talk honestly about what actually changed — not what was promised in the JEP, but what teams experience in real deployments.

The short answer: for I/O-bound workloads, virtual threads deliver on their promise. The caveats are real and important, but they don't undermine the core value proposition.


What Virtual Threads Actually Change

Before virtual threads, every thread in Java was a platform thread — a direct wrapper around an OS thread. OS threads are expensive: they consume around 1MB of stack memory by default and have significant scheduling overhead. A standard Spring Boot application with Tomcat handles up to 200 concurrent requests (the default thread pool size) before queuing starts.

Virtual threads are lightweight. They're managed by the JVM, not the OS, and they're cheap enough to create one per request — or even one per I/O operation. The JVM multiplexes thousands of virtual threads onto a small pool of OS carrier threads.

The key insight is how blocking I/O works. When a virtual thread blocks on a database call, it's unmounted from its carrier thread. The carrier thread can immediately pick up another virtual thread and execute it. When the database responds, the original virtual thread is remounted and resumes. From the developer's perspective, the code looks like blocking code — but the underlying execution is non-blocking.

This means:

  • Throughput increases dramatically for I/O-bound workloads
  • You write simple, synchronous code — no callbacks, no reactive operators
  • Thread pools stop being a bottleneck for most applications

Enabling Virtual Threads in Spring Boot

Enabling virtual threads in Spring Boot 3.2+ is a single configuration line:

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

This switches Tomcat's thread pool to use virtual threads — one virtual thread per request instead of borrowing from a fixed pool.

For async processing, you can also create a virtual thread executor explicitly:

@Configuration public class ThreadConfig { @Bean("virtualThreadExecutor") public Executor virtualThreadExecutor() { return Executors.newVirtualThreadPerTaskExecutor(); } } @Async("virtualThreadExecutor") public CompletableFuture<ReportDto> generateReport(Long reportId) { // This runs on a virtual thread // Blocking calls here are fine — they yield the carrier thread Report report = reportRepo.findById(reportId).orElseThrow(); List<DataPoint> data = analyticsService.fetchData(report.getDateRange()); return CompletableFuture.completedFuture(buildReport(report, data)); }

For @Scheduled tasks, Spring Boot automatically uses virtual threads when the flag is enabled — no additional configuration needed.


Real-World Performance Impact

The benefit of virtual threads depends heavily on what your application actually does:

High impact (I/O-bound): A REST API that makes 3-4 database calls and 1-2 external HTTP calls per request is a perfect fit. Each I/O operation that would have blocked a platform thread now yields the carrier thread for other work. Teams commonly report 2-4x throughput improvement with the same hardware and without changing any code.

Low impact (CPU-bound): If your endpoint spends most of its time doing computation — parsing, cryptography, data transformation — virtual threads provide little benefit. The carrier threads are busy with CPU work and there's nothing to yield. For these workloads, you get the same throughput as before, just with slightly different threading semantics.

Negative impact (synchronized blocks with long critical sections): This is where teams get surprised.


The Thread Pinning Problem

Virtual threads have one significant limitation: they cannot yield when they're inside a synchronized block or method. If a virtual thread enters a synchronized block and then blocks on I/O, it pins its carrier thread for the duration. This is called thread pinning, and it can eliminate the benefits of virtual threads if it happens on hot paths.

// This pins the carrier thread if called while blocking public synchronized void processOrder(Order order) { Order saved = orderRepo.save(order); // Database call inside synchronized block! // Carrier thread is pinned for the entire database round trip } // Fix: use ReentrantLock instead — virtual threads CAN yield on ReentrantLock private final ReentrantLock lock = new ReentrantLock(); public void processOrder(Order order) { lock.lock(); try { Order saved = orderRepo.save(order); // Database call — virtual thread can yield! } finally { lock.unlock(); } }

The most common source of thread pinning in Spring Boot applications is not your code — it's libraries. JDBC drivers like the older MySQL Connector use synchronized internally. The Spring team and major JDBC vendors have been working to replace synchronized with ReentrantLock, but progress is uneven.

# Detect thread pinning at runtime java -Djdk.tracePinnedThreads=full -jar app.jar # Outputs a stack trace every time a virtual thread is pinned # Use this during load testing to find pinning hotspots

For PostgreSQL, the PgJDBC driver (the standard Postgres connector) eliminated most synchronized blocks in version 42.7. For MySQL, MySQL Connector/J 9.x greatly improved virtual thread compatibility. If you're on older versions, pinning may limit your throughput gains.


Observability: Debugging Virtual Threads

Thread dumps look different with virtual threads:

# Take a thread dump with virtual threads jcmd $(pgrep -f app.jar) Thread.dump_to_file -format=json /tmp/threads.json # Or via actuator curl http://localhost:8080/actuator/threaddump

Virtual threads appear in thread dumps, but you'll see thousands of them rather than hundreds. Tools like VisualVM and IntelliJ IDEA (2024+) can filter and group virtual threads to make the dump readable.

For production monitoring, the key metrics are carrier thread utilization rather than virtual thread count:

@Component public class VirtualThreadMetrics { @PostConstruct public void registerMetrics() { // Monitor carrier thread pool — the bottleneck if pinning occurs Gauge.builder("jvm.virtual.threads.carrier.active", ForkJoinPool.commonPool(), pool -> pool.getActiveThreadCount()) .register(meterRegistry); Gauge.builder("jvm.virtual.threads.carrier.queued", ForkJoinPool.commonPool(), pool -> pool.getQueuedSubmissionCount()) .description("Tasks queued on carrier thread pool — high values indicate pinning") .register(meterRegistry); } }

Virtual Threads vs Reactive: The Honest Comparison

Virtual threads and reactive programming (WebFlux) both solve the same problem — efficient I/O handling without blocking threads. They're different approaches with different trade-offs:

Virtual Threads (Project Loom):

  • Imperative code — easy to read, debug, and write
  • Works with all existing blocking libraries
  • Thread-local variables work naturally
  • Stack traces are readable
  • Weaker backpressure control

Reactive (WebFlux/Project Reactor):

  • Functional, non-blocking code — harder to learn
  • Requires reactive-compatible libraries throughout the stack
  • Explicit backpressure — critical for streaming large datasets
  • Harder to debug (stack traces show Reactor internals)
  • Marginally more efficient at very high concurrency (50K+ req/s)

For most Spring Boot applications, virtual threads are now the better choice. They're simpler, they work with all your existing code, and the throughput is sufficient for the vast majority of workloads. Reactive makes sense when you need explicit backpressure — streaming large files, processing high-volume event streams, or building systems with > 50K concurrent connections.

The Spring team has said explicitly that they don't see reactive as the future for all Spring Boot applications — virtual threads have made synchronous code competitive again for most use cases.


Common Mistakes to Avoid

  • Using ThreadLocal for request context — ThreadLocal works with virtual threads, but InheritableThreadLocal doesn't propagate as expected when using virtual thread executors; use ScopedValue (Java 21 preview) or Spring's RequestContextHolder instead
  • Assuming no thread pinning in your dependencies — audit your JDBC driver and connection pool versions; older drivers pin heavily
  • Not running JDK 21.0.3 or later — early Java 21 releases had virtual thread performance issues that were fixed in patch releases
  • Mixing virtual and platform threads carelessly — blocking a platform thread on a virtual thread operation can cause deadlocks in edge cases

Summary

Java 21 virtual threads deliver real throughput improvements for I/O-bound Spring Boot applications — commonly 2-4x with no code changes, just enabling spring.threads.virtual.enabled=true. Thread pinning in older libraries is the main practical limitation. Use jdk.tracePinnedThreads=full to find pinning hot spots, update your JDBC drivers to versions with ReentrantLock internal implementations, and monitor carrier thread utilization in production. For most applications, virtual threads are now the right default.


Combine Virtual Threads with Performance Analysis

Virtual threads improve concurrency, but they don't fix slow queries or N+1 patterns. JOptimize helps you identify the data access issues that limit throughput regardless of your threading model.

More threads, faster queries — that's the combination.

Want to go deeper?

Master Spring Boot, security, and Java performance with hands-on courses.

Detect issues in your project

JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.