Virtual Threads were introduced in Java 21 by Project Loom. They allow a single JVM to handle a large number of tasks and manage millions of concurrent operations, making them especially suitable for I/O bound processes. Because the memory model of virtual threads differs from that of traditional threads, performance issues can be hard to detect.
Although virtual threads consume less memory than traditional threads, the real challenge is that Project Loom’s memory management introduces a variation in how thread count, object retention, and heap usage are connected. This change in memory management while handling a large number of threads may cause inefficiencies, leading to Memory Leaks, heap exhaustion and signaling an OutOfMemoryError.
This article provides a checklist to assist SRE teams and Java architects in detecting, diagnosing, and resolving Virtual Thread memory leaks before they reach production. If you are new to Project Loom, Java Project Loom: Unlocking the Power of Java Virtual Threads is a good starting point for building a strong foundation.
Why Virtual Threads Create a New Memory Leak Surface
Platform threads use OS-level stack memory, approximately 1 MB in size. There is a limitation on the number of threads, which results in fewer ThreadLocal variables and less request data. In contrast, Virtual threads are JVM-managed lightweight threads created and stored on the Java heap, which grows on demand. This allows millions of virtual threads to coexist. This architectural shift has a direct impact: all objects used by Virtual threads remain in the heap, leading to unexpected memory usage and creating a memory leak at scale.
For more insights on the difference between Virtual Threads and Platform threads, Virtual Threads vs Platform Threads in Java 23 is a good place to start. When a memory leak occurs, tools like HeapHero can analyze a heap dump and highlight problem areas.
Four vital memory leak patterns are observed at the Virtual Thread scale:

Fig: Virtual Thread Memory leakage patterns Vs Platform thread boundary including Detection Checklist. Every single pattern indicates root cause and suggested fix.
The Four Leak Patterns: Checklist
Let’s discuss the four vital Leak Patterns that emerge specifically for Virtual Threads.
1.ThreadLocal Accumulation
This is the general Virtual Thread leak pattern. ThreadLocal was intended for architectures with restricted threads. Consider a system with 200 platform threads as a maximum bound; in such a system, it is possible to have a maximum of 200 ThreadLocal entries. The Virtual Thread architecture is different. Every incoming request can be assigned its own thread and, in turn, its own ThreadLocal entry. Under heavy load, processing millions of ThreadLocal entries in memory occurs. A serious memory leak occurs when these entries are not cleared automatically. Such entries remain in the heap until the thread becomes inactive.
// DANGEROUS with Virtual Threads — unbounded growthprivate static final ThreadLocal<UserContext> CTX = new ThreadLocal<>(); // SAFE — use ScopedValue (JEP 446, Java 21 preview / Java 23 final)private static final ScopedValue<UserContext> CTX = ScopedValue.newInstance();ScopedValue.where(CTX, userContext).run(() -> { // CTX is automatically released when the scope exits});
How to detect:
For a clear view of memory usage, take a heap dump under load and count the number of entries for ThreadLocal$ThreadLocalMap. The count should be near zero at Virtual Thread scale. A count in the thousands can be a warning sign of an increasing Virtual Thread memory leak via ThreadLocal.
An automated heap dump analyzer like HeapHero — Free Heap Dump Analyser is capable of detecting ThreadLocal$ThreadLocalMap growth and listing the top-ranking object chains without manual intervention.
2. Carrier Thread Pinning: the Performance Killer
This issue typically arises with limited platform threads (e.g., 200 Platform Threads). The JVM pins the Virtual Thread to its carrier thread the moment a VirtualThread performs a blocking I/O operation within a synchronized block. A carrier thread is an ordinary OS thread that handles Virtual Thread processing. During pinning, the carrier thread can’t be assigned to another task. Under peak load, this pinning behavior causes extended delays and latency spikes, leading to high performance issues similar to those seen in earlier Java releases.
// CAUSES PINNING — prevents carrier thread reuse during I/Osynchronized (lock) { result = database.query(sql); // blocks here} // FIX — use ReentrantLock, which Virtual Threads can park without pinningprivate final ReentrantLock lock = new ReentrantLock();lock.lock();try { result = database.query(sql);} finally { lock.unlock();}
How to detect:
The JVM flag -Djdk.tracePinnedThreads=full enables the JVM to log details about pinned threads. These logs clearly highlight the pinned Virtual Threads and the exact synchronized code that is holding the carrier thread, when analyzed using fastThread.io. It is better to run this analysis during load testing, not in production.
3. StructuredTaskScope Leak: Unclosed Scopes Hold References
Java 21’s StructuredTaskScope is the recommended API for concurrent processing with Virtual Threads. Each scope creates child Virtual Threads that hold references to their subtask objects. If StructuredTaskScope is not closed appropriately—either through try-with-resources or by explicitly calling close()—the subtask objects, their results, and any objects they reference remain on the heap indefinitely.
// LEAKS — scope never closed if exception thrown before close()var scope = new StructuredTaskScope.ShutdownOnFailure();scope.fork(() -> fetchUserData(userId));scope.join(); // scope.close() never called if join() throws // CORRECT — try-with-resources guarantees close()try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { scope.fork(() -> fetchUserData(userId)); scope.join().throwIfFailed();} // scope.close() called automatically
How to detect:
In a heap dump (HeapHero), look for StructuredTaskScope instances still reachable from GC roots after request completion. In GC logs (-Xlog:gc*), a steadily rising old-generation occupancy that doesn’t reclaim between requests is the key signal. In code, flag every new StructuredTaskScope() not wrapped in try-with-resources as a leak risk.
For a more detailed understanding of how structured concurrency works under the hood, Structured Concurrency in Java 23 covers the lifecycle model and explains why try-with-resources is the only safe pattern for production code.
4. Object Pool Mismatch: Pools Sized for Platform Threads
Connection pools, object pools, and ExecutorService configurations sized for platform-thread concurrency (e.g. maximumPoolSize=200) become a bottleneck under Virtual Thread workloads, where concurrent requests may scale up to tens of thousands. Virtual Threads queue behind the pool limit, holding their heap-allocated state — request objects, response buffers, context maps — while waiting. This typically floods the live heap with objects that the garbage collector cannot reclaim, because they are still accessible from the waiting Virtual Threads.
How to detect:
Monitor jvm_memory_used_bytes{area=”heap”} via Prometheus during peak Virtual Thread concurrency. A heap that scales linearly with request rate, despite low per-request allocation, implies pool starvation causing heap retention. Reevaluate pool sizing based on observed Virtual Thread concurrency instead of historical platform-thread limits.
To visualize heap and GC trends in real time, Visualising JVM Metrics with Prometheus and Grafana provides a step-by-step setup guide. You can also use GCeasy – GC Log Analyser to analyze GC log files and identify old-generation growth patterns that indicate pool starvation. For Spring Boot teams in particular, How to Configure Virtual Threads in Spring Boot provides recommended executor configurations for Virtual Thread workloads.
Diagnostic Toolchain for Project Loom Memory Management
Standard JVM diagnostic tools work with Virtual Threads but need configuration adjustments to capture Virtual Thread-specific information:
- Heap dumps: Produce a heap dump using jcmd <PID> GC.heap_dump /path/heap.hprof. Analyze the heap dump using HeapHero. Examine ThreadLocal$ThreadLocalMap and VirtualThread instances in the heap dump. Thousands of entries under persistent load confirm a ThreadLocal heap leak.
- Thread dumps: Use jcmd <PID> Thread.dump_to_file -format=json /path/threads.json. The JSON format includes Virtual Thread state. Upload it to fastThread to find PINNED threads and blocked carrier threads.
- GC logs: Enable with -Xlog:gc* and watch tenured memory occupancy under Virtual Thread load. Uneven heap growth relative to request volume indicates object retention from one of the four patterns above. Feed the GC log into GCeasy for immediate automated analysis and trend visualization.
- Pinning diagnostics: Add -Djdk.tracePinnedThreads=full to the JVM flags during load testing. Every pinning event logs a stack trace; these reveal synchronized blocks that need a ReentrantLock transition.
- JFR (Java Flight Recorder): Run jcmd <PID> JFR.start duration=60s filename=loom.jfr. JFR captures Virtual Thread scheduling events, mount/unmount counts, and pinning frequency—the most complete view of Project Loom memory behavior available without a commercial APM.
Tip: Virtual Threads do not appear in traditional thread dumps by default. Use -Djdk.virtualThreadScheduler.parallelism=N to control carrier thread count, and always use JSON format thread dumps (jcmd Thread.dump_to_file -format=json) to capture the full Virtual Thread inventory.
For teams that need a complete picture in one place, yCrash correlates heap dumps, GC logs, and thread dumps into a single automated root-cause report, eliminating the need to correlate across three separate tools. To go deeper into GC log analysis, the GC Log Analysis Using Deterministic AI webinar is a useful walkthrough of real production log patterns.
The Architect’s Checklist: Summary
- Replace ThreadLocal with ScopedValue (JEP 446) for context transmission requests. Perform a complete audit of all ThreadLocal usage before deploying to production.
- Replace synchronized blocks with ReentrantLock wherever I/O or blocking operations happen inside the critical section. Enable -Djdk.tracePinnedThreads=full to identify and verify the absence of Virtual Thread Pinning during load testing.
- Ensure all StructuredTaskScope instances are properly closed, especially during failures. Use try-with-resources to enforce this.
- Inspect all pool sizes against Virtual Thread concurrency. Connection pools, HTTP client pools, and ExecutorService configurations built for 200 platform threads are inadequate for Virtual Thread workloads. Quantify the actual peak concurrency under VT load before setting limits.
- Enable GC logging and organize heap dump capture before deploying Virtual Threads to production. The leak patterns mentioned above remain invisible until you have the diagnostic tooling to surface them.
- Use JSON thread dumps for Virtual Thread visibility. Conventional text thread dumps omit most Virtual Threads by default.
Conclusion
Project Loom’s memory model is not a fault; it is a design shift that requires architects to revisit designs that were benign at the platform-thread scale but become risky at the Virtual Thread scale. All four of the leak patterns described above are avoidable before production with the diagnostic actions explained above. Teams moving to Java 21 Virtual Threads should assess these designs during staging, and configure their JVMs for GC and heap visibility, then replace synchronized blocks with ReentrantLock. Teams that proactively identify blocking operations can leverage Project Loom’s scalability advantages by avoiding these memory-related issues, which otherwise frequently worsen performance in production.
To make end-to-end JVM incident diagnosis a fully automated process, yCrash can correlate heap, GC, and thread data into a single root-cause report. Deeper JVM knowledge can be built across a team through a JVM Performance Masterclass, whose curriculum includes GC tuning and troubleshooting, thread dump analysis, OutOfMemoryError troubleshooting, deadlocks, HTTP errors, and poor response time.

Share your Thoughts!