π§΅ Virtual Threads (Project Loom)
Introduced as a stable feature in Java 21, Virtual Threads (Project Loom) completely revolutionize the Java concurrency model. They solve the fundamental physical bottleneck of Tomcat and Spring Boot servers: the "Thread-Per-Request" model.
π― Why Should I Care?β
For Beginners: The Scalability Wallβ
Every Java web application hits this wall eventually:
Concurrent Users: 100 500 1,000 5,000 10,000
Response Time: 50ms 55ms 80ms 2,000ms π₯ Connection refused
Why does performance collapse at scale? Because traditional Java servers run one thread per request, and threads are expensive. You typically cap at ~200 threads. User #201 waits. User #500 gets a timeout. User #10,000 gets an error page.
Virtual threads remove this ceiling. You can handle millions of concurrent requests without changing your coding style.
For Intermediate Developers: The Best of Both Worldsβ
Before virtual threads, you had two bad options:
| Approach | Pros | Cons |
|---|---|---|
| Thread-per-request (Spring MVC) | Simple, imperative code; debuggable stack traces | Limited to ~200 concurrent requests |
| Reactive (WebFlux / Project Reactor) | Handles 100K+ concurrent connections | Complex callback chains; unreadable stack traces; steep learning curve |
Virtual threads give you both: simple imperative code + massive concurrency. Write blocking code that magically doesn't block.
For Senior Engineers: A Paradigm Shiftβ
Virtual threads change fundamental architecture decisions:
- Thread pools become obsolete for I/O-bound work
- Reactive frameworks (WebFlux, RxJava) lose their primary advantage
- Connection pool sizing becomes the new bottleneck (not threads)
synchronizedkeyword becomes a potential disaster (pinning)ThreadLocalbecomes a potential memory bomb
Understanding these implications is critical before flipping spring.threads.virtual.enabled=true in production.
1. The Bottleneck: Platform Threadsβ
For the last 25 years, every time you called new Thread(), Java created a Platform Thread. A Platform Thread is a 1-to-1 wrapper around an Operating System (OS) thread.
The Problemβ
OS threads are incredibly heavy.
- They require ~1MB of RAM just for their call stack. If you create 10,000 threads, you've instantly burned 10GB of RAM just for idle stacks.
- Generating a thread involves trapping the OS kernel (a massive CPU penalty).
- Switching between threads (Context Switching) takes 1-5 microseconds.
Because of this, traditional servers use Thread Pools capped at around 200 threads. If 201 users try to download a slow file from your server at the same time, the 201st user hangs forever until one of the 200 threads finishes its download.
The Numbersβ
| Resource | Platform Thread | Virtual Thread |
|---|---|---|
| Memory | ~1MB per thread (fixed stack) | ~1KB initially, grows as needed |
| Creation cost | ~1ms (OS kernel call) | ~1ΞΌs (JVM-managed) |
| Context switch | 1β5ΞΌs (OS scheduler) | ~200ns (JVM scheduler) |
| Max count | ~5,000β10,000 (limited by RAM) | ~1,000,000+ (limited by heap) |
| Scheduling | OS kernel | JVM ForkJoinPool |
2. What is a Virtual Thread?β
Virtual threads are managed entirely by the Java Virtual Machine (JVM), not the OS. They are extraordinarily cheap to create, consume only bytes of memory, and you can comfortably create millions of them on a standard laptop.
πΆ Beginner Concept: The "Call Center Operator"β
Imagine a giant customer service Call Center.
- Platform Threads: You hire 200 employees (OS Threads). Every time the phone rings, an employee picks up. The customer says, "Hold on, let me find my credit card." The employee is forced to hold the phone to their ear in total silence for 5 minutes (Blocking I/O). Meanwhile, 10,000 other customers are getting a busy signal because all 200 employees are waiting on hold.
- Virtual Threads: You still have 200 employees (Carrier Threads), but now they have millions of active phone lines (Virtual Threads). When a customer says, "Hold on," the employee instantly puts that line on hold (Unmounting) and immediately answers the next ringing phone. When the first customer finally finds their credit card, the employee grabs that line back (Mounting) and continues the transaction.
Nobody ever gets a busy signal!
Creating Virtual Threadsβ
// Method 1: Direct creation
Thread vThread = Thread.ofVirtual().name("my-vthread").start(() -> {
System.out.println("Running on: " + Thread.currentThread());
});
// Method 2: Factory
ThreadFactory factory = Thread.ofVirtual().name("worker-", 0).factory();
Thread t = factory.newThread(() -> doWork());
t.start();
// Method 3: ExecutorService (most common in Spring)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> handleRequest(request));
}
// Method 4: Spring Boot (simplest!)
// application.properties:
// spring.threads.virtual.enabled=true
// That's it β every request handler now runs on a virtual thread.
3. How Virtual Threads Work (Under the Hood)β
Virtual threads decouple the Java thread from the OS thread.
- The Carrier Thread: The JVM maintains a small underlying ForkJoinPool of standard OS Platform Threads (usually equal to the number of CPU cores).
- Mounting: When you execute a Virtual Thread, the JVM mounts it onto an available Carrier Thread.
- Unmounting (The Magic): When your Virtual Thread makes a blocking call (e.g.,
Thread.sleep(),HttpClient.send(),JDBC query), the JVM detects this. It instantly copies the Virtual Thread's call stack into the Java Heap memory (a Continuation) and unmounts it. - Re-mounting: The Carrier Thread is now completely free to execute another Virtual Thread. When the original database query returns its payload, the JVM grabs the Continuation from the Heap, mounts it back onto any available Carrier Thread, and resumes execution identically.
// Create 100,000 Virtual Threads effortlessly
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 100_000).forEach(i -> {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // Instantly unmounts! Doesn't block an OS thread.
return i;
});
});
} // Finishes in exactly 1 second, not pooling!
π The Mount/Unmount Lifecycle (Visual)β
Timeline for a single HTTP request:
Virtual Thread #42:
βββmountedβββ βββmountedβββ βββmountedβββ
β parse β β process β β serialize β
β request β β result β β response β
ββββββ¬βββββββ ββββββ¬βββββββ ββββββ¬βββββββ
β β β
βΌ DB query (blocking I/O) βΌ HTTP call (blocking I/O) βΌ done
ββββunmountedββββββββββββββββ ββββunmountedββββββββββββ
Carrier Thread (OS Thread):
ββVT#42ββββVT#99ββββVT#7βββββVT#42ββββVT#55ββββVT#42ββ
βparse ββprocess ββformat ββprocess ββquery ββserial.β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
The carrier thread is NEVER idle! While VT#42 waits for the database,
the carrier serves VT#99, VT#7, VT#55, etc.
Stack Storage: Heap Continuationsβ
When a virtual thread unmounts, its stack frames are stored as a continuation on the heap:
Platform Thread stack: Virtual Thread "stack":
βββββββββββββββ ββββββββββββββββββββββββββββ
β Fixed 1MB β β Continuation (on heap) β
β OS-managed β β βββ Frame 1 (~100 bytes) β
β contiguous β β βββ Frame 2 (~200 bytes) β
β block β β βββ Frame 3 (~150 bytes) β
β β β Total: ~450 bytes β
β (mostly β β Grows/shrinks dynamically β
β unused) β β Garbage-collectible! β
βββββββββββββββ ββββββββββββββββββββββββββββ
This is why virtual threads can exist in millions β each one is just a small object on the heap, not a 1MB OS allocation.
4. Senior Deep Dive: The Dangers of "Pinning"β
Virtual threads are not magic. They have one catastrophic weakness that Senior Engineers must audit their codebase for before migrating to Java 21: Thread Pinning.
What is Pinning?β
When a Virtual Thread executes inside a synchronized block or method, the JVM physically cannot unmount it. The Virtual Thread literally pins the underlying OS Carrier Thread to itself.
If that synchronized block makes a slow database call, the Carrier Thread is blocked. If you only have 8 CPU cores (8 Carrier Threads), and 8 Virtual Threads enter synchronized DB queries, your entire JVM grinds to an absolute halt. You have 0 available Carrier Threads left to execute the other 99,992 Virtual Threads in your queue.
The Fix: ReentrantLockβ
ReentrantLock uses AQS and LockSupport.park(), which the JVM fully understands. A Virtual Thread blocked on a ReentrantLock naturally unmounts.
// β DANGEROUS inside Virtual Threads
public synchronized void fetchFromDatabase() {
// Carrier thread is pinned and DEAD while the DB responds!
var data = db.query("SELECT SLEEP(10)");
}
// β
SAFE inside Virtual Threads
private final ReentrantLock lock = new ReentrantLock();
public void fetchFromDatabase() {
lock.lock();
try {
// Virtual thread unmounts! Carrier thread is free to work!
var data = db.query("SELECT SLEEP(10)");
} finally {
lock.unlock();
}
}
Note: The OpenJDK team is actively working on changing the JVM's C++ monitor implementation so
synchronizedno longer pins threads, but as of Java 21, you must migrate toReentrantLock.
π How to Detect Pinningβ
# JVM flag to log pinning events
-Djdk.tracePinnedThreads=full # full stack trace
-Djdk.tracePinnedThreads=short # summary only
Output looks like:
Thread[#42,VirtualThread[#42]/runnable@ForkJoinPool-1-worker-3,5,CarrierThreads]
com.app.UserRepository.findById(UserRepository.java:45) <== monitors:1
com.app.UserService.getUser(UserService.java:23)
Common Pinning Sources in Real Codebasesβ
| Source | Why It Pins | Fix |
|---|---|---|
Your own synchronized methods | Directly pinning | Replace with ReentrantLock |
| JDBC drivers | Many drivers use synchronized internally | Update to virtual-thread-compatible versions (e.g., PostgreSQL JDBC 42.7+) |
Collections.synchronizedMap() | Uses synchronized internally | Use ConcurrentHashMap instead |
Hashtable | Every method is synchronized | Use ConcurrentHashMap |
StringBuffer | Synchronized (vs StringBuilder) | Use StringBuilder |
| Logging frameworks | Some appenders use synchronized | Update log4j/logback to latest versions |
| Connection pools | Some use synchronized for checkout | HikariCP 5.1+ is virtual-thread-safe |
5. Migration Best Practicesβ
If you upgrade an older Spring Boot application to Java 21 and flip spring.threads.virtual.enabled=true, you must follow these rules:
| Rule | Reason |
|---|---|
| NEVER pool Virtual Threads | Pooling exists to save the creation cost of expensive OS threads. Virtual threads are disposable. Use newVirtualThreadPerTaskExecutor(). |
Replace synchronized | Audit your I/O paths for synchronized and replace with ReentrantLock. |
Beware ThreadLocal limits | Legacy code uses ThreadLocal heavily. If you spawn 1,000,000 virtual threads, you will create 1,000,000 heavy ThreadLocal HashMap allocations and crash the Heap. |
| Use Semaphores for scaling | A Virtual Thread won't break the JVM, but 10,000 concurrent DB queries will instantly crash your PostgreSQL connection pool. Use a Semaphore(50) to restrict extreme bursts. |
π’ Step-by-Step Migration Guideβ
Phase 1: Assessment (1 week)β
# 1. Find all synchronized blocks that do I/O
grep -rn "synchronized" --include="*.java" src/ | head -50
# 2. Find ThreadLocal usage
grep -rn "ThreadLocal" --include="*.java" src/
# 3. Check JDBC driver compatibility
# PostgreSQL: need 42.7.0+
# MySQL: need 8.2.0+ (Connector/J)
# Check your pom.xml/build.gradle
Phase 2: Fix Pinning (1β2 weeks)β
// Before
public class CacheService {
private final Map<String, Object> cache = new HashMap<>();
// β synchronized + I/O = pinning
public synchronized Object getOrLoad(String key) {
if (!cache.containsKey(key)) {
cache.put(key, loadFromDatabase(key)); // I/O inside synchronized!
}
return cache.get(key);
}
}
// After
public class CacheService {
private final ConcurrentHashMap<String, Object> cache = new ConcurrentHashMap<>();
// β
No synchronized, no pinning
public Object getOrLoad(String key) {
return cache.computeIfAbsent(key, this::loadFromDatabase);
}
}
Phase 3: Protect Downstream Resourcesβ
// β Without semaphore: 100,000 virtual threads = 100,000 DB connections = π₯
@Service
public class UserService {
public User getUser(Long id) {
return repository.findById(id).orElseThrow();
}
}
// β
With semaphore: limit concurrent DB access
@Service
public class UserService {
// Allow max 50 concurrent DB queries (matches connection pool size)
private static final Semaphore DB_LIMITER = new Semaphore(50);
public User getUser(Long id) {
DB_LIMITER.acquire(); // virtual thread unmounts here if semaphore is full
try {
return repository.findById(id).orElseThrow();
} finally {
DB_LIMITER.release();
}
}
}
Phase 4: Enable & Monitorβ
# application.properties
spring.threads.virtual.enabled=true
# JVM flags for monitoring
-Djdk.tracePinnedThreads=short
-XX:+UnlockDiagnosticVMOptions
-XX:+DebugNonSafepoints
6. Real-World Use Casesβ
1. High-Concurrency REST APIβ
A Spring Boot API that aggregates data from 3 downstream services per request:
// β Platform threads: 200 thread pool, each request blocks 3 times
// Max throughput: ~70 concurrent requests (200 threads / 3 calls each)
// β
Virtual threads: unlimited concurrent requests
@RestController
public class DashboardController {
@GetMapping("/dashboard")
public DashboardResponse getDashboard(@RequestParam Long userId) {
// Each call blocks for ~100ms β but the virtual thread unmounts!
var profile = userService.getProfile(userId); // 100ms, unmounts
var orders = orderService.getOrders(userId); // 100ms, unmounts
var recommendations = recService.getRecommendations(userId); // 100ms, unmounts
return new DashboardResponse(profile, orders, recommendations);
}
}
// With virtual threads + Structured Concurrency (Java 21+), run in parallel:
@GetMapping("/dashboard/fast")
public DashboardResponse getDashboardFast(@RequestParam Long userId)
throws InterruptedException {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var profileTask = scope.fork(() -> userService.getProfile(userId));
var ordersTask = scope.fork(() -> orderService.getOrders(userId));
var recsTask = scope.fork(() -> recService.getRecommendations(userId));
scope.join().throwIfFailed();
return new DashboardResponse(
profileTask.get(), ordersTask.get(), recsTask.get()
);
}
// Total time: ~100ms (parallel) instead of ~300ms (sequential)!
}
2. Batch Processing with Rate Limitingβ
Processing 1 million records, each requiring an HTTP API call:
// β
1,000,000 virtual threads with controlled concurrency
Semaphore rateLimiter = new Semaphore(100); // max 100 concurrent API calls
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (Record record : allRecords) {
executor.submit(() -> {
rateLimiter.acquire();
try {
apiClient.process(record); // I/O β virtual thread unmounts
} finally {
rateLimiter.release();
}
});
}
}
3. WebSocket / Chat Serverβ
// β
One virtual thread per connected client β handle millions of connections
public class ChatServer {
public void startServer() throws IOException {
ServerSocket server = new ServerSocket(8080);
while (true) {
Socket client = server.accept();
// Each connection gets its own virtual thread β even with 100K clients
Thread.ofVirtual().start(() -> handleClient(client));
}
}
private void handleClient(Socket client) {
try (var reader = new BufferedReader(new InputStreamReader(client.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) { // blocks β unmounts!
broadcast(line);
}
}
}
}
7. Architecture Deep Diveβ
Virtual Threads vs Reactive (WebFlux)β
| Aspect | Virtual Threads | Reactive (WebFlux) |
|---|---|---|
| Code style | Simple imperative blocking | Complex reactive chains (Mono, Flux) |
| Debugging | Normal stack traces | Fragmented, hard to read |
| Learning curve | Minimal β write regular Java | Steep β learn reactive operators |
| Library support | Works with ALL existing blocking libs | Needs reactive-specific drivers (R2DBC, reactive HTTP) |
| CPU-bound work | No advantage | No advantage (same as virtual threads) |
| I/O-bound work | β Excellent | β Excellent |
| Back-pressure | Manual (Semaphore) | Built-in (reactive streams) |
| Ecosystem maturity | New (Java 21+) | Mature (5+ years) |
| Exception handling | Standard try/catch | Complex error operators (.onErrorResume()) |
Recommendation: For new projects on Java 21+, prefer virtual threads. For existing WebFlux apps, no urgent need to rewrite β but stop choosing WebFlux for new services.
Structured Concurrency (Preview in Java 21+)β
Structured Concurrency ensures that child tasks don't outlive their parent scope:
// β Unstructured β what happens if ordersTask fails?
// profileTask and recsTask keep running as orphans
Future<Profile> profileTask = executor.submit(() -> getProfile(userId));
Future<Orders> ordersTask = executor.submit(() -> getOrders(userId));
Future<Recs> recsTask = executor.submit(() -> getRecs(userId));
// β
Structured β if any task fails, ALL are cancelled automatically
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var profileTask = scope.fork(() -> getProfile(userId));
var ordersTask = scope.fork(() -> getOrders(userId));
var recsTask = scope.fork(() -> getRecs(userId));
scope.join(); // wait for all
scope.throwIfFailed(); // propagate the first exception
// If ordersTask threw an exception:
// - profileTask and recsTask are automatically cancelled
// - No orphaned threads
// - Exception propagates to the caller naturally
}
Scoped Values (Preview β Replacement for ThreadLocal)β
ScopedValue is the virtual-thread-friendly replacement for ThreadLocal:
// β ThreadLocal: 1 million virtual threads = 1 million HashMap allocations
private static final ThreadLocal<User> currentUser = new ThreadLocal<>();
// β
ScopedValue: immutable, scoped, virtual-thread-optimized
private static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
// Usage
ScopedValue.where(CURRENT_USER, authenticatedUser).run(() -> {
// CURRENT_USER is available here and in all called methods
processRequest();
});
// In any nested method:
User user = CURRENT_USER.get(); // fast, no HashMap lookup
| Feature | ThreadLocal | ScopedValue |
|---|---|---|
| Mutability | Mutable (set/get anywhere) | Immutable within scope |
| Memory | Heavy (HashMap per thread) | Lightweight (stack-like) |
| Inheritance | InheritableThreadLocal (copies data) | Naturally inherited by child tasks |
| Virtual thread safe | β οΈ Memory bomb with millions of threads | β Designed for virtual threads |
| Lifetime | Until remove() or thread death | Automatic β ends with scope |
βοΈ Trade-offs & When NOT to Use Virtual Threadsβ
When Virtual Threads DON'T Helpβ
// β CPU-bound work β no I/O, so no unmounting opportunity
public BigInteger computeFibonacci(int n) {
// Pure computation β the virtual thread never yields
// This is WORSE than a platform thread due to scheduling overhead
BigInteger a = BigInteger.ZERO, b = BigInteger.ONE;
for (int i = 0; i < n; i++) {
BigInteger temp = b;
b = a.add(b);
a = temp;
}
return b;
}
// For CPU-bound: use ForkJoinPool or a fixed-size platform thread pool
Decision Matrixβ
| Workload | Use Virtual Threads? | Why |
|---|---|---|
| REST API with database calls | β Yes | I/O-bound, massive concurrency benefit |
| Microservice calling 5+ downstream APIs | β Yes | Multiple I/O waits per request |
| File upload/download service | β Yes | I/O-bound (disk/network) |
| WebSocket / chat server | β Yes | Many long-lived I/O connections |
| Batch processing with API calls | β Yes | Thousands of I/O tasks |
| JSON parsing / data transformation | β No | CPU-bound, no I/O to unmount |
| Image/video processing | β No | CPU-bound |
| ML model inference | β No | CPU/GPU-bound |
| In-memory computation | β No | No I/O wait |
The Connection Pool Bottleneckβ
The irony of virtual threads: they move the bottleneck from threads to connection pools.
Before: 200 threads β 200 max concurrent DB queries β DB is fine
After: 1,000,000 virtual threads β 1,000,000 DB queries β π₯ DB explodes
Solution: Always pair virtual threads with resource limiters:
@Configuration
public class DatabaseConfig {
// HikariCP pool: max 50 connections
@Bean
public DataSource dataSource() {
HikariConfig config = new HikariConfig();
config.setMaximumPoolSize(50);
config.setConnectionTimeout(30000); // virtual thread will unmount while waiting
return new HikariDataSource(config);
}
}
HikariCP handles this gracefully β virtual threads waiting for a connection unmount (free the carrier), so thousands of virtual threads can safely queue for 50 connections without wasting OS threads.
π For a complete guide on how thread pools, Tomcat, Netty, and HikariCP relate and how to size them in production, see Thread Pools, Netty, Tomcat & HikariCP.
π§ͺ Testing Virtual Threadsβ
Verifying Virtual Thread Behaviorβ
@Test
void shouldRunOnVirtualThread() {
Thread thread = Thread.ofVirtual().start(() -> {
assertTrue(Thread.currentThread().isVirtual());
assertFalse(Thread.currentThread().isVirtual() == false);
});
thread.join();
}
Load Testing to Detect Pinningβ
@Test
void shouldNotPinCarrierThreads() throws InterruptedException {
int virtualThreadCount = 10_000;
CountDownLatch latch = new CountDownLatch(virtualThreadCount);
AtomicInteger pinnedCount = new AtomicInteger(0);
// Set system property before running
// -Djdk.tracePinnedThreads=full
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < virtualThreadCount; i++) {
executor.submit(() -> {
try {
myService.doWorkWithIO(); // should unmount during I/O
} finally {
latch.countDown();
}
});
}
// If this times out, carrier threads are likely pinned
boolean completed = latch.await(30, TimeUnit.SECONDS);
assertTrue(completed, "Tasks should complete within 30s β pinning suspected if timeout");
}
}
Comparing Throughput: Platform vs Virtualβ
@Test
void benchmarkVirtualVsPlatform() throws InterruptedException {
int taskCount = 10_000;
Duration ioDelay = Duration.ofMillis(100);
// Platform threads (capped at 200)
long platformTime;
try (var executor = Executors.newFixedThreadPool(200)) {
long start = System.nanoTime();
for (int i = 0; i < taskCount; i++) {
executor.submit(() -> Thread.sleep(ioDelay));
}
executor.shutdown();
executor.awaitTermination(60, TimeUnit.SECONDS);
platformTime = System.nanoTime() - start;
}
// Virtual threads (unlimited)
long virtualTime;
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
long start = System.nanoTime();
for (int i = 0; i < taskCount; i++) {
executor.submit(() -> Thread.sleep(ioDelay));
}
executor.shutdown();
executor.awaitTermination(60, TimeUnit.SECONDS);
virtualTime = System.nanoTime() - start;
}
// Virtual threads should be ~50x faster for this I/O-bound workload
System.out.printf("Platform: %dms, Virtual: %dms%n",
platformTime / 1_000_000, virtualTime / 1_000_000);
// Expected: Platform ~5000ms, Virtual ~100ms
}
π Relationship to Other Java Conceptsβ
| Concept | Relationship |
|---|---|
| Stack vs Heap | Virtual thread stacks are stored on the heap as continuations, unlike platform thread stacks which are fixed OS allocations |
| Concurrency | Virtual threads simplify concurrent programming β write sequential blocking code, get concurrent execution |
synchronized | Causes pinning with virtual threads β migrate to ReentrantLock |
ThreadLocal | Memory-expensive with millions of virtual threads β migrate to ScopedValue |
| CompletableFuture | Still useful for composing async results, but virtual threads reduce the need for complex async chains |
| Spring Boot | One property (spring.threads.virtual.enabled=true) enables virtual threads for all request handling |
| JDBC | Virtual threads unmount during JDBC calls if the driver supports it β check driver version |
| Connection Pools | Become the new bottleneck β virtual threads queue on pool waiters and unmount naturally |
8. Interview Questionsβ
Q: Does a Virtual Thread make my CPU-bound loop execute faster?β
A: No! Virtual threads provide zero value for CPU-bound tasks (like parsing massive JSON or mining crypto). The thread cannot unmount if it doesn't do I/O. For CPU bounds, you still use a traditional ForkJoinPool sized to CPU cores.
Q: Why do Virtual Threads kill Reactive Programming (Project Reactor / WebFlux)?β
A: Reactive programming (Mono/Flux) was invented specifically to solve the exact same problem: getting thousands of concurrent connections off of 200 OS threads. But WebFlux forces you into complex "callback hell" and breaks standard debugging stack traces. Virtual Threads let you write simple, imperative, blocking code that magically gets all the throughput benefits of WebFlux natively inside the JVM.
Q: What is Structured Concurrency?β
A: A Java 21+ feature that forces parent-child scopes for Virtual Threads using StructuredTaskScope. It prevents "Orphaned Threads" by guaranteeing that if a parent API request aborts, all its spawned downstream Virtual Threads are automatically cancelled before the parent exits.
Q: What is thread pinning and how do you fix it?β
A: Pinning happens when a virtual thread executes inside a synchronized block β the JVM cannot unmount it, so the underlying carrier (OS) thread is blocked. With only 8β16 carrier threads, a few pinned threads can halt the entire application. Fix: replace synchronized with ReentrantLock, which supports proper unmounting. Detect with -Djdk.tracePinnedThreads=full.
Q: Can you pool virtual threads?β
A: You should not pool virtual threads. Pooling amortizes the cost of expensive thread creation β but virtual threads cost ~1ΞΌs to create and ~1KB of memory. Pooling adds unnecessary complexity and defeats the design purpose. Always use Executors.newVirtualThreadPerTaskExecutor().
Q: What replaces ThreadLocal in a virtual thread world?β
A: ScopedValue (preview in Java 21+). ThreadLocal allocates a HashMap per thread β with 1M virtual threads, that's 1M HashMaps. ScopedValue is immutable, scoped, lightweight, and designed specifically for virtual threads.
Q: How do virtual threads affect connection pool sizing?β
A: Virtual threads remove the thread bottleneck but expose the connection pool bottleneck. With 200 platform threads, you'd need at most 200 DB connections. With 100,000 virtual threads, they all try to get connections simultaneously. Use a Semaphore or properly sized HikariCP pool β virtual threads will unmount while waiting for a connection, so queuing is efficient.
Q: Should I rewrite my WebFlux application to use virtual threads?β
A: Not urgently. WebFlux still works and has built-in back-pressure. But for new projects on Java 21+, prefer virtual threads with Spring MVC β you get the same throughput with much simpler code. Migrate WebFlux services opportunistically when they need significant changes.
Q: How do virtual threads interact with CompletableFuture?β
A: CompletableFuture is still useful for composing results and expressing async workflows. But many patterns that required CompletableFuture chains (to avoid blocking a platform thread) can now be written as simple sequential blocking code on a virtual thread. Structured Concurrency (StructuredTaskScope) is often a cleaner alternative for fan-out/fan-in patterns.