Back to Blog
java-21virtual-threadsproject-loomspring-bootconcurrencyperformance

Java Virtual Threads & Project Loom: Complete Guide for Spring Boot (2026)

Java 21 virtual threads change everything about concurrency. Learn how they work, how to use them in Spring Boot, and when platform threads are still the right choice.

J

JOptimize Team

May 19, 2026· 9 min read

Java 21 made virtual threads a stable feature with Project Loom, and they fundamentally change how you think about concurrency in Spring Boot. You can now write simple, blocking code that scales to hundreds of thousands of concurrent requests - without reactive programming, callbacks, or thread pool math.


Why Virtual Threads Exist: The Problem with Platform Threads

Traditional Java threads are OS threads - creating one allocates ~1MB of stack space and requires a kernel context switch. A typical server handles 200-500 concurrent platform threads before performance degrades. When your app makes a blocking I/O call (database, HTTP, file), the thread sits idle but still consumes those resources.

// Traditional blocking code - each request ties up a platform thread @RestController public class OrderController { @GetMapping("/orders/{id}") public Order getOrder(@PathVariable Long id) { // Thread is BLOCKED here while DB responds return orderRepository.findById(id).orElseThrow(); } }

With 1000 concurrent requests, this needs 1000 platform threads - ~1GB of stack memory, massive scheduling overhead, and throughput collapse once the pool is exhausted.


What Virtual Threads Are (and Aren't)

Virtual threads are JVM-managed threads multiplexed onto a small pool of carrier (platform) threads. When a virtual thread blocks on I/O, the JVM parks it and reassigns the carrier thread to another virtual thread. No OS thread sits idle.

// Virtual threads are cheap - create millions Thread vt = Thread.ofVirtual().start(() -> { System.out.println("Running on: " + Thread.currentThread()); }); // With ExecutorService: try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { IntStream.range(0, 1_000_000).forEach(i -> executor.submit(() -> { Thread.sleep(Duration.ofMillis(100)); return i; }) ); }

One million virtual threads use only a handful of OS threads and run comfortably on a laptop.

Key point: Virtual threads are NOT faster for CPU-bound work. They solve throughput under I/O blocking, not raw computation speed.


Enabling Virtual Threads in Spring Boot 3.2+

One property switches Tomcat's thread pool to virtual threads:

# application.properties spring.threads.virtual.enabled=true

Spring Boot replaces the Tomcat thread pool, async task executor, and scheduled task executor with virtual thread executors. Your existing blocking controllers now scale like reactive code, with no code changes.

To verify it's active:

@RestController public class ThreadInfoController { @GetMapping("/thread-info") public String threadInfo() { Thread t = Thread.currentThread(); return "Virtual: " + t.isVirtual() + ", Name: " + t.getName(); } }

Structured Concurrency: Run Tasks in Parallel Safely

Structured concurrency (stable in Java 23) makes forking and joining concurrent tasks safe and readable:

import java.util.concurrent.StructuredTaskScope; public record UserProfile(User user, List<Order> orders, AccountStatus status) {} public UserProfile fetchUserProfile(Long userId) throws Exception { try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { var userTask = scope.fork(() -> userService.findById(userId)); var ordersTask = scope.fork(() -> orderService.findByUserId(userId)); var statusTask = scope.fork(() -> accountService.getStatus(userId)); scope.join().throwIfFailed(); // waits for all, cancels on any failure return new UserProfile( userTask.get(), ordersTask.get(), statusTask.get() ); } }

All three calls run in parallel on virtual threads. If any fails, the others are cancelled automatically. No orphaned threads, no manual CompletableFuture exception handling.


Pitfalls to Watch For

Pinned Virtual Threads

A virtual thread is "pinned" (can't unmount from its carrier) inside a synchronized block or when calling native code. Use ReentrantLock instead:

// Bad - pins the virtual thread synchronized (lock) { doBlockingWork(); } // Good - virtual thread can unmount while waiting private final ReentrantLock lock = new ReentrantLock(); lock.lock(); try { doBlockingWork(); } finally { lock.unlock(); }

ThreadLocal Abuse

With millions of virtual threads, per-thread caches (large objects in ThreadLocal) can exhaust memory. Prefer ScopedValue (Java 21+) for request-scoped data.

Database Connection Pools

Virtual threads unblock the thread pool, but your DB still has limited connections. With spring.threads.virtual.enabled=true, tune HikariCP's maximum-pool-size based on DB capacity, not thread count.

CPU-Bound Tasks

Don't spawn virtual threads for pure computation. They'll compete for the same carrier threads and perform worse than a sized thread pool.


Performance Comparison

ScenarioPlatform ThreadsVirtual Threads
1000 concurrent I/O requests~1GB stack memory~10MB
Thread creation time~1ms~1?s
Context switch overheadHigh (OS)Low (JVM)
CPU-bound tasks? Ideal? No benefit
Blocking I/O tasks? Pool exhaustion? Scales freely

Summary

Java virtual threads, enabled with a single property in Spring Boot 3.2+, let you write straightforward blocking code that scales to massive concurrency without reactive frameworks. They are the right tool for I/O-heavy applications - REST APIs, database-backed services, microservices calling other services. Avoid them for CPU-bound work and watch out for synchronized pinning.


Find Thread Safety Issues Before They Hit Production

As you migrate to virtual threads, mutable state in Spring singletons becomes a critical risk. JOptimize scans your codebase for shared mutable fields in @Service and @Component beans, and flags synchronized blocks that will pin virtual threads.

Catch concurrency bugs before switching to virtual threads - free scan, no setup required.

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.