Technical Briefing
Virtual threads (Project Loom) are designed to be lightweight and massively scalable, supporting millions of concurrent tasks on a handful of OS threads. However, 'pinning' occurs when a virtual thread becomes stuck to its carrier (platform) thread โ preventing the JVM scheduler from mounting other virtual threads onto it. This commonly happens inside synchronized blocks that perform blocking I/O, or when invoking native methods. The result is silent performance degradation: no exception thrown, just thread exhaustion.
โ Signal Detected
// Thread Dump via jcmd <pid> Thread.dump_to_file -format=json "VirtualThread-1" #25 virtual java.lang.VirtualThread.park(VirtualThread.java:582) java.util.concurrent.locks.LockSupport.park(LockSupport.java:371) ... at com.example.service.UserService.findUser(UserService.java:42) - pinned on <0x00000007abc12345> (a java.lang.Object) // JVM Output when using: -Djdk.tracePinnedThreads=full Thread[ForkJoinPool-1-worker-1,5,CarrierThreads] com.example.service.UserService.findUser(UserService.java:42) <== monitors:1
โ Trace Analysis
The JVM 21 runtime cannot unmount a virtual thread from its carrier when the virtual thread enters a monitor (synchronized block or method). If I/O blocking occurs while holding that monitor โ such as a database call or REST request โ the entire OS carrier thread blocks. With a ForkJoinPool carrier pool of typically CPU_CORES threads, just a handful of pinned virtual threads can starve the entire scheduling pool.
โฆ Remediation Plan
Replace every synchronized block and method with java.util.concurrent.locks.ReentrantLock โ virtual threads can yield while waiting to acquire it.
Remove long-lived ThreadLocal usage from async/virtual thread paths; replace with ScopedValue (Preview in Java 21, stable in Java 23).
Upgrade JDBC drivers to Loom-aware versions: PostgreSQL 42.6+, MySQL Connector/J 8.3+, Oracle 23.3+.
Add -Djdk.tracePinnedThreads=full in staging environments to identify all pinning sites before they hit production.
Set spring.threads.virtual.enabled=true in Spring Boot 3.2+ and monitor carrier thread saturation with JFR or Micrometer.
// โ PINNING TRAP โ synchronized blocks pin the carrier thread class="hi-ann">@Service public class UserService { private final Object lock = new Object(); public User findUser(Long id) { synchronized (lock) { // repository.findById() blocks on JDBC I/O // The entire carrier thread is now pinned! return repository.findById(id).orElseThrow(); } } } // โ LOOM-SAFE FIX โ ReentrantLock allows virtual threads to yield class="hi-ann">@Service public class UserService { private final ReentrantLock lock = new ReentrantLock(); public User findUser(Long id) { lock.lock(); // Virtual thread parks here, carrier is FREE to run others try { return repository.findById(id).orElseThrow(); } finally { lock.unlock(); } } } // โ MODERN PATTERN โ ScopedValue instead of ThreadLocal // application.yml // spring: // threads: // virtual: // enabled: true # Enable virtual threads globally in Boot 3.2+
โ Engineering Deep-Dive
How Carrier Thread Scheduling Works
Java 21's virtual thread scheduler is a work-stealing ForkJoinPool. By default it creates CPU_CORES carrier (platform) threads. When a virtual thread calls a blocking operation โ network I/O, file read, Thread.sleep() โ the JVM unmounts it from the carrier and parks it. The carrier is immediately free to pick up another virtual thread from the run queue.
This cooperative scheduling is what makes one million concurrent virtual threads feasible. A carrier does real CPU work continuously; it never blocks. Pinning breaks this contract.
Identifying Pinning in Production
Use -Djdk.tracePinnedThreads=full in staging to log every pinning event with a full stack trace. In production, use Java Flight Recorder (JFR) โ it has a built-in VirtualThreadPinned event with zero-allocation overhead. Alert on it in your observability stack.
Third-Party Library Risk
Many mature libraries โ connection pools, caching layers, messaging clients โ heavily use synchronized. HikariCP 5.1.0+ is Loom-aware. For others, audit with JFR before enabling virtual threads in production. A single pinning hot-path in a dependency can silently cap your throughput.
ScopedValue: The ThreadLocal Successor
ThreadLocal works with virtual threads but carries a memory cost: each virtual thread gets its own copy of the value, and with millions of virtual threads that can mean significant heap pressure. ScopedValue is immutable and structurally scoped to a call tree โ lower memory, safer semantics, and zero copy-on-inherit cost.
โ Elite Standards
Engineering Rule
Audit all third-party dependencies with JFR VirtualThreadPinned events before going live โ libraries are often the biggest source of hidden pinning.
Engineering Rule
Treat synchronized as a code smell in virtual-thread-heavy services; enforce a linting rule in your CI pipeline.
Engineering Rule
Keep carrier pool size as Runtime.getRuntime().availableProcessors() (the default). Increasing it masks pinning bugs rather than fixing them.
FAQ
- What causes Virtual Thread Pinning in Spring Boot 3.2+ in Spring Boot 3?
- The JVM 21 runtime cannot unmount a virtual thread from its carrier when the virtual thread enters a monitor (synchronized block or method). If I/O blocking occurs while holding that monitor โ such as a database call or REST request โ the entire OS carrier thread blocks. With a ForkJoinPool carrier pool of typically CPU_CORES threads, just a handful of pinned virtual threads can starve the entire scheduling pool.
- How do I fix Virtual Thread Pinning in Spring Boot 3.2+?
- Replace every synchronized block and method with java.util.concurrent.locks.ReentrantLock โ virtual threads can yield while waiting to acquire it. Remove long-lived ThreadLocal usage from async/virtual thread paths; replace with ScopedValue (Preview in Java 21, stable in Java 23). Upgrade JDBC drivers to Loom-aware versions: PostgreSQL 42.6+, MySQL Connector/J 8.3+, Oracle 23.3+. Add -Djdk.tracePinnedThreads=full in staging environments to identify all pinning sites before they hit production. Set spring.threads.virtual.enabled=true in Spring Boot 3.2+ and monitor carrier thread saturation with JFR or Micrometer.
- Best practice #1 for preventing Java 21 ยท Project Loom errors?
- Audit all third-party dependencies with JFR VirtualThreadPinned events before going live โ libraries are often the biggest source of hidden pinning.
- Best practice #2 for preventing Java 21 ยท Project Loom errors?
- Treat synchronized as a code smell in virtual-thread-heavy services; enforce a linting rule in your CI pipeline.
- Best practice #3 for preventing Java 21 ยท Project Loom errors?
- Keep carrier pool size as Runtime.getRuntime().availableProcessors() (the default). Increasing it masks pinning bugs rather than fixing them.