How Java 21 Virtual Threads Enable 100 k Threads Without Thread‑Pool Tuning
After weeks of futile thread‑pool tuning for an IO‑bound gateway, the author switched to Java 21 virtual threads, achieving stable 5,000 concurrent requests with dramatically lower P99 latency, reduced memory usage, and higher CPU efficiency, and then explains virtual‑thread internals, Scoped Values, migration steps, and limitations.
Background and Problem with Traditional Thread Pools
The author describes a gateway service that processes front‑end requests, validates parameters, calls 3‑5 downstream micro‑services, and aggregates results. The workload is pure I/O‑bound, with 90% of the time spent waiting for downstream HTTP responses.
Using kernel threads (the traditional model) each thread consumes about 1 MB of stack memory. A pool of 200 threads already uses ~200 MB, and a pool of 1 000 threads consumes ~1 GB. Context‑switch overhead becomes severe, causing CPU utilization to spike while throughput drops.
Thread pool size 200: at 400 concurrent requests the queue starts growing and P99 latency spikes.
Thread pool size 500: latency improves but context‑switch overhead drives CPU usage to 40 % and reduces throughput.
Thread pool size 300: a compromise, yet 600 concurrent requests still cannot be handled.
Dynamic pool (400 at peak, 100 at low load): code complexity doubles and the problem remains unsolved.
The author likens the situation to a restaurant where too many waiters block the kitchen doorway while most are simply waiting for dishes.
Virtual Threads: Threads Finally Cheap
Java 21 (JEP 444) officially introduces virtual threads as the core deliverable of Project Loom. Their design goal is simple: make threads cheap enough that a separate thread can be created for each task without exhausting resources.
Underlying Mechanism: User‑Space Scheduling
Virtual threads run on top of platform (kernel) threads.
One platform thread can "mount" many virtual threads, executing only one at a time.
When a virtual thread encounters a blocking operation (I/O, sleep, LockSupport.park), the JVM automatically "unmounts" it, saves its state, and lets the platform thread run another virtual thread.
After the blocking operation completes, the virtual thread is remounted on a free platform thread and resumes execution.
In plain terms, a virtual thread is a "task" rather than a "resource". Thousands of virtual threads may share only a handful of platform threads because most are waiting on I/O.
This approach is similar to Go's goroutine or Kotlin's coroutine, but Java's implementation remains fully compatible with the existing Thread API, ExecutorService, synchronized, and ReentrantLock constructs.
Code Comparison: Before vs. After
Traditional approach with a fixed thread pool and CompletableFuture callbacks:
// 以前:线程池 + CompletableFuture回调地狱
ExecutorService executor = Executors.newFixedThreadPool(200);
CompletableFuture.supplyAsync(() -> callServiceA(), executor)
.thenCompose(resultA -> CompletableFuture.supplyAsync(() -> callServiceB(resultA), executor))
.thenCompose(resultB -> CompletableFuture.supplyAsync(() -> callServiceC(resultB), executor))
.thenAccept(finalResult -> sendResponse(finalResult));Same logic with virtual threads written synchronously:
// 现在:虚拟线程,同步写法,但底层是异步调度
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> {
var resultA = callServiceA(); // 阻塞等A,但虚拟线程会unmount
var resultB = callServiceB(resultA); // 阻塞等B,继续unmount
var resultC = callServiceC(resultB); // 阻塞等C
sendResponse(resultC);
});
}The synchronous style yields the performance of asynchronous execution without callback hell or thread‑pool size tuning, and the code becomes far more readable.
Key Fix: synchronized No Longer Pins Platform Threads
Before Java 24, a virtual thread inside a synchronized block or Object.wait() would pin the underlying platform thread, preventing unmounting and negating the benefits of virtual threads.
Java 24 (JEP 491) fixes this issue, allowing virtual threads to be unmounted even when executing inside synchronized blocks. For JDK 21/23, developers should audit long‑running synchronized sections; for JDK 24+ the problem is resolved.
Note: native (JNI) calls still pin the platform thread and should be avoided in virtual‑thread contexts.
Scoped Values: The Successor to ThreadLocal
With virtual threads becoming mainstream in Java 21, ThreadLocal becomes problematic because it was designed for a fixed number of platform threads. Creating a ThreadLocal per virtual thread can explode memory usage and lead to leaks.
Java 25 (JEP 487) introduces Scoped Values, which solve three issues:
Immutable : once bound, the value cannot be changed, avoiding mid‑execution mutations.
Scope‑bound : the value is visible only within the current code block and any child threads (including virtual threads); it is automatically cleared when the scope exits.
Cross‑thread propagation : when a new virtual or platform thread is created, the Scoped Value is automatically transferred.
Example usage:
// 定义一个 Scoped Value
private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
// 绑定值并执行代码
ScopedValue.where(REQUEST_ID, "uuid-12345").run(() -> {
log.info("处理请求: {}", REQUEST_ID.get()); // 输出 uuid-12345
Thread.startVirtualThread(() -> {
log.info("子线程也能拿到: {}", REQUEST_ID.get()); // 同样输出 uuid-12345
});
});
// 代码块结束后 REQUEST_ID 自动清理Contrast with ThreadLocal which requires manual removal to avoid leaks:
// ThreadLocal:可变,要手动清理,虚拟线程场景内存爆炸
private static final ThreadLocal<String> REQUEST_ID = new ThreadLocal<>();
REQUEST_ID.set("uuid-12345");
try {
log.info("处理请求: {}", REQUEST_ID.get());
} finally {
REQUEST_ID.remove(); // 忘记 remove 会泄漏
}For projects migrating to virtual threads, replacing all ThreadLocal instances with Scoped Values is the recommended practice.
Other Concurrency Enhancements (Java 9‑12)
Before virtual threads, Java 9 added useful CompletableFuture methods such as completeOnTimeout, orTimeout, and copy. Java 12 introduced exceptionallyAsync and exceptionallyComposeAsync for asynchronous exception handling. These APIs simplify timeout handling and error propagation.
Java 9 also standardized the Flow API (JEP 266) with interfaces Publisher, Subscriber, Subscription, and Processor, which are implemented by libraries like RxJava and Project Reactor.
Performance Comparison: When to Use Virtual Threads
Real‑world measurements from the author's gateway service:
Concurrency capacity : platform thread pool (200) starts queuing at 800 concurrent requests; virtual threads handle 5 000 stable – a 6× increase.
P99 latency (1 000 concurrent) : 450 ms with platform threads vs. 65 ms with virtual threads – a 7× reduction.
Memory usage : 1.8 GB vs. 1.2 GB – a 33 % decrease.
CPU utilization : 65 % (heavy context switching) vs. 78 % (mostly business logic) – more efficient execution.
Virtual threads excel in I/O‑intensive scenarios but provide little benefit for pure CPU‑bound workloads such as heavy computation, image processing, or video encoding.
Best fit : gateways, BFFs, micro‑service aggregation layers, HTTP/RPC servers – heavy downstream waiting.
Very suitable : services with many database or file‑I/O operations.
Generally suitable : mixed I/O and compute workloads.
Not suitable : pure compute‑intensive tasks (scientific computing, encoding, large‑scale data processing).
Migration Guide: Four Steps to Adopt Virtual Threads
1. Replace the Executor
Swap the traditional thread‑pool executor for a virtual‑thread executor:
// 以前
ExecutorService executor = Executors.newFixedThreadPool(200);
// 现在
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();Spring Boot 3.2+ can enable this globally with spring.threads.virtual.enabled=true, causing Tomcat to use virtual threads for request handling.
2. Audit ThreadLocal Usage
Search the codebase for ThreadLocal instances (e.g., traceId, userId) and evaluate whether they can be replaced by Scoped Values.
3. Check synchronized Blocks
If running on JDK <24, identify long‑running synchronized sections (e.g., synchronized I/O or DB calls). Either shrink the critical section or upgrade to JDK 24+ where the pinning issue is fixed.
4. Validate with Load Testing
After migration, run performance tests and verify:
Improved concurrency capacity.
Reduced latency distribution.
No platform‑thread pinning (use -Djdk.tracePinnedThreads=full to monitor).
Conclusion
The Java concurrency model has evolved from the early JUC utilities (Java 5) through Flow and CompletableFuture enhancements (Java 9‑12) to the revolutionary virtual threads (Java 21) and Scoped Values (Java 25). Virtual threads allow Java developers to write simple synchronous code that scales to high‑concurrency I/O workloads, eliminating the need for complex thread‑pool tuning, callback hell, or reactive frameworks.
Use virtual threads for I/O‑bound services; avoid them for pure compute workloads.
Do not pool virtual threads—let the JVM schedule one thread per task.
Be aware of synchronized pinning on JDK <24; upgrade to benefit from the fix.
Replace ThreadLocal with Scoped Values when moving to virtual threads.
Native (JNI) calls still pin platform threads; minimize their use.
Three Immediate Actions
Inspect your current thread‑pool configuration; if the service is I/O‑bound, replace the executor with Executors.newVirtualThreadPerTaskExecutor() and run a quick load test.
Globally search for ThreadLocal usages; list them and assess which can be migrated to Scoped Values.
Add the JVM flag -Djdk.tracePinnedThreads=full to your test environment and run a load test to detect any remaining pinned platform threads caused by synchronized blocks.
Next Article Preview
The upcoming post will cover GC evolution—from CMS to ZGC—explaining why CMS was deprecated, how ZGC’s colored pointers achieve sub‑millisecond pauses, the trade‑offs between generational ZGC and Shenandoah, and the compact object header introduced in JDK 25 that saves roughly 20 % of memory.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Tinker Programmer
Solving problems with code, sharing practical tech insights, and leveling up together!
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
