DeepReach · Java Core · Video #3

Java Virtual Threads
Project Loom

The biggest concurrency change in Java's 25-year history. Platform thread memory architecture, carrier thread scheduling, mount/unmount mechanics, Spring Boot integration, thread pinning, Structured Concurrency, and everything you need at a senior interview.

Java 21 LTS Project Loom Spring Boot 3.2+ StructuredTaskScope Thread Pinning Interview Ready
01

Platform Threads Don't Scale

🧵

Platform Thread Cost

  • Each thread = ~1 MB OS stack — reserved in native memory, not the Java heap
  • OS allocates it at creation, never releases until thread exits
  • Tomcat default pool: 200 threads = 200 MB locked
  • OS context switching — expensive kernel-mode operation
  • Thread blocked on I/O = OS thread doing absolutely nothing
📉

The Real-World Impact

  • REST API calls a database — 20 ms wait
  • Thread is BLOCKED that entire 20 ms, serving no one
  • Under load: all 200 threads blocked on I/O simultaneously
  • Request 201 waits → timeout → 503 Service Unavailable
  • Adding more threads helps only until DB/network becomes the limit
A thread blocking on I/O holds an OS thread doing absolutely nothing — that's the waste virtual threads eliminate
Diagram 1 — Platform threads: state over time
THREAD STATE OVER TIME → thread-1 RUNNING BLOCKED — DB wait (20ms) 😴 RUN BLOCKED — HTTP call (150ms) 😴 thread-2 BLOCKED — Kafka consumer poll... entire duration 😴 thread-3 RUN BLOCKED — more I/O wait 😴 4–200 threads ALL BLOCKED — 197 threads burning ~197 MB of OS memory doing nothing 💸 Thread pool exhausted → request 201 queues → timeout → 503 Service Unavailable 200 threads × 1MB OS stack = 200MB RAM consumed for threads that are just SLEEPING
02

How Virtual Threads Work

🎯

Carrier Threads

A small ForkJoinPool of OS "carrier" threads — typically one per CPU core. These are the only real OS threads. Virtual threads mount onto them to run.

🔄

Mount / Unmount

When a virtual thread hits blocking I/O (DB, HTTP, sleep), the JVM saves its stack to a Continuation object on the heap and unmounts it. The carrier immediately picks up another VT.

💾

Heap-Stored Stack

Virtual thread stacks live on the Java heap, not OS memory. Start at ~1–2 KB, grow dynamically only as needed. GC'd when done.

📊 Key numbers Platform thread stack: ~1 MB OS native memory · Virtual thread stack: ~1–2 KB Java heap · Memory ratio: ~500–1000× savings · At 1 million concurrent tasks: platform threads need ~1 TB of OS stack (impossible) vs virtual threads ~2 GB of heap (feasible)
~1MB
Platform thread stack
(OS native memory)
~1KB
Virtual thread stack
(Java heap)
~200
Tomcat default
platform threads
millions
Virtual threads
possible
Diagram 2 — Virtual thread mount/unmount lifecycle
Client
HTTP Request
Request arrives → Tomcat creates a new virtual thread per request. No thread pool. No queuing.
Virtual Thread
Mounted
on carrier-1
VT mounts onto carrier thread-1. Executes controller code — fast synchronous logic.
DB Call
Blocking I/O
JDBC blocks here
JVM detects blocking. Saves VT stack to a Continuation object on the heap. VT unmounts from carrier-1.
Carrier Thread-1
Now Free!
picks up another VT
Carrier-1 is immediately free to mount another virtual thread. Zero wasted OS resources.
DB responds
I/O Complete
Database returns data. VT is rescheduled for remounting on any available carrier (not necessarily carrier-1).
Response
200 OK
VT completes, sends response, is garbage collected. Your code is plain synchronous Java — 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
03

Creating Virtual Threads — 3 Ways

VirtualThreadExamples.java
Java 21
1// ── Way 1: Thread.ofVirtual() ───────────────────────────────────────
2Thread vt = Thread.ofVirtual().name("vt-1").start(() -> {
3 System.out.println(Thread.currentThread() + " isVirtual=" + Thread.currentThread().isVirtual());
4});
5// Output: VirtualThread[#21,vt-1]/runnable@... isVirtual=true
6
7// ── Way 2: Thread.startVirtualThread() shorthand ────────────────────
8Thread.startVirtualThread(() -> fetchFromDatabase()); // fire-and-forget
9
10// ── Way 3: newVirtualThreadPerTaskExecutor() — use in production ─────
11try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
12 executor.submit(() -> processRequest("A"));
13 executor.submit(() -> processRequest("B"));
14} // auto-closes, waits for all tasks
15
16// ── Demo: 100,000 VTs simultaneously — works fine! ───────────────────
17long start = System.nanoTime();
18try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
19 IntStream.range(0, 100_000).forEach(i -> ex.submit(() -> Thread.sleep(1000)));
20}
21// 100,000 VTs, each sleeping 1s — completes in ~1s total!
22// With platform threads: OutOfMemoryError — 100,000 × 1MB = 100GB!
23
24// ── ANTI-PATTERN: never pool virtual threads ─────────────────────────
25// ❌ Executors.newFixedThreadPool(100, Thread.ofVirtual().factory())
26// Pooling adds overhead for threads that are nearly free to create!
Key points
  • newVirtualThreadPerTaskExecutor() creates a new virtual thread per submitted task — no reuse, no pool. This is correct — VTs are cheap to create (heap allocation only, no OS syscall).
  • Thread.currentThread().isVirtual() returns true inside a virtual thread. Useful for assertions and logging in tests.
  • Never wrap a virtual thread factory in newFixedThreadPool(). Thread pools exist to amortise the high cost of platform thread creation. That cost doesn't exist for VTs.
04

Spring Boot Integration

application.yml
Spring Boot 3.2+
1# Spring Boot 3.2+ — ONE property enables virtual threads for Tomcat, @Async, @Scheduled
2spring:
3 threads:
4 virtual:
5 enabled: true # That's it. Zero code changes to controllers/services.
6
7 # Tune HikariCP — VTs can now create thousands of concurrent DB requests!
8 datasource:
9 hikari:
10 maximum-pool-size: 50 # Tune for DB capacity, NOT thread count
VirtualThreadConfig.java
Spring Boot 3.0 / 3.1
1@Configuration
2public class VirtualThreadConfig {
3
4 // Tomcat: replace thread pool with virtual thread executor
5 @Bean
6 public TomcatProtocolHandlerCustomizer<?> virtualThreadCustomizer() {
7 return ph -> ph.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
8 }
9
10 // @Async: use virtual thread executor
11 @Bean(name = "asyncExecutor")
12 public Executor asyncExecutor() {
13 return Executors.newVirtualThreadPerTaskExecutor();
14 }
15}
Key points
  • Spring Boot 3.2+: spring.threads.virtual.enabled=true configures Tomcat, Spring's task executor, and @Scheduled all at once. Your @RestController, @Service, @Repository code needs zero changes.
  • HikariCP gotcha: Before VTs you had 200 Tomcat threads so you'd set pool to 200. With VTs you can now have thousands of concurrent requests — all wanting DB connections. Default pool is 10! Set maximum-pool-size based on your database's actual capacity (e.g. 50–100 for PostgreSQL).
  • HikariCP 5.1.0+, Spring JDBC, Lettuce 6.3+, Kafka client 3.6+, OkHttp 5+ — all virtual-thread aware. No pinning issues with these libraries.
05

Structured Concurrency — Parallel Calls Made Simple

OrderService.java
Java 21
1// ❌ OLD WAY — CompletableFuture chains are verbose and hard to debug
2CompletableFuture<Order> orderF = CompletableFuture.supplyAsync(() -> orderRepo.findById(id));
3CompletableFuture<User> userF = CompletableFuture.supplyAsync(() -> userRepo.findById(uid));
4CompletableFuture.allOf(orderF, userF).join(); // error handling? Ugly ExecutionException wrapping
5
6// ✅ BETTER — CompletableFuture with virtual thread executor
7var vtEx = Executors.newVirtualThreadPerTaskExecutor();
8CompletableFuture.supplyAsync(() -> orderRepo.findById(id), vtEx) // each stage on a VT
9 .thenCombine(CompletableFuture.supplyAsync(() -> userRepo.findById(uid), vtEx),
10 (order, user) -> new OrderResponse(order, user)).join();
11
12// ✅ BEST — Structured Concurrency (Java 21) — clean, readable, safe
13try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
14 Subtask<Order> order = scope.fork(() -> orderRepo.findById(id));
15 Subtask<User> user = scope.fork(() -> userRepo.findById(uid));
16 scope.join().throwIfFailed(); // clean exception propagation
17 return new OrderResponse(order.get(), user.get()); // both results available
18}
19
20// ShutdownOnSuccess: first result wins, all others cancelled
21try (var scope = new StructuredTaskScope.ShutdownOnSuccess<Price>()) {
22 scope.fork(() -> vendor1.getPrice(sku));
23 scope.fork(() -> vendor2.getPrice(sku));
24 scope.fork(() -> vendor3.getPrice(sku));
25 scope.join();
26 return scope.result(); // cheapest/fastest vendor wins, others cancelled
27}
Key points
  • scope.fork() spawns a virtual thread for each task. Both run in parallel. Blocking inside fork does not block a carrier thread — it unmounts.
  • ShutdownOnFailure — if any subtask throws, all others are cancelled automatically. No orphaned threads. Far cleaner than CompletableFuture exception chaining.
  • ShutdownOnSuccess — returns the first successful result and cancels all remaining forks. Classic use case: querying multiple pricing vendors and returning the fastest/cheapest.
06

Thread Pinning — The Hidden Danger

⚠️ Thread pinning is the one pitfall that can silently destroy all the scalability benefits of virtual threads
❌ PINNED — synchronized block
Virtual Thread
Enters synchronized
Acquires synchronized monitor lock
Blocking I/O inside
PINNED 📌
Cannot unmount! Carrier thread is blocked. Same cost as a platform thread.
Carrier Thread
Also blocked 💸
OS thread wasted. All benefit lost.
✅ 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.
PinningExample.java — fix
Java 21
1// ❌ CAUSES PINNING — synchronized + blocking I/O
2public synchronized Order getOrder(Long id) {
3 return orderRepository.findById(id).orElseThrow(); // ← blocks inside synchronized = PINNED
4}
5
6// ✅ FIX — replace synchronized with ReentrantLock
7private final ReentrantLock lock = new ReentrantLock();
8
9public Order getOrder(Long id) {
10 lock.lock(); // VT can unmount while waiting for lock
11 try {
12 return orderRepository.findById(id).orElseThrow(); // ← safe to unmount here
13 } finally {
14 lock.unlock(); // always in finally!
15 }
16}
17
18// Detect pinning — add this JVM flag when running under load:
19// -Djdk.tracePinnedThreads=full
20// Prints a stack trace every time a virtual thread is pinned to its carrier
21
22// Alternative: JFR event (non-intrusive, good for production)
23// jdk.VirtualThreadPinned — enable via JFR streaming at runtime
Key points
  • Pinning occurs when blocking inside synchronized blocks/methods or JNI native calls. The JVM cannot create a Continuation and detach the virtual thread from its carrier.
  • ReentrantLock is virtual-thread aware. A VT inside lock.lock() waiting for contention can unmount cleanly. Always use it in new code alongside VTs.
  • Most modern libraries have already migrated: HikariCP 5.1+, Lettuce 6.3+, Kafka client 3.6+, Spring JDBC (Java 21). Audit your own code — third-party libs are mostly safe.
07

When NOT to Use Virtual Threads

Virtual threads are not a silver bullet — three clear scenarios where they don't help or actively hurt
ScenarioUse Virtual Threads?Why
I/O-bound workloads✅ Yes — idealDB calls, REST calls, Kafka, file I/O — blocking releases the carrier
High-concurrency APIs✅ Yes — great fitHandle millions of simultaneous requests without reactive code
CPU-bound workloads⚠️ No benefitImage processing, crypto, ML inference — thread never blocks, no unmounting gain. Use a bounded platform thread pool sized to CPU cores.
synchronized + I/O❌ Fix firstThread pinning eliminates the benefit and can degrade performance below platform threads
Pooling virtual threads⚠️ Anti-patternVTs cost ~1KB to create (no OS syscall). Pooling adds overhead for zero benefit. Always use newVirtualThreadPerTaskExecutor().
ThreadLocal caching❌ Anti-patternVTs are not pooled — ThreadLocal caches (e.g. SimpleDateFormat, DB connections) are never reused. Use object pools instead.
08

Virtual Threads vs Reactive (WebFlux)

Diagram 3 — Platform Threads vs Virtual Threads vs Reactive — side by side
Platform Threads Virtual Threads Reactive (WebFlux) CONCURRENCY MODEL 1 thread per request 1 VT per request Async callbacks (Mono) MAX CONCURRENCY ~200 (Tomcat default) Millions ✅ Millions CODE STYLE Synchronous (simple) Synchronous (simple) ✅ Async/functional (complex) MIGRATION EFFORT Baseline (no change) 1 line in yml ✅ Full rewrite required BEST FOR Low–medium concurrency I/O-bound microservices ✅ Streaming / back-pressure
For most Spring Boot microservices → Virtual Threads replace the need for WebFlux while keeping simple synchronous code
⚛️

Still use Reactive when…

  • Streaming large payloads (SSE, WebSocket)
  • Fine-grained back-pressure control is required
  • Protocol-level async patterns are intrinsic to the domain
  • Your team already has deep Reactor expertise
🧵

Use Virtual Threads when…

  • I/O-bound Spring MVC application needing scale-up
  • You want readable, debuggable synchronous code
  • Zero migration from existing Spring Boot code
  • Complete stack traces (reactive breaks them)
09

Complete HTTP Request Lifecycle with Virtual Threads

GET /api/orders/{id} — full Spring Boot virtual thread flow
Client
GET /api/orders/1
HTTP request arrives at Tomcat. New virtual thread created immediately — no thread pool queue.
Virtual Thread
Mounted
on carrier-N
VT mounts onto a carrier thread. Executes OrderController.getOrder() synchronously.
Spring Security
JWT Validation
fast CPU op
Passes through security filters. No blocking — carrier stays mounted.
Controller → Service
OrderService
Normal synchronous method calls down the stack. No reactive types needed.
DB Query
orderRepo.findById()
JDBC blocks here
JDBC blocks. JVM detects it → unmounts VT, saves stack to heap as Continuation. Carrier is immediately free to serve another request.
DB responds
Data returned
DB returns result. VT is rescheduled and remounted on any available carrier. Execution resumes exactly where it left off.
Response
200 OK — JSON
Response sent. VT is garbage collected. Zero wasted resources. ✅
Spring Boot virtual thread request chain
HTTP arrives New VT created Security filters Controller → Service DB: VT unmounts DB done: remounts 200 OK + GC'd
10

Senior Interview Q&A

Q1: What is the memory difference between platform threads and virtual threads?
Platform threads have a fixed ~1 MB OS stack reserved in native memory at creation — not the Java heap, never GC'd until the thread exits. Virtual threads have a heap-allocated stack starting at ~1–2 KB, growing dynamically, and eligible for GC when done. At 1 million concurrent tasks: platform threads would need ~1 TB of OS stack (impossible). Virtual threads need ~2 GB of heap (feasible). That's the ~500–1000× 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 stored on the heap. It contains: the 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 resumes exactly where it left off.
Q3: What is thread pinning, what causes it, and how do you detect and fix it?
Pinning = the JVM cannot create a Continuation and unmount the virtual thread from its carrier. Causes: synchronized blocks/methods with blocking I/O inside, or JNI native method calls. When pinned, the carrier thread is blocked — same as a platform thread. Detect: -Djdk.tracePinnedThreads=full JVM flag (or JFR event jdk.VirtualThreadPinned for production). Fix: replace synchronized with ReentrantLock, which is virtual-thread aware and supports unmounting.
Q4: How does the JVM scheduler work for virtual threads?
A dedicated work-stealing ForkJoinPool (separate from the common pool, ~1 carrier thread per CPU core) maintains a queue of runnable virtual threads. It mounts them onto available carriers FIFO. Virtual threads are completely invisible to the OS scheduler — the OS only sees the small carrier thread pool. The carrier count can be tuned with -Djdk.virtualThreadScheduler.parallelism=N.
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, StructuredTaskScope (Java 21) replaces complex CompletableFuture chains with readable synchronous-style parallel code and clean error propagation via throwIfFailed().
Q6: Should you pool virtual threads? Why not?
No — anti-pattern. Thread pools amortise the high cost of platform thread creation (kernel syscall, OS stack allocation). Virtual threads have no meaningful creation cost — just a heap allocation of ~1–2 KB. Pooling adds scheduling complexity for zero benefit. Use 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 handling high concurrency. Reactive: still valuable for streaming (SSE, WebSocket), fine-grained back-pressure, and protocol-level async patterns where the event-driven model is intrinsic. For most enterprise Spring Boot APIs virtual threads achieve the same throughput as WebFlux with far simpler code.
Q8: What is StructuredTaskScope.ShutdownOnSuccess used for?
When you want the first successful result and want all remaining forks cancelled immediately. Classic use case: querying multiple pricing vendors simultaneously and returning the cheapest or fastest response. As soon as one fork returns a valid result, all others are cancelled — saving resources and time. Contrast with ShutdownOnFailure which cancels all forks when any one throws an exception.
11

Quick Reference

📊 Key numbers Platform thread: ~1 MB OS stack · Virtual thread: ~1–2 KB heap · Memory ratio: ~500–1000× · Carrier threads: ~1 per CPU core · VT creation cost: ~10× faster than platform thread · 1M VTs heap: ~2 GB · 1M platform threads: ~1 TB OS stack (impossible)
⚙️
JVM flags to know -Djdk.tracePinnedThreads=full — detect thread pinning (stack trace on every pin)
-Djdk.virtualThreadScheduler.parallelism=N — set carrier thread count
jcmd <PID> Thread.dump_to_file -format=json — thread dump including VTs
Virtual-thread-safe libraries (no pinning) HikariCP 5.1.0+, Spring JDBC (Java 21+), Lettuce 6.3+ (Redis), Kafka client 3.6+, OkHttp 5+, Netty 4.2+, Apache HttpClient 5.3+
3 anti-patterns to avoid 1. newFixedThreadPool(N, Thread.ofVirtual().factory()) — pooling VTs defeats their purpose
2. synchronized + blocking I/O — causes pinning, carrier thread blocked
3. ThreadLocal caching — caches never reused (VTs not pooled), memory leak risk
🔄
CompletableFuture upgrade path Old: CompletableFuture.supplyAsync(() -> ...) — uses ForkJoinPool platform threads
Better: supplyAsync(() -> ..., Executors.newVirtualThreadPerTaskExecutor())
Best: StructuredTaskScope.ShutdownOnFailure — parallel forks, clean error handling
🌱
Spring Boot complete yml (copy-paste ready) spring.threads.virtual.enabled: true — Tomcat + @Async + @Scheduled
spring.datasource.hikari.maximum-pool-size: 50 — tune for DB capacity
🎯
Decision framework (say this in interviews) "I/O-bound? Enable VTs. CPU-bound? Bounded platform thread pool. synchronized around I/O? Fix with ReentrantLock first. Pooling VTs? Stop. ThreadLocal caching? Replace with object pool."