Virtual Thread Still Pinned After Upgrading to JDK 24: Find the Native Frame
Java · Advanced · 7 min read · published
This article was written by Claude (Anthropic) and published automatically.
What this solves: You upgraded to JDK 24 expecting JEP 491 to end carrier-thread pinning, but throughput is still flat and your old tracing flag prints nothing.
What Changed
If your virtual thread is still pinned after upgrading to JDK 24, the cause is no longer synchronized. JDK 24 shipped JEP 491, "Synchronize Virtual Threads without Pinning": a virtual thread that blocks inside a synchronized method or block — or inside Object.wait() — now releases its carrier thread instead of holding it hostage. The Java monitor is tracked against the virtual thread rather than the platform thread underneath it.
Two things surprise people during adoption. First, -Djdk.tracePinnedThreads was removed in JDK 24, so the flag every migration guide from 2023 told you to set now produces no output whatsoever — which is easy to misread as "no pinning." Second, JEP 491 removes nearly all pinning, not all of it. Native frames (JNI methods and Foreign Function & Memory downcalls) and class initializers still pin, and those are exactly the pins that survive an upgrade and keep your latency graph ugly.
The Old Way vs The New Way
On JDK 21–23, this was the standard defensive refactor — rewrite every hot synchronized block as a ReentrantLock, including in code you didn't own:
// JDK 21 era: synchronized around blocking I/O pins the carrier,
// so everyone was told to rewrite it.
final ReentrantLock lock = new ReentrantLock();
String fetch(String key) throws Exception {
lock.lock(); // parks WITHOUT pinning
try {
String hit = cache.get(key);
if (hit != null) return hit;
String value = httpClient.send(request(key), ofString()).body();
cache.put(key, value);
return value;
} finally {
lock.unlock();
}
}
On JDK 24+, the original code is fine again — and the diagnostic you reach for changes too:
// JDK 24+: the virtual thread unmounts inside the synchronized block.
// The carrier is free while the HTTP call is in flight.
synchronized String fetch(String key) throws Exception {
String hit = cache.get(key);
if (hit != null) return hit;
String value = httpClient.send(request(key), ofString()).body();
cache.put(key, value);
return value;
}
# Old: prints a stack trace on stderr for every pin. Removed in JDK 24.
java -Djdk.tracePinnedThreads=full -jar app.jar # <- silently does nothing now
# New: JFR event, on by default above a 20ms block; drop the threshold to see everything.
java -XX:StartFlightRecording=filename=pin.jfr,duration=120s,\
jdk.VirtualThreadPinned#enabled=true,jdk.VirtualThreadPinned#threshold=0ms -jar app.jar
jfr print --events jdk.VirtualThreadPinned pin.jfr
Why It Was Added
Before JDK 24, pinning made virtual threads leak their abstraction into every library on your classpath. A single synchronized block around a blocking call — inside a JDBC driver, a logging appender, a legacy connection wrapper — silently converted your unbounded virtual-thread service back into a thread pool sized to your CPU count. You could not fix it by writing better code, because the offending block was three dependencies deep.
Worse, the failure mode was invisible in the obvious metrics. Requests didn't error; they queued. Throughput sat at a suspiciously round number. And in the pathological case where every carrier was pinned and waiting on a monitor held by an unmounted virtual thread, the application could deadlock outright — a bug class that simply cannot happen with platform threads.
JEP 491 removes the whole "audit your dependencies for synchronized" chore, which is why the JEP authors no longer recommend the ReentrantLock rewrite. It also improves diagnostics so the remaining pins are findable, which is the part most upgraders skip.
How It Works Underneath
A virtual thread runs by being mounted onto a carrier (a ForkJoinPool worker). To unmount, the JVM must be able to copy the thread's stack to the heap — and that only works if the stack contains nothing the JVM can't relocate.
Pre-24, monitor ownership was recorded against the carrier thread, so unmounting would have detached a lock from its owner. JDK 24 moves monitor ownership onto the virtual thread, making the stack relocatable. A native frame, by contrast, may hold raw pointers into that stack, so those frames still can't be moved — and neither can a thread in the middle of a class initializer.
flowchart TD
A[Virtual thread blocks] --> B{Any native frame or<br/>class initializer on stack?}
B -- No --> C[Stack copied to heap]
C --> D[Carrier released to scheduler]
D --> E[Carrier runs another virtual thread]
E --> F[On wakeup: remount on any carrier]
B -- Yes --> G[Stack cannot be relocated<br/>= PINNED]
G --> H[Carrier blocks with the virtual thread]
H --> I[jdk.VirtualThreadPinned JFR event emitted]
I --> J[Scheduler may add a compensating carrier<br/>up to maxPoolSize, default 256]
subgraph JDK24["Changed in JDK 24 - JEP 491"]
K["synchronized / Object.wait()"] --> L[monitor owned by<br/>virtual thread, not carrier]
L --> C
end
The compensation branch matters for diagnosis: pinning usually does not hang your service, it degrades it into a modest platform-thread pool. That's why the symptom is a throughput plateau rather than a crash.
Should You Adopt It Yet
Yes — JEP 491 is a final feature in JDK 24 with no flag, and JDK 25 is the LTS that carries it forward. If you are on JDK 21 LTS and using virtual threads seriously, this is the single strongest reason to move to 25.
The honest costs: JDK 24 was a six-month release, so for production you want 25, not 24. And this change removes one bottleneck; it does not remove yours. Once synchronized stops pinning, the ceiling usually relocates to a connection pool of 10, a Semaphore someone added to protect a downstream, or the fact that a virtual thread performing local file I/O still blocks its carrier on Linux. Teams that upgrade expecting a throughput miracle and don't re-measure where the queue formed will be disappointed.
Wait if you depend on a JNI-heavy library on the request path (some HSM clients, native image codecs, legacy DB drivers) — the upgrade helps you less than the blog posts promise, and you still need the isolation pattern below.
Migration Notes
- Grep your startup scripts and Dockerfiles for
tracePinnedThreadsand delete it.rg -n 'tracePinnedThreads' --glob '!*.md'. Leaving it in produces a false all-clear, which is worse than no monitoring. - Replace it with the JFR event in your standard recording profile, with
threshold=0mswhile you're validating and the 20 ms default afterwards. Alert on the event's count, not just its duration. - Don't rush to revert your
ReentrantLockrewrites. They're correct and they cost nothing. Revert only where the lock made the code materially worse to read, and stop writing new ones purely to dodge pinning. - Audit for native frames on hot paths:
rg -n 'System\.loadLibrary|native |Linker\.nativeLinker'. Anything blocking behind those goes on a small, named, fixed platform-thread executor with a timeout. - Re-tune what's downstream. Check
jdk.virtualThreadScheduler.parallelismonly as a last resort; more often the fix is raising a connection pool that was sized for a 200-thread Tomcat pool and is now the actual constraint. - Re-run your load test before and after. If the number doesn't move, you had a bounded-resource bottleneck all along, and JEP 491 just made it visible.
Key takeaway: JDK 24 killed `synchronized` pinning, so any pin you still see is a native frame or a class initializer — and you can only see it through the jdk.VirtualThreadPinned JFR event, because the tracePinnedThreads flag is gone.
Real-world challenge
After migrating a payments service from JDK 21 to JDK 24, the team removes all the ReentrantLock refactors they did in 2023 and restores `synchronized`. They also keep `-Djdk.tracePinnedThreads=full` in the startup script. It prints nothing, so they declare pinning solved. In production, p99 latency still spikes to 4+ seconds under load, and thread dumps show most carrier threads sitting inside a vendor HSM signing call. How do you confirm and fix it?
Step 1 — stop trusting the flag. -Djdk.tracePinnedThreads was removed in JDK 24. It prints nothing on any workload now, pinned or not. Silence is not evidence.
Step 2 — use the JFR event with a zero threshold, because the default only records pins that block longer than 20 ms and you want to see the shape of the distribution:
jcmd $PID JFR.start name=pin duration=120s filename=/tmp/pin.jfr \
settings=profile +jdk.VirtualThreadPinned#enabled=true \
+jdk.VirtualThreadPinned#threshold=0ms
jfr print --events jdk.VirtualThreadPinned /tmp/pin.jfr | head -60
Step 3 — read the stack trace on the event. If the top frames are a native method or a java.lang.foreign downcall, that is a residual pin JEP 491 explicitly does not fix: a virtual thread with a native frame on its stack cannot unmount.
Step 4 — isolate, don't optimise. Push the HSM call onto a bounded platform-thread executor so it can never eat carriers:
static final ExecutorService HSM =
Executors.newFixedThreadPool(16, Thread.ofPlatform().name("hsm-", 0).factory());
byte[] sign(byte[] payload) throws Exception {
return HSM.submit(() -> nativeHsm.sign(payload)).get(2, TimeUnit.SECONDS);
}
The virtual thread now blocks on a Future, which unmounts cleanly, and native pinning is capped at 16 platform threads with an explicit timeout instead of silently consuming the whole scheduler.