Virtual Threads (Project Loom)
Virtual threads, introduced as a preview in Java 19 and finalized in Java 21, fundamentally change how Java handles concurrency. They are lightweight threads managed by the JVM rather than the OS, enabling millions of concurrent threads without the memory overhead of platform threads. Spring Boot 3.2+ provides first-class support for virtual threads.
1. Platform Threads vs Virtual Threads
Platform Threads (Traditional)
┌─────────────────────────────────────────────────────────────────┐
│ Each thread ≈ 1 OS thread │
│ Stack size: ~1 MB (configurable) │
│ 200 threads ≈ 200 MB of stack memory │
│ Context switch: OS kernel (expensive, ~1-10 microseconds) │
│ Creating threads: slow (syscall to OS) │
│ Typical pool size: 200-400 for a web server │
│ Under load: thread pool exhaustion → requests queue or fail │
└─────────────────────────────────────────────────────────────────┘
Virtual Threads (Project Loom)
┌─────────────────────────────────────────────────────────────────┐
│ Each thread ≈ JVM-managed continuation │
│ Stack size: starts at ~1 KB, grows as needed │
│ 1,000,000 virtual threads ≈ a few GB │
│ Context switch: JVM userspace (cheap, ~nanoseconds) │
│ Creating threads: fast (no syscall) │
│ No pool needed: create per task, let GC clean up │
│ Under load: scales to available work (no pool exhaustion) │
└─────────────────────────────────────────────────────────────────┘
Carrier Thread Model:
┌─────────────────────────────────────────────────────────────────┐
│ ForkJoinPool (carrier threads = CPU cores) │
│ │
│ Carrier 1: [VT-A] ─── I/O wait ──→ [VT-B] ─── I/O wait ──→ │
│ Carrier 2: [VT-C] ─── I/O wait ──→ [VT-A] ─── compute ───→ │
│ Carrier 3: [VT-D] ─── I/O wait ──→ [VT-E] ─── I/O wait ──→ │
│ │
│ When a virtual thread blocks on I/O, its carrier is freed │
│ to run other virtual threads. The blocked VT is "parked" │
│ and resumed when the I/O completes. │
└─────────────────────────────────────────────────────────────────┘2. Enabling Virtual Threads in Spring Boot
2.1 Simple Configuration
# application.yml — Spring Boot 3.2+
spring:
threads:
virtual:
enabled: true # That's it. Tomcat/Jetty will use virtual threads.This single property configures:
- Tomcat/Jetty to handle each request on a virtual thread
@Asyncmethods to run on virtual threads- Spring MVC async request handling on virtual threads
- Task scheduling on virtual threads
2.2 Explicit Executor Configuration
@Configuration
public class VirtualThreadConfig {
// Explicit virtual thread executor (if you need more control)
@Bean
public AsyncTaskExecutor applicationTaskExecutor() {
return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
}
// For @Scheduled tasks
@Bean
public TaskScheduler taskScheduler() {
SimpleAsyncTaskScheduler scheduler = new SimpleAsyncTaskScheduler();
scheduler.setVirtualThreads(true);
scheduler.setThreadNamePrefix("scheduled-vt-");
return scheduler;
}
// Custom executor for specific use cases
@Bean("batchExecutor")
public ExecutorService batchExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
}2.3 Verifying Virtual Threads Are Active
@RestController
@RequestMapping("/api/debug")
public class ThreadDebugController {
@GetMapping("/thread-info")
public Map<String, Object> threadInfo() {
Thread current = Thread.currentThread();
return Map.of(
"threadName", current.getName(),
"isVirtual", current.isVirtual(),
"threadId", current.threadId(),
"threadClass", current.getClass().getName(),
"carrier", current.isVirtual()
? "mounted on carrier (ForkJoinPool)"
: "platform thread"
);
}
}
// Response with virtual threads enabled:
// {
// "threadName": "tomcat-handler-1",
// "isVirtual": true,
// "threadId": 54,
// "threadClass": "java.lang.VirtualThread",
// "carrier": "mounted on carrier (ForkJoinPool)"
// }3. When Virtual Threads Help
3.1 I/O-Bound Workloads (Big Win)
@Service
public class OrderEnrichmentService {
private final RestClient customerClient;
private final RestClient inventoryClient;
private final RestClient pricingClient;
private final JdbcTemplate jdbcTemplate;
// Each of these blocking calls releases the carrier thread
// while waiting for the network response
public EnrichedOrder enrichOrder(OrderRequest request) {
// 1. Database query (JDBC blocks → virtual thread parks)
Order order = jdbcTemplate.queryForObject(
"SELECT * FROM orders WHERE id = ?",
new OrderRowMapper(), request.getOrderId());
// 2. HTTP call to customer service (blocks → parks)
Customer customer = customerClient.get()
.uri("/api/customers/{id}", order.getCustomerId())
.retrieve()
.body(Customer.class);
// 3. HTTP call to inventory service (blocks → parks)
StockLevel stock = inventoryClient.get()
.uri("/api/inventory/{sku}", order.getSku())
.retrieve()
.body(StockLevel.class);
// 4. HTTP call to pricing service (blocks → parks)
Price price = pricingClient.get()
.uri("/api/pricing/{sku}?tier={tier}",
order.getSku(), customer.getTier())
.retrieve()
.body(Price.class);
return new EnrichedOrder(order, customer, stock, price);
}
}
// With platform threads: 200 concurrent requests = 200 threads = thread pool limit
// With virtual threads: 10,000 concurrent requests = 10,000 virtual threads
// = only ~N carrier threads (N = CPU cores), rest are parked waiting for I/O3.2 Parallel I/O with StructuredTaskScope
@Service
public class ParallelOrderService {
public OrderDetails getOrderDetails(Long orderId) throws Exception {
// StructuredTaskScope ensures all subtasks complete or fail together
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// Fork parallel tasks — each runs on its own virtual thread
Subtask<Order> orderTask = scope.fork(() ->
orderClient.getOrder(orderId));
Subtask<Customer> customerTask = scope.fork(() ->
customerClient.getCustomer(orderId));
Subtask<List<OrderItem>> itemsTask = scope.fork(() ->
itemClient.getItems(orderId));
Subtask<ShippingInfo> shippingTask = scope.fork(() ->
shippingClient.getShipping(orderId));
// Wait for all tasks to complete
scope.join();
scope.throwIfFailed();
// All completed successfully — combine results
return new OrderDetails(
orderTask.get(),
customerTask.get(),
itemsTask.get(),
shippingTask.get()
);
}
}
// Fan-out pattern: process many items in parallel
public List<ProcessedItem> processItems(List<Item> items) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
List<Subtask<ProcessedItem>> tasks = items.stream()
.map(item -> scope.fork(() -> processItem(item)))
.toList();
scope.join();
scope.throwIfFailed();
return tasks.stream()
.map(Subtask::get)
.toList();
}
}
// With custom error handling
public OrderDetails getOrderDetailsResilient(Long orderId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<Object>()) {
// Race: return whichever responds first
scope.fork(() -> primaryService.getOrder(orderId));
scope.fork(() -> fallbackService.getOrder(orderId));
scope.join();
return (OrderDetails) scope.result();
}
}
}4. When Virtual Threads Don't Help
4.1 CPU-Bound Work
// NO BENEFIT — virtual threads don't help with CPU-bound computation
@Service
public class CpuIntensiveService {
public BigInteger computeFactorial(int n) {
// This is pure CPU work — no I/O, no blocking
// Virtual thread stays mounted on carrier the entire time
// No opportunity to park and switch
BigInteger result = BigInteger.ONE;
for (int i = 2; i <= n; i++) {
result = result.multiply(BigInteger.valueOf(i));
}
return result;
}
// For CPU-bound work, use a fixed thread pool sized to CPU cores
@Bean("cpuExecutor")
public ExecutorService cpuBoundExecutor() {
return Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors());
}
}4.2 Thread-Local Heavy Code
// CAUTION — thread-local variables work but can be memory-expensive
// because each of millions of virtual threads gets its own copy
// BAD: SimpleDateFormat in thread-local (wastes memory per VT)
private static final ThreadLocal<SimpleDateFormat> formatter =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
// GOOD: Use DateTimeFormatter (thread-safe, no ThreadLocal needed)
private static final DateTimeFormatter formatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd");
// GOOD: Use ScopedValue (Java 21 preview) instead of ThreadLocal
private static final ScopedValue<RequestContext> REQUEST_CONTEXT =
ScopedValue.newInstance();
public void handleRequest(RequestContext ctx) {
ScopedValue.runWhere(REQUEST_CONTEXT, ctx, () -> {
// Available in this scope and all called methods
processOrder();
});
}
private void processOrder() {
RequestContext ctx = REQUEST_CONTEXT.get(); // no ThreadLocal
// ...
}5. Pinning Issues
Pinning occurs when a virtual thread cannot be unmounted from its carrier thread, blocking the carrier and reducing throughput.
5.1 Causes of Pinning
// CAUSE 1: synchronized blocks/methods
// The virtual thread stays pinned to its carrier for the entire
// synchronized block, even during I/O operations inside it.
// BAD — pinning during synchronized + blocking I/O
public class LegacyService {
private final Object lock = new Object();
public String fetchData() {
synchronized (lock) {
// Virtual thread is PINNED here
return httpClient.send(request, BodyHandlers.ofString()).body();
// Carrier thread is blocked — cannot run other virtual threads
}
}
}
// GOOD — use ReentrantLock instead (virtual-thread friendly)
public class ModernService {
private final ReentrantLock lock = new ReentrantLock();
public String fetchData() {
lock.lock();
try {
// Virtual thread can unmount while waiting for I/O
return httpClient.send(request, BodyHandlers.ofString()).body();
} finally {
lock.unlock();
}
}
}
// CAUSE 2: native methods that hold the monitor
// Some JDK classes internally use synchronized (e.g., old I/O classes)
// Solution: Use java.nio, java.net.http, or libraries updated for Loom5.2 Detecting Pinning
# JVM flag to detect pinning
java -Djdk.tracePinnedThreads=short -jar myapp.jar
# or
java -Djdk.tracePinnedThreads=full -jar myapp.jar
# Output when pinning occurs:
# Thread[#42,VirtualThread-unparker,5,CarrierThreads] <== monitors:1
# com.example.LegacyService.fetchData(LegacyService.java:15)
# <== pinned while synchronized// Programmatic pinning detection
@Configuration
public class PinningDetectionConfig {
@Bean
public ApplicationRunner pinningDetector() {
return args -> {
// Set system property for pinning detection
System.setProperty("jdk.tracePinnedThreads", "short");
};
}
}5.3 Common Libraries with Pinning Issues
┌────────────────────────────┬────────────────────────────────────┐
│ Library │ Status / Workaround │
├────────────────────────────┼────────────────────────────────────┤
│ HikariCP │ Fixed in 5.1+ (uses ReentrantLock)│
│ JDBC drivers (PostgreSQL) │ Fixed in 42.7.0+ │
│ MySQL Connector/J │ Fixed in 8.2.0+ │
│ Apache HttpClient │ Use java.net.http.HttpClient │
│ OkHttp │ Fixed in 5.0+ │
│ Netty (reactor-netty) │ N/A (non-blocking, no pinning) │
│ Jackson │ No issues │
│ Logback │ Minor pinning (acceptable) │
│ Spring Security │ No issues in 6.2+ │
│ Hibernate │ Fixed in 6.4+ │
└────────────────────────────┴────────────────────────────────────┘6. Monitoring Virtual Threads
6.1 JFR Events
@Configuration
public class VirtualThreadMonitoring {
@Bean
public ApplicationRunner jfrMonitoring() {
return args -> {
// Enable JFR events for virtual thread monitoring
// jdk.VirtualThreadStart
// jdk.VirtualThreadEnd
// jdk.VirtualThreadPinned
// jdk.VirtualThreadSubmitFailed
};
}
}6.2 Custom Metrics
@Component
public class VirtualThreadMetrics implements MeterBinder {
@Override
public void bindTo(MeterRegistry registry) {
// Track virtual thread creation rate
Gauge.builder("jvm.threads.virtual.count", () -> {
// Thread.getAllStackTraces() is expensive; use JMX or JFR
return Thread.getAllStackTraces().keySet().stream()
.filter(Thread::isVirtual)
.count();
}).description("Number of active virtual threads")
.register(registry);
// Track carrier thread utilization
ForkJoinPool carrier = ForkJoinPool.commonPool();
Gauge.builder("jvm.carrier.active", carrier, ForkJoinPool::getActiveThreadCount)
.description("Active carrier threads")
.register(registry);
Gauge.builder("jvm.carrier.pool.size", carrier, ForkJoinPool::getPoolSize)
.description("Carrier thread pool size")
.register(registry);
Gauge.builder("jvm.carrier.queued", carrier, ForkJoinPool::getQueuedTaskCount)
.description("Queued tasks on carrier pool")
.register(registry);
}
}6.3 Thread Dump Filtering
@RestController
@RequestMapping("/api/admin/threads")
public class ThreadDiagnosticController {
@GetMapping("/virtual")
@PreAuthorize("hasRole('ADMIN')")
public Map<String, Object> virtualThreadInfo() {
Map<Thread.State, Long> stateCount = Thread.getAllStackTraces().keySet().stream()
.filter(Thread::isVirtual)
.collect(Collectors.groupingBy(Thread::getState, Collectors.counting()));
long totalVirtual = stateCount.values().stream().mapToLong(Long::longValue).sum();
long totalPlatform = Thread.getAllStackTraces().keySet().stream()
.filter(t -> !t.isVirtual())
.count();
return Map.of(
"virtualThreads", totalVirtual,
"platformThreads", totalPlatform,
"virtualThreadStates", stateCount,
"carrierPoolSize", ForkJoinPool.commonPool().getPoolSize(),
"carrierActive", ForkJoinPool.commonPool().getActiveThreadCount()
);
}
}7. Migration from Platform Threads
7.1 Step-by-Step Migration
// Step 1: Update Java to 21+ and Spring Boot to 3.2+
// Step 2: Update dependencies that cause pinning
// - HikariCP 5.1+
// - PostgreSQL JDBC 42.7.0+
// - MySQL Connector/J 8.2.0+
// Step 3: Replace synchronized with ReentrantLock
// BEFORE:
public class CacheService {
private final Map<String, Object> cache = new HashMap<>();
public synchronized Object get(String key) {
return cache.get(key);
}
public synchronized void put(String key, Object value) {
cache.put(key, value);
}
}
// AFTER:
public class CacheService {
private final Map<String, Object> cache = new HashMap<>();
private final ReentrantLock lock = new ReentrantLock();
public Object get(String key) {
lock.lock();
try {
return cache.get(key);
} finally {
lock.unlock();
}
}
public void put(String key, Object value) {
lock.lock();
try {
cache.put(key, value);
} finally {
lock.unlock();
}
}
}
// OR better: use ConcurrentHashMap
public class CacheService {
private final Map<String, Object> cache = new ConcurrentHashMap<>();
public Object get(String key) { return cache.get(key); }
public void put(String key, Object value) { cache.put(key, value); }
}
// Step 4: Replace ThreadLocal with ScopedValue where possible
// Step 5: Enable virtual threads
// spring.threads.virtual.enabled=true
// Step 6: Remove thread pool tuning (no longer needed for request handling)
// BEFORE:
// server.tomcat.threads.max=400
// server.tomcat.threads.min-spare=50
// AFTER: remove these — virtual threads don't need tuning
// Step 7: Run with -Djdk.tracePinnedThreads=short to find pinning
// Step 8: Load test and compare metrics7.2 Gradual Adoption
@Configuration
@Profile("virtual-threads")
public class VirtualThreadProfile {
@Bean
public TomcatProtocolHandlerCustomizer<?> protocolHandlerCustomizer() {
return protocolHandler -> {
protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
};
}
}
// Run with: --spring.profiles.active=virtual-threads
// Compare performance against the default profile8. Performance Benchmarks
┌──────────────────────────────────────────────────────────────────┐
│ Benchmark: REST API with 3 downstream HTTP calls │
│ + 1 database query per request │
│ │
│ Concurrent Platform Threads Virtual Threads Reactive │
│ Requests (200 pool) (unlimited) (WebFlux) │
│ ───────────── ───────────────── ──────────────── ──────────── │
│ 100 45 ms P99 42 ms P99 40 ms P99 │
│ 500 120 ms P99 55 ms P99 50 ms P99 │
│ 1,000 350 ms P99 65 ms P99 58 ms P99 │
│ 2,000 1200 ms P99* 80 ms P99 62 ms P99 │
│ 5,000 TIMEOUT* 120 ms P99 85 ms P99 │
│ 10,000 TIMEOUT* 180 ms P99 110 ms P99 │
│ │
│ * Platform threads hit pool exhaustion at ~200 concurrent │
│ │
│ Memory at 350 MB 120 MB 100 MB │
│ 5000 conc. (OOM risk) (stable) (stable) │
│ │
│ Throughput ~200 req/s ~5000 req/s ~6000 req/s │
│ (sustained) (pool-limited) (I/O-limited) (I/O-lim.) │
└──────────────────────────────────────────────────────────────────┘
Note: Numbers are illustrative. Actual performance depends on
downstream service latency, database query time, hardware, etc.9. Reactive vs Virtual Threads Decision
┌────────────────────────┬──────────────────────┬──────────────────────┐
│ Factor │ Virtual Threads │ Reactive (WebFlux) │
├────────────────────────┼──────────────────────┼──────────────────────┤
│ Programming Model │ Imperative (simple) │ Functional (complex) │
│ Learning Curve │ Low (familiar Java) │ High (Reactor API) │
│ Debugging │ Normal stack traces │ Complex async traces │
│ Existing Code │ Mostly compatible │ Complete rewrite │
│ Blocking Libraries │ Work naturally │ Must wrap/avoid │
│ JDBC/JPA │ Works as-is │ Needs R2DBC │
│ Throughput (I/O) │ Very good │ Excellent │
│ Memory Efficiency │ Good │ Best │
│ Streaming/SSE │ Supported │ Native strength │
│ Backpressure │ Manual │ Built-in │
│ Ecosystem Maturity │ Growing (Java 21+) │ Mature │
│ Testing │ Standard JUnit │ StepVerifier needed │
│ Exception Handling │ try/catch │ onErrorResume chains │
│ Team Adoption │ Easy │ Takes months │
├────────────────────────┼──────────────────────┼──────────────────────┤
│ Choose When │ I/O-bound CRUD apps, │ Streaming, extreme │
│ │ migrating existing │ throughput, already │
│ │ blocking code, │ reactive codebase, │
│ │ team prefers simple │ backpressure needed │
│ │ imperative code │ │
└────────────────────────┴──────────────────────┴──────────────────────┘
Recommendation for new projects (2025+):
- DEFAULT: Virtual threads + Spring MVC (simple, performant, familiar)
- STREAMING: WebFlux (SSE, WebSocket, real-time data)
- GATEWAY: WebFlux or Spring Cloud Gateway (non-blocking proxying)
- EXTREME SCALE: WebFlux (backpressure, sub-ms latency requirements)10. Production Checklist
/**
* Virtual Threads Production Checklist:
*
* 1. JAVA VERSION: Ensure Java 21+ in all environments
*
* 2. DEPENDENCIES: Update all JDBC drivers, connection pools,
* and HTTP clients to versions that avoid pinning
*
* 3. SYNCHRONIZED: Audit codebase for synchronized blocks
* containing I/O operations. Replace with ReentrantLock
* or concurrent collections
*
* 4. THREAD-LOCALS: Audit ThreadLocal usage. Each virtual thread
* gets its own copy — millions of copies = memory pressure.
* Consider ScopedValue or shared state
*
* 5. THREAD POOL SIZING: Remove tomcat.threads.max tuning.
* Keep connection pool limits (HikariCP max-pool-size) — those
* protect the database, not the JVM
*
* 6. MONITORING: Add virtual thread count metrics. Monitor
* carrier pool utilization. Watch for pinning events
*
* 7. CONNECTION POOLS: Size database connection pools based on
* DATABASE capacity, not thread count. With virtual threads
* you can have 10,000 requests but only 50 DB connections
*
* 8. LOAD TESTING: Run load tests comparing platform vs virtual
* threads with your actual workload profile
*
* 9. PINNING DETECTION: Run with -Djdk.tracePinnedThreads=short
* during testing to find pinning issues
*
* 10. GRADUAL ROLLOUT: Enable via Spring profile, roll out to
* a canary environment first, monitor for regressions
*/Virtual threads are the most significant Java concurrency improvement in a decade. For the vast majority of Spring Boot applications — those doing CRUD operations, calling downstream services, and querying databases — enabling spring.threads.virtual.enabled=true on Java 21+ delivers dramatic scalability improvements with zero code changes. The imperative programming model stays intact, debugging remains straightforward, and existing blocking libraries work naturally. Reserve reactive/WebFlux for streaming scenarios and extreme throughput requirements where backpressure is essential.