I ended yesterday's post with a line I left hanging: "a badly placed synchronized will wreck your virtual thread." I left it there because I didn't have the precise data on hand and didn't want to make it up. Today I have it. I went straight to the source — JEP 444, the one that introduced virtual threads in Java 21 — and there it is, in plain text, no ambiguity: there are two cases. Not three, not "it depends on context," not "several possible scenarios." Two.
That precision matters to me. If you're evaluating migrating a backend with legacy code to virtual threads, the question isn't "can pinning happen?" The question is "does my code touch either of these two specific cases?" And that's a question you can actually answer with a grep, not with guesswork.
What JEP 444 Says About Pinning (Verbatim)
The JEP doesn't hedge. Quoting directly from the "Detecting and diagnosing pinning" section:
"A virtual thread cannot be unmounted during blocking operations when it is pinned to its carrier thread. This can happen in two cases: First, when it executes code inside a synchronized block or method. Second, when it executes a native method or a foreign function."
Translating without losing precision: a virtual thread gets pinned (can't unmount from the carrier thread) in exactly two situations:
- Inside a
synchronizedblock or method — the JVM's native monitor doesn't know how to handle unmounting virtual threads, so if the thread blocks in there (say, waiting on I/O inside the block), it stays stuck to the carrier. - Executing a native method or a foreign function — JNI code, or any call that crosses over into native territory via the Foreign Function & Memory API. The JVM has no visibility or control over what that code does on the other side, so it can't unmount it.
Outside of those two cases, a blocked virtual thread unmounts from the carrier and frees that carrier thread for other work. That's literally the entire point of Project Loom. If your block falls into one of these two cases, that doesn't happen. The carrier stays occupied like it was a traditional platform thread.
The Minimal Experiment: Reproducing Pinning With Synchronized
You don't need a production benchmark to see this. This snippet is enough, run with -Djdk.tracePinnedThreads=full (the diagnostic flag the JEP itself mentions):
// Synchronized block + blocking operation inside = pinning
Object monitor = new Object();
Thread.startVirtualThread(() -> {
synchronized (monitor) {
try {
Thread.sleep(Duration.ofSeconds(1)); // blocking INSIDE the synchronized
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});With that flag on, the JVM prints the exact stack trace of the pinning point to console. If you move the Thread.sleep outside the synchronized block, the pinning disappears — the virtual thread unmounts normally during the sleep. That contrast (inside vs. outside the block) is the most direct way to see it with your own eyes, no need for anyone else's metrics.
Where People Get It Wrong
The recipe I keep seeing repeated is: "just swap every synchronized for ReentrantLock and you're virtual-thread-friendly." That recipe isn't false, but it's lazy. My take is that not every synchronized carries the same risk, and treating them as equal is how you end up rewriting locks you didn't need to touch.
A synchronized that protects a pure in-memory operation — incrementing a counter, updating an in-memory map — doesn't block on any I/O inside. There, pinning technically exists (the thread gets marked as pinned while holding the monitor) but the duration is so short it doesn't practically compete with anything. The real problem shows up when there's a blocking call inside the synchronized: a database query, a Thread.sleep, a socket read.
The counterexample worth keeping in mind: a synchronized method that calls Files.readAllBytes() on a big file. There it does matter, and there you do need to rewrite. But going out and changing every synchronized in a legacy codebase without distinguishing these cases is wasted work — and in concurrent code, touching locks without a real need is the fastest way to introduce a race bug that wasn't there before. I've seen that exact mistake turn a "safety" refactor into a bug hunt.
For the native/JNI case, the situation is different: there's no "it depends." If the code crosses into native, it's pinned for the entire duration of that call, period. There's no short or long version that changes the diagnosis.
Decision Matrix: What to Check First
| Situation in the code | Pinning risk | What to check first |
|---|---|---|
synchronized with only in-memory operations | Low, minimal duration | Confirm there's no I/O inside before touching anything |
synchronized with blocking I/O inside (DB, file, socket) | High | Migrate to ReentrantLock or move the I/O outside the block |
| JNI calls or Foreign Function & Memory API | Total for the duration of the call | Evaluate whether that native dependency is avoidable or isolate it in its own pool |
Legacy libraries with hidden internal synchronized | Unknown without profiling | Run with -Djdk.tracePinnedThreads=full before migrating |
| Virtual thread pool with carriers running out under load | Suspicious | Check pinning logs before assuming it's some other bottleneck |
This table doesn't replace measuring. Each row is a criterion for deciding where to look first, not a conclusion about what will happen in a specific system.
The Limits of This
What I can state with the JEP in hand are the two documented cases and the behavior of the diagnostic flag — that's official text, verifiable by anyone who opens the link. What I can't state without running something real is how much pinning impacts a specific production system: that depends on how many virtual threads are competing for how many carriers, how long each block lasts, and how many cores the machine has. Without that experiment with your own data, any number I gave you would be made up. If yesterday's post left this precision out, it was precisely to avoid falling into that generalization.
It's also not a universal conclusion to say "rewrite every synchronized." The JEP describes the mechanism, it doesn't tell you what proportion of your legacy code falls into the risky case. You have to profile that yourself, with the trace flag, before deciding what to touch. That's the boundary I hold to: documented mechanism, yes; blanket prescription for your codebase, no.
If you already read the analysis on virtual threads and the general limitations of the Loom model, this completes that piece — /blog/virtual-threads-java-loom-limitaciones has the broader picture, this post is the fine print on one specific point.
FAQ
What does it mean for a virtual thread to be "pinned" to the carrier thread? It means it can't unmount during a blocking operation. Instead of freeing the carrier thread for another virtual thread to use, the carrier stays occupied waiting, just like with a traditional platform thread.
Does every synchronized cause pinning?
Technically yes, while holding the monitor. But the real impact depends on whether there's a blocking operation inside the block. A short synchronized over pure memory doesn't create a practical problem.
How do I detect pinning in code I didn't write?
With the -Djdk.tracePinnedThreads=full flag at JVM startup. It prints the exact stack trace of every pinning event, so you can see if it comes from a synchronized or a native call.
Does ReentrantLock always replace synchronized with no side effects?
Not without reviewing the code. ReentrantLock isn't automatically equivalent in the same semantic sense if the code depends on specific details of the native monitor (like interruption during the wait). You have to read the method, not just find-and-replace.
Does the JNI case only apply if I use JNI directly? No. It also applies if a third-party library you use internally makes native calls — old database drivers, compression, cryptography with native bindings. The pinning happens at the native call, regardless of who wrote it.
Does this problem exist in Java 21 or was it fixed in later versions? JEP 444 documents the behavior as it was delivered in Java 21 (the version that stabilized virtual threads). I can't tell you what a future JEP might change here — what I can tell you is that with the public evidence available today, these are the two documented cases, no more.
My Take
Pinning isn't blog folklore or some generic "watch out for concurrency" warning. It's two cases, documented by name in the official JEP, and you can search for them in a codebase with a well-aimed grep. If you're migrating legacy code to virtual threads, the first step isn't rewriting everything — it's running with the trace flag and seeing what shows up. After that, only after that, do you decide what to touch. Anyone who tells you to rewrite every synchronized before measuring is selling you a shortcut that costs more than it saves.
Original source:
- JEP 444: Virtual Threads — https://openjdk.org/jeps/444 </content>
Looking for this approach on your team?
Explore my technical case studies or discuss a senior role, architecture and technical leadership.
Related Articles
gRPC shows up when REST starts breaking under its own weight
gRPC solves inter-service communication with code generation and HTTP/2. It's fast, it's typed, and the learning curve is going to hurt a bit. Spoiler: it's worth it.
Oct 10 2026 · 5′ · Experiments · java · rpc
Apache Spark won't melt down when your data doesn't fit in RAM anymore
Apache Spark has been the de facto standard for processing data at scale for over a decade. We go over why it's still alive, how it works under the hood, and when you should NOT use it.
Oct 08 2026 · 5′ · Experiments · java · big data
Actuator /env: what changes when show-values is enabled
Correction: Spring Boot 3+ hides values by default. Start with the exact version and effective show-values configuration.
Sep 11 2026 · 2′ · Tutorials · spring-boot · java
Comments (0)
What do you think of this?
Drop your comment in 10 seconds.
We only use your login to show your name and avatar. No spam.
No comments yet. Be the first — your take matters most when we're few.