Java 21 · Project Loom · Spring Boot 3.2+

Java Virtual Threads
— Complete Guide

The biggest concurrency change in Java's 25-year history. Carrier threads, mount/unmount, Continuation objects, Spring Boot integration, thread pinning, Structured Concurrency, and 8 senior interview Q&As.

Java 21 LTS Project Loom Spring Boot 3.2+ Tomcat / Netty Thread Pinning Interview Ready
CHAPTER 1

Platform Threads Don't Scale

🧵

Platform Thread Cost

  • Each thread = ~1MB OS stack memory (native, not heap)
  • OS allocates it at thread creation — never GC'd until thread exits
  • OS context switching between threads is expensive
  • Tomcat default pool: 200 threads = 200MB OS memory locked
  • Thread blocked on I/O = wasted resource, doing nothing
📉

The Real-World Problem

  • REST API calls a database — 20ms wait
  • Thread is BLOCKED for that 20ms
  • Can't serve other requests during the wait
  • Under load: thread pool exhausted at 200 requests
  • Result: timeouts, 503 errors, latency spikes
The Core Problem A thread blocking on I/O holds an OS thread doing absolutely nothing. That OS thread costs 1MB of native memory that can never be GC'd. 200 threads = 200MB locked in OS memory just sleeping.

Platform Threads (200 max) — serving 200 concurrent API calls

thread-1
RUN
thread-2
BLOCKED — waiting for DB response (20ms) 😴
thread-3
BLOCKED — waiting for HTTP response (150ms) 😴
thread-4
BLOCKED — waiting for Kafka poll 😴
threads 5–200
ALL BLOCKED — 196 threads burning 196MB of OS memory doing nothing 💸

Virtual Threads — millions available, carriers always busy

carrier-1
ALWAYS RUNNING — mounts any unblocked virtual thread
carrier-2
ALWAYS RUNNING — mounts any unblocked virtual thread
Thread pool exhausted → request 201 waits → timeout → 503 Service Unavailable
~1 MB
Platform thread OS stack
~2 KB
Virtual thread heap stack
200
Tomcat default threads
millions
Virtual threads possible
123×
Less memory at 1M tasks
Memory Math At 1 million concurrent tasks: platform threads need ~1 TB of OS stack — impossible. Virtual threads need ~2 GB of heap — totally feasible. That's the 123× difference in action.
CHAPTER 2

How Virtual Threads Work

🎯

Carrier Threads

Small pool of OS "carrier" threads — one per CPU core via ForkJoinPool (work-stealing). Virtual threads mount onto carriers to run. The OS only sees carriers — never the virtual threads.

🔄

Mount / Unmount

When a virtual thread hits a blocking call (DB, HTTP, sleep), the JVM unmounts it from the carrier. The carrier immediately picks up another virtual thread. Zero wasted OS time.

💾

Heap-Stored Stack

Virtual thread stacks live on the Java heap — not OS memory. Start at ~1–2 KB, grow dynamically. Eligible for GC when done. Invisible to the OS scheduler.

The Continuation Object — The Core Mechanism When a virtual thread unmounts during blocking I/O, the JVM serialises its execution state into a Continuation object on the heap. This Continuation holds: full stack snapshot with all call frames, local variable values at each frame, and the exact instruction pointer to resume from. On I/O completion, this Continuation is remounted onto any available carrier thread — execution continues exactly where it left off, possibly on a different carrier.
🚕
Analogy: Carrier thread = taxi driver. Virtual thread = passenger. When passenger says "wait, I need to go to the ATM" — the taxi drops them off and picks up the next passenger immediately. When the ATM is done, the passenger calls a new taxi. The taxi is never idle.
📦
Continuation object = the "saved game state." Stack frames + local variables + instruction pointer, all serialised to heap as a plain Java object. This is also why synchronized breaks it — the monitor lock is tied to the carrier OS thread, the Continuation can't be detached while a monitor is held.
🧠
Key distinction: Platform thread stack = OS native memory (not heap, never GC'd until thread exits). Virtual thread stack = Java heap object (GC'd when done). This is why the 123× memory saving is real and not a rounding error.
⚙️
ForkJoinPool scheduler: A work-stealing scheduler separate from the common pool, ~1 carrier per CPU core. Maintains a queue of runnable virtual threads, mounts them FIFO. Virtual threads are completely invisible to the OS scheduler — the OS only ever sees the small carrier pool.

Mount / Unmount Lifecycle — HTTP request with DB call

Client
HTTP Request
GET /api/orders
Request arrives → Tomcat creates a new virtual thread per request. No thread pool. No queuing. Instant.
Virtual Thread
Mounted
on carrier-1
Virtual thread mounts onto carrier thread-1. Runs the controller code — executes fast synchronous logic.
DB Call
Blocking I/O
orderRepo.findById()
Thread hits a blocking operation (JDBC call). JVM detects this. Virtual thread unmounts from carrier-1. Stack saved to heap as Continuation object.
Carrier Thread-1
Now Free!
picks up another VT
Carrier-1 is immediately free to pick up another virtual thread. Could be any of thousands of other requests. Zero waste.
DB responds
I/O Complete
data ready
Database returns data. Virtual thread is scheduled for remounting on any available carrier (not necessarily carrier-1).
Response
200 OK
JSON returned
Virtual thread completes, returns response, and is garbage collected. Synchronous-looking code — no callbacks, no Mono/Flux. ✅
Mount/Unmount cycle
Request arrives New VT created Mounts on carrier I/O: unmounts Carrier free for others I/O done: remounts Response + GC'd
CORE API

Creating Virtual Threads — 3 Ways

VirtualThreadBasics.java
Java 21
1// ── Way 1: Thread.ofVirtual() ─────────────────────────────────────────
2Thread vt = Thread.ofVirtual().name("my-vt").start(() -> {
3 System.out.println("Running on: " + Thread.currentThread());
4 System.out.println("Is virtual: " + Thread.currentThread().isVirtual()); // true
5});
6vt.join(); // wait for it to complete
7
8// ── Way 2: Thread.startVirtualThread() shorthand ──────────────────────
9Thread.startVirtualThread(() -> fetchDataFromDB());
10// Good for fire-and-forget tasks — no pool, no overhead
11
12// ── Way 3: newVirtualThreadPerTaskExecutor (PREFERRED in production) ──
13try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
14 Future<Order> future = exec.submit(() -> orderRepo.findById(id));
15 Order order = future.get(); // blocks VT, not the carrier thread
16} // executor auto-closed, all VTs complete
17
18// ── Spawn 100,000 virtual threads — totally fine ──────────────────────
19try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
20 IntStream.range(0, 100_000).forEach(i ->
21 exec.submit(() -> processRequest(i))
22 );
23} // try-with-resources waits for all 100,000 tasks — completes in ms
24// With platform threads: OutOfMemoryError (~100GB of stack memory). With VTs: ~200MB heap.
Key Points
  • Thread.ofVirtual().start() — creates and immediately starts a virtual thread. Name is optional but useful for debugging stack traces.
  • isVirtual() — new in Java 21. Confirm a thread is virtual in logs: Thread.currentThread().isVirtual()
  • newVirtualThreadPerTaskExecutor() — creates a new virtual thread per submitted task. This is the preferred executor for production. Do NOT use a fixed pool.
  • Anti-pattern: Executors.newFixedThreadPool(100, Thread.ofVirtual().factory()) — pooling virtual threads defeats their purpose. They're nearly free to create; pooling adds complexity for zero benefit.
  • Spawning 100,000 virtual threads is normal and expected. Each uses only a few KB of heap. This would crash with platform threads (~100GB of stack memory needed).
SPRING BOOT

Enable Virtual Threads — Spring Boot 3.2+

application.yml
YAML
1# Spring Boot 3.2+ — one property enables VTs for Tomcat, @Async, @Scheduled
2spring:
3 threads:
4 virtual:
5 enabled: true # That's it. Tomcat now uses a virtual thread per HTTP request.
6
7 datasource:
8 hikari:
9 maximum-pool-size: 50 # Tune for DB capacity, NOT your old thread count
VirtualThreadConfig.java — for Spring Boot 3.0 / 3.1
Java 17+
1@Configuration
2public class VirtualThreadConfig {
3
4 // For Spring Boot 3.0 / 3.1 — configure Tomcat manually
5 @Bean
6 public TomcatProtocolHandlerCustomizer<?> virtualThreadCustomizer() {
7 return ph -> ph.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
8 }
9
10 // @Async — explicitly use virtual thread executor
11 @Bean(name = "virtualThreadTaskExecutor")
12 public Executor asyncExecutor() {
13 return Executors.newVirtualThreadPerTaskExecutor();
14 }
15}
OrderController.java — verify it's running on a virtual thread
Java 21
1@GetMapping("/api/orders/{id}")
2public Order getOrder(@PathVariable Long id) {
3 log.info("thread={} isVirtual={}",
4 Thread.currentThread().getName(),
5 Thread.currentThread().isVirtual()); // should print: true
6 return orderService.findById(id); // normal sync code — zero changes needed
7}
Key Points
  • Spring Boot 3.2 — spring.threads.virtual.enabled=true configures Tomcat, Spring's task executor, and @Scheduled tasks all at once. One line.
  • Your existing @RestController, @Service, @Repository code needs zero changes. Write normal synchronous Spring Boot code.
  • HikariCP gotcha: Default pool = 10 connections. With VTs you can now have thousands of concurrent requests — all wanting a DB connection. If pool stays at 10–200, threads will wait on the pool, not on the DB. Set maximum-pool-size based on your actual database capacity (e.g. 50 for PostgreSQL).
  • HikariCP 5.1.0+ is virtual-thread aware. Spring Boot 3.2 auto-configures pool size correctly when VTs are enabled.
JAVA 21

Structured Concurrency — Parallel Calls Made Simple

OrderService.java — old vs new parallel fetch
Java 21
1// ❌ OLD WAY — CompletableFuture with platform thread pool
2CompletableFuture<Order> orderF = CompletableFuture.supplyAsync(() -> orderRepo.find(id));
3CompletableFuture<User> userF = CompletableFuture.supplyAsync(() -> userRepo.find(uid));
4CompletableFuture.allOf(orderF, userF).join(); // error handling is painful
5return new OrderResponse(orderF.get(), userF.get());
6
7// ✅ BETTER — CompletableFuture with VT executor
8var vtExec = Executors.newVirtualThreadPerTaskExecutor();
9CompletableFuture.supplyAsync(() -> orderRepo.find(id), vtExec); // each stage on a VT
10
11// ✅ BEST — Structured Concurrency (Java 21)
12try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
13 Subtask<Order> order = scope.fork(() -> orderRepo.findById(id));
14 Subtask<User> user = scope.fork(() -> userRepo.findById(userId));
15 scope.join().throwIfFailed(); // clean error handling — no ExecutionException wrapping
16 return new OrderResponse(order.get(), user.get()); // both results available
17}
Key Points
  • scope.fork() — spawns a virtual thread for each task. Both run in parallel. Calling thread waits at scope.join() without blocking a carrier thread.
  • ShutdownOnFailure — if either subtask fails, the scope cancels both automatically. No orphaned threads. Far cleaner than CompletableFuture exception chaining.
  • ShutdownOnSuccess — cancels all remaining forks as soon as the first one succeeds. Classic use case: query multiple pricing vendors simultaneously and return the fastest/cheapest result.
  • This replaces CompletableFuture.allOf() patterns with readable synchronous-style code. No callbacks, no thenCompose, no thenApply chains. Old = ~15 lines. New = 6 lines.
  • CompletableFuture is not deprecated — passing a VT executor to supplyAsync() is still valuable. Every thenApply/thenCompose stage runs on its own VT. Blocking in any stage no longer holds a platform thread.
PITFALL

Thread Pinning — The Hidden Killer

What is Thread Pinning? When a virtual thread is pinned, it cannot unmount from its carrier thread while blocking. The carrier thread is then stuck — defeating the entire purpose of virtual threads. This can silently destroy performance without any obvious error.
📌

When Does Pinning Occur?

  • Blocking inside a synchronized block or method
  • Native method calls (JNI)
  • Foreign function calls

Java's monitor locks (synchronized) are fundamentally tied to the carrier OS thread. The JVM cannot detach the Continuation while a monitor is held.

How to Fix Pinning

  • Replace synchronized with ReentrantLock
  • ReentrantLock uses a mechanism that is virtual-thread aware
  • HikariCP 5.1+, Lettuce 6.3+, Kafka 3.6+ already use ReentrantLock internally
  • Audit only your own code for synchronized
PinningExample.java — ❌ DON'T DO THIS
Java 21
1// ❌ ANTI-PATTERN 1 — synchronized method with blocking I/O inside
2public synchronized Order getOrder(Long id) { // ← synchronized on method
3 return orderRepository.findById(id).orElseThrow(); // ← blocks inside synchronized = PINNED
4}
5// result: virtual thread CANNOT unmount → carrier blocked → same as platform thread
6
7// ❌ ANTI-PATTERN 2 — synchronized block with blocking I/O inside
8private final Object lock = new Object();
9public Order getOrderV2(Long id) {
10 synchronized (lock) { // ← synchronized block
11 return orderRepository.findById(id).orElseThrow(); // ← blocks inside = PINNED
12 }
13}
14
15// Detect pinning — add this JVM flag to your run config:
16// -Djdk.tracePinnedThreads=full
17// Prints full stack trace every time a virtual thread pins to its carrier
PinningFixed.java — ✅ Production safe
Java 21
1// ✅ FIX — replace synchronized with ReentrantLock
2private final ReentrantLock lock = new ReentrantLock();
3
4public Order getOrder(Long id) {
5 lock.lock(); // ← ReentrantLock supports virtual thread unmounting while waiting
6 try {
7 return orderRepository.findById(id).orElseThrow(); // ← safe to unmount here
8 } finally {
9 lock.unlock(); // always release in finally
10 }
11}
12
13// ✅ Also fine — tryLock with timeout
14if (lock.tryLock(1, TimeUnit.SECONDS)) {
15 try { doWork(); } finally { lock.unlock(); }
16}
Key Points
  • ReentrantLock is virtual-thread-aware. When a virtual thread blocks on lock.lock(), the JVM can unmount it from the carrier while it waits for the lock.
  • synchronized is an intrinsic lock tied to the carrier OS thread. This is a fundamental JVM constraint — not fixable by libraries. Replace with ReentrantLock.
  • Most modern libraries have already replaced internal synchronized with ReentrantLock — HikariCP 5.1+, Lettuce 6.3+, Kafka client 3.6+, Spring JDBC (Java 21), OkHttp 5+. Audit only your own code.
❌ PINNED — synchronized block
Virtual Thread
Enters synchronized
Acquires synchronized lock
Blocking I/O inside
PINNED 📌
Cannot unmount! Carrier thread is blocked. Defeats virtual thread purpose.
Carrier Thread
Also blocked
OS thread wasted. Same as platform thread behaviour. 💸
✅ SAFE — ReentrantLock
Virtual Thread
Acquires Lock
Uses ReentrantLock.lock()
Blocking I/O inside
Unmounts ✅
Unmounts cleanly! Carrier thread is freed for other work.
Carrier Thread
Free for others
Full virtual thread benefit preserved. ✅
Detect pinning at runtime
JVM flag: -Djdk.tracePinnedThreads=full — logs a stack trace every time a virtual thread is pinned. Add to run config during development.
Detect in production (JFR)
JFR event: jdk.VirtualThreadPinned — non-intrusive, can be enabled at runtime with JFR streaming. Zero overhead when no pinning.
IMPORTANT

When NOT to Use Virtual Threads

ScenarioUse Virtual Threads?Why
I/O-bound workloads✅ Yes — idealDB calls, REST calls, Kafka — blocking releases the carrier thread
High-concurrency APIs✅ Yes — great fitHandle millions of simultaneous requests without reactive code
CPU-bound workloads⚠️ No benefitImage processing, crypto, ML inference — CPU never blocks, no gain. Use bounded platform thread pool sized to CPU cores.
synchronized + blocking I/O❌ Risk of pinningThread pinning eliminates the benefit and can degrade performance below platform threads. Fix first.
Pooled virtual threads⚠️ Anti-patternDon't use newFixedThreadPool with VT factory. VTs are cheap to create — pooling adds overhead for zero benefit. Use newVirtualThreadPerTaskExecutor().
ThreadLocal caching❌ Memory leak riskPatterns that cache objects per-thread (SimpleDateFormat, DB connections) create one cache per VT. With millions of VTs — millions of cached objects, never reused. Use proper object pooling instead.
Decision Framework I/O-bound? Enable VTs. CPU-bound? Platform thread pool sized to cores. synchronized around I/O? Fix it first. Pooling VTs? Stop. ThreadLocal caching? Replace with proper pool.
COMPARISON

Virtual Threads vs Reactive (WebFlux)

⚛️

Reactive — WebFlux

  • Async, non-blocking all the way down
  • Complex — Mono, Flux, back-pressure
  • Steep learning curve, hard to debug
  • Stack traces fragmented across thread boundaries
  • Best for streaming, very high event-driven throughput
  • Still better for protocol-level streaming efficiency
🧵

Virtual Threads — Loom

  • Synchronous code — easy to read and debug
  • No Mono/Flux — normal Java style
  • Complete stack traces — easy to debug in production
  • Handles I/O-bound load at massive scale
  • Drop-in for any Spring MVC application
  • Same performance as reactive for most microservice use cases
Verdict for Most Spring Boot Applications For most Spring Boot microservices doing I/O-bound work — virtual threads replace the need for WebFlux while keeping simple synchronous code and readable stack traces. Reactive is still valuable for streaming, backpressure, and protocol-level async patterns where the event-driven model is intrinsic to the domain.
AspectPlatform ThreadsVirtual ThreadsReactive (WebFlux)
Concurrency model1 thread per request1 virtual thread per requestAsync callbacks (Mono)
Max concurrency~200 (Tomcat default)MillionsMillions
Code styleSynchronous (simple)Synchronous (simple) ✅Async/functional (complex)
Migration effortBaseline (no change)1 line in yml ✅Full rewrite required
Debug stack tracesFull traceFull trace ✅Fragmented across threads
Best forLow-medium concurrencyI/O-bound microservices ✅Streaming / back-pressure
FLOW

HTTP Request Lifecycle With Virtual Threads

Client
GET /api/orders/1
HTTP request arrives at Tomcat. No thread pool queue — a new virtual thread is created immediately for every request.
Virtual Thread
Mounted on carrier
carrier thread-N
Virtual thread mounts onto a carrier thread. Executes OrderController.getOrder() synchronously.
Spring Security
Filter Chain
JWT validation
Passes through all security filters. JwtAuthFilter validates token — fast CPU operation, no blocking, no unmounting.
Controller → Service
OrderController
calls service layer
Calls OrderService.findById() — normal synchronous call. No reactive types needed.
DB Query
JDBC blocks here
orderRepo.findById()
JDBC call blocks. JVM detects blocking → unmounts virtual thread from carrier. Carrier immediately picks up another request. This thread's stack is saved to heap as Continuation.
DB Response
Data returned
I/O complete
DB responds. Virtual thread is rescheduled for remounting on any available carrier thread.
Response
200 OK
JSON returned
Response sent. Virtual thread is garbage collected. Zero wasted resources. ✅
Virtual thread request chain
HTTP arrives New VT created Security filters Controller → Service DB: VT unmounts DB done: remounts 200 OK + GC'd
What if pinning occurs in this chain?
If any service class uses synchronized around the DB call, the carrier thread blocks. Detect with -Djdk.tracePinnedThreads=full.
Is HikariCP safe?
Yes — HikariCP 5.1.0+ is virtual-thread aware. Spring Boot 3.2 auto-configures the connection pool correctly. Tune maximum-pool-size based on DB capacity.
INTERVIEW

Senior-Level Interview Q&A — 8 Questions

Q1: What is the memory difference between platform threads and virtual threads?
Platform threads have a fixed ~1MB OS stack reserved at creation in native memory — never GC'd until the thread exits. Virtual threads have a heap-allocated stack starting at ~1–2KB, growing dynamically, and eligible for GC when done. At 1 million concurrent tasks: platform threads need ~1 TB of OS stack — impossible. Virtual threads need ~2 GB of heap — totally feasible. That's the 123× memory difference.
Q2: What is a Continuation object and what does it contain?
When a virtual thread unmounts from its carrier during blocking I/O, the JVM serialises its execution state into a Continuation object on the heap. It contains: full stack snapshot with all call frames, local variable values at each frame, and the exact instruction pointer to resume from. On I/O completion, this Continuation is remounted onto any available carrier thread — execution continues exactly where it left off, possibly on a different carrier than before.
Q3: What is thread pinning, what causes it, and how do you detect and fix it?
Pinning = a virtual thread cannot create a Continuation and unmount from its carrier. Caused by: synchronized blocks/methods with blocking I/O inside, or JNI native method calls. Detect: -Djdk.tracePinnedThreads=full JVM flag prints a stack trace every time a VT is pinned; or use JFR event jdk.VirtualThreadPinned in production. Fix: replace synchronized with ReentrantLock — it uses a different locking mechanism that supports unmounting while waiting.
Q4: How does the JVM scheduler work for virtual threads?
A work-stealing ForkJoinPool — separate from the common pool, approximately one carrier thread per CPU core — maintains a queue of runnable virtual threads. It mounts them onto available carriers in FIFO order. Virtual threads are completely invisible to the OS scheduler — the OS only ever sees the small carrier thread pool. When a virtual thread blocks, the Continuation is saved to heap and the carrier is freed immediately to pick up the next runnable virtual thread.
Q5: How does CompletableFuture benefit from virtual threads?
By providing Executors.newVirtualThreadPerTaskExecutor() as the executor to supplyAsync(), every stage of the future chain runs on a virtual thread. When any stage blocks on I/O, the carrier is freed instead of being held. Additionally, Java 21's StructuredTaskScope replaces complex CompletableFuture chains with readable synchronous-style parallel code — fork two VTs, join them, get both results. throwIfFailed() gives clean error propagation instead of ExecutionException wrapping.
Q6: Should you pool virtual threads? Why not?
No — this is a common anti-pattern. Thread pools were invented to amortise the high creation cost of platform threads (OS syscall + stack allocation). Virtual threads have no meaningful creation cost — just a heap allocation of ~1–2KB with no OS syscall. Pooling adds scheduling complexity for zero benefit and also breaks the mental model. Use Executors.newVirtualThreadPerTaskExecutor() — creates a fresh VT per task, then lets it be GC'd.
Q7: Virtual threads vs reactive WebFlux — when to choose which?
Virtual threads: synchronous code, complete stack traces, zero migration from Spring MVC, ideal for I/O-bound microservices. Reactive: still valuable for streaming, backpressure, protocol-level async patterns where the event-driven model is intrinsic to the domain. For most enterprise Spring Boot APIs doing request/response with DB and HTTP calls — virtual threads achieve the same throughput as WebFlux while keeping simpler, more debuggable code.
Q8: What is StructuredTaskScope.ShutdownOnSuccess used for?
When you want the first successful result and to cancel all remaining forks immediately. Classic use case: querying multiple pricing vendors simultaneously and returning the cheapest/fastest response — as soon as one fork returns a valid result, all others are cancelled, saving resources and latency. Compare to ShutdownOnFailure — which cancels all forks if any one of them throws an exception. The two modes together cover the most common parallel fork patterns cleanly.
REFERENCE

Quick Reference — Numbers, Flags & Anti-Patterns

Key Numbers to Know Platform thread stack: ~1 MB (OS native memory) · Virtual thread stack: ~1–2 KB (Java heap) · Memory ratio: 123× · Carrier threads: ~1 per CPU core · VT creation: ~10× faster than platform thread · 1M VTs heap usage: ~2 GB · 1M platform threads: ~1 TB OS stack (impossible)
⚙️
JVM Flags:
-Djdk.tracePinnedThreads=full — detect thread pinning (dev/test)
-Djdk.virtualThreadScheduler.parallelism=N — set carrier thread count
jcmd <PID> Thread.dump_to_file -format=json — thread dump with VTs
Safe Libraries (No Pinning): HikariCP 5.1+, Lettuce 6.3+, Kafka client 3.6+, Spring JDBC (Java 21), OkHttp 5+, Netty 4.2+. These have all replaced internal synchronized with ReentrantLock.
3 Anti-Patterns:
1. Executors.newFixedThreadPool(N, Thread.ofVirtual().factory()) — pooling VTs
2. synchronized + blocking I/O = thread pinning
3. ThreadLocal caching patterns — caches never reused with VTs, memory leak risk
🔄
CompletableFuture Upgrade Path:
Old: CompletableFuture.supplyAsync(() -> ...) — uses default ForkJoinPool (platform threads)
Better: supplyAsync(() -> ..., Executors.newVirtualThreadPerTaskExecutor())
Best: StructuredTaskScope.ShutdownOnFailure — parallel forks, clean error handling
🌱
Spring Boot Complete Config:
spring.threads.virtual.enabled: true — enables Tomcat, @Async, @Scheduled
spring.datasource.hikari.maximum-pool-size: 50 — tune for DB capacity (not thread count)
📋
Decision Checklist: I/O-bound? → Enable VTs. CPU-bound? → Platform thread pool sized to CPU cores. Has synchronized + I/O? → Fix pinning first. Pooling VTs? → Stop. ThreadLocal caching? → Replace with proper pool.