Virtual threads became a permanent Java feature in Java 21 through Project Loom. They let applications create very large numbers of lightweight threads while retaining the familiar thread-per-request programming model.
Early virtual-thread implementations had an important limitation called pinning. A virtual thread that blocked while executing inside a synchronized method or block could remain attached, or pinned, to its carrier platform thread. Long and frequent pinning could reduce scalability.
That changed significantly in Java 24. JEP 491 changed the JVM so virtual threads can unmount from their carrier threads while holding or waiting for Java monitors. As a result, ordinary synchronized methods and blocks are no longer a major source of virtual-thread pinning.
How Java virtual threads work
Platform threads are generally backed by operating-system threads. Virtual threads are scheduled by the Java runtime and mounted onto platform threads known as carrier threads.
When a virtual thread performs a supported blocking operation, such as socket I/O, the JVM can normally unmount it from its carrier. The carrier is then free to execute another virtual thread. When the original operation is ready to continue, the virtual thread can be mounted again, potentially on a different carrier.
This mounting and unmounting behavior is a major reason virtual threads can support extremely high concurrency without requiring an equally large number of operating-system threads.
What changed with pinning in Java 24?
Before Java 24, code like this could cause a virtual thread to remain pinned if readData() blocked:
synchronized (lock) {
readData();
}Developers were therefore advised to avoid long blocking operations inside synchronized sections and, where appropriate, replace monitors with constructs such as ReentrantLock.
With Java 24 and later, that recommendation is largely obsolete. JEP 491 changed monitor ownership in the JVM so a virtual thread can unmount while inside a synchronized method or block, while waiting to enter one, and while using Object.wait().
You can now choose between synchronized and java.util.concurrent.locks based on the synchronization semantics your application needs rather than choosing ReentrantLock simply to avoid virtual-thread pinning.
What can still pin a virtual thread?
Pinning has not disappeared completely. In current Java releases, a virtual thread can still be pinned when it executes certain native code or a foreign function and then encounters a blocking operation before returning to ordinary Java code.
There are also uncommon JVM situations involving class loading or class initialization where a carrier can remain occupied. These cases are substantially narrower than the synchronized-based pinning problem that existed in Java 21 through Java 23.
Pinning itself does not make an application incorrect. It becomes a scalability concern when it occurs frequently and lasts long enough to consume a meaningful number of carrier threads.
Enable virtual threads in Spring Boot
Supported Spring Boot versions can use virtual threads through configuration:
spring:
threads:
virtual:
enabled: trueAfter enabling them, load-test the application and observe its behavior rather than assuming that virtual threads automatically improve every workload.
Detect virtual thread pinning with JFR
Older Java virtual-thread tutorials commonly recommended this JVM option:
-Djdk.tracePinnedThreads=fullDo not rely on that option on Java 24 and later. JEP 491 removed the need for the jdk.tracePinnedThreads diagnostic property, and setting it no longer provides the old synchronized-pinning diagnostics.
Java Flight Recorder is the preferred way to investigate remaining pinning. Start a recording with jcmd:
jcmd <PID> JFR.start duration=200s filename=recording.jfrThen inspect virtual-thread pinning events:
jfr print --events jdk.VirtualThreadPinned recording.jfrThe jdk.VirtualThreadPinned JFR event remains useful for the pinning situations that still exist. Modern JDKs can include information about why the thread was pinned and its carrier thread.
Do you still need ReentrantLock?
ReentrantLock is still useful, but avoiding virtual-thread pinning is no longer a general reason to replace synchronized with it on Java 24 and later.
Use ReentrantLock when you specifically need capabilities such as timed lock acquisition, interruptible lock acquisition, fairness policies or multiple Condition objects.
var lock = new ReentrantLock();
lock.lock();
try {
updateSharedState();
} finally {
lock.unlock();
}For straightforward mutual exclusion, synchronized is often simpler and less error-prone:
synchronized (lock) {
updateSharedState();
}Regardless of which mechanism you choose, keep critical sections focused and avoid unnecessary contention.
Virtual threads do not remove resource limits
Virtual threads are inexpensive, but the operations they perform might not be. A million virtual threads can still compete for database connections, sockets, memory, remote APIs and other limited resources.
Use concurrency controls when the resource itself has a real capacity limit. A semaphore is useful when only a fixed number of operations should execute concurrently:
var permits = new Semaphore(20);
permits.acquire();
try {
callLimitedResource();
} finally {
permits.release();
}The semaphore should represent the capacity of the constrained resource. Do not use an arbitrary low permit count merely to imitate the small thread pools commonly used with platform threads.
Measure virtual-thread performance
Virtual threads are primarily a scalability tool for workloads that spend significant time waiting, particularly blocking I/O workloads. They do not make CPU-bound work execute faster.
Measure the application under realistic load and pay attention to:
- Throughput. Measure completed requests or operations per second.
- Latency. Watch median and tail latency, especially p95 and p99 values.
- Carrier utilization. Look for situations where carrier threads remain occupied for unexpectedly long periods.
- JFR events. Investigate meaningful
jdk.VirtualThreadPinnedevents rather than assuming every event is a problem. - External resources. Watch database pools, sockets, API rate limits and other resources that can become bottlenecks as concurrency rises.
- CPU and memory. Verify that increased concurrency does not simply move the bottleneck elsewhere.
Java virtual thread pinning: Key points
For developers using Java 24 or later, the virtual-thread pinning story is much simpler than it was when virtual threads became generally available in Java 21:
- Virtual threads became a permanent Java feature in Java 21.
- Java 24 eliminated the major pinning problem associated with
synchronizedmethods and blocks. - You generally do not need to replace
synchronizedwithReentrantLockmerely to prevent pinning. - Native and foreign-function interactions can still produce pinning in some circumstances.
- Use JFR's
jdk.VirtualThreadPinnedevent to diagnose meaningful remaining pinning. - Do not use
-Djdk.tracePinnedThreadsas your modern Java diagnostic strategy. - Virtual threads improve scalability for waiting-heavy workloads, not raw CPU performance.
- Always load-test and measure the actual application.
The practical lesson is no longer "avoid synchronized when using virtual threads." On modern Java, developers can use normal Java synchronization constructs and concentrate their optimization efforts on the much narrower set of operations that can still pin carriers or exhaust genuinely limited resources.
A N M Bazlur Rahman is a Java Champion and staff software developer at DNAstack. He is also founder and moderator of the Java User Group in Bangladesh.