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.
flowchart TD
A[Virtual thread executes] --> B{Blocking inside synchronized or native/JNI?}
B -->|Yes| C[Pinning: carrier thread stays occupied]
B -->|No| D[Normal unmount: frees the carrier]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>
Related Articles
Actuator Endpoints in Spring Boot: Allowlist, Don't Just Disable the Obvious Ones
Spring Boot Actuator exposes more by default than most teams realize. The difference between an endpoint that's useful for monitoring and a map of environment variables handed to an attacker comes down to a decision almost nobody makes explicitly: allowlist versus disabling what looks obvious.
Aug 23 2026 · 8′ · Tutorials · spring-boot · java
What It Means to Be a Java Champion in 2026: The Real Criteria Behind the Recognition and Why I Care
Java Champion isn't an academic title or a corporate badge. It's recognition of community contribution — and in 2026, quality technical content in Spanish is an undervalued and completely legitimate contribution. My declared goal and the real criteria behind the program.
Aug 18 2026 · 10′ · Tutorials · open source · spring-boot
CAdES vs XAdES Digital Signatures in Java: The Differences That Matter When Your CA Asks for One and You've Built the Other
CAdES and XAdES aren't interchangeable even though both are "advanced signatures." The choice depends on document type, trust profile, and what the CA actually expects to validate. A technical guide with DSS and Java to make the right call before you sign anything.
Aug 12 2026 · 9′ · Tutorials · seguridad · certificados
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.