<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Felipe Maschio</title>
    <description>The latest articles on DEV Community by Felipe Maschio (@maschiojv).</description>
    <link>https://dev.to/maschiojv</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4024535%2Fb52b2b1d-7735-425f-9648-49cb6fc2c73d.jpg</url>
      <title>DEV Community: Felipe Maschio</title>
      <link>https://dev.to/maschiojv</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/maschiojv"/>
    <language>en</language>
    <item>
      <title>Your Loom App Quietly Became a Thread Pool Again: A Field Guide to Virtual Thread Pinning</title>
      <dc:creator>Felipe Maschio</dc:creator>
      <pubDate>Fri, 10 Jul 2026 21:54:18 +0000</pubDate>
      <link>https://dev.to/maschiojv/your-loom-app-quietly-became-a-thread-pool-again-a-field-guide-to-virtual-thread-pinning-2a3f</link>
      <guid>https://dev.to/maschiojv/your-loom-app-quietly-became-a-thread-pool-again-a-field-guide-to-virtual-thread-pinning-2a3f</guid>
      <description>&lt;p&gt;The incident that taught me to respect pinning looked like nothing. A service freshly migrated to virtual threads, a load test that plateaued at about 420 requests per second no matter how much traffic we threw at it, CPU sitting at 9%, zero errors, zero warnings, nothing in the logs. The machine had 8 cores, and the one downstream HTTP call in the hot path took about 19 ms. Do the arithmetic: 8 × (1000 / 19) ≈ 421.&lt;/p&gt;

&lt;p&gt;The service that was supposed to scale to millions of virtual threads was serving exactly one request per CPU core. Loom had quietly handed us back a bounded thread pool, and the code looked perfectly innocent. That failure mode has a name — &lt;strong&gt;pinning&lt;/strong&gt; — and this is the field guide I wish I'd had that night: what it is, the two (and only two) things that cause it, what JDK 24 changed, and how to catch it before your throughput graph does.&lt;/p&gt;

&lt;h2&gt;
  
  
  What pinning actually is
&lt;/h2&gt;

&lt;p&gt;A virtual thread doesn't own an OS thread. It runs on a small pool of platform threads called &lt;strong&gt;carrier threads&lt;/strong&gt; — concretely, the workers of a dedicated &lt;code&gt;ForkJoinPool&lt;/code&gt; living in a thread group named &lt;code&gt;CarrierThreads&lt;/code&gt;, with default parallelism equal to &lt;code&gt;Runtime.availableProcessors()&lt;/code&gt;. When a virtual thread blocks — on I/O, a lock, a queue — it normally &lt;strong&gt;unmounts&lt;/strong&gt;: it saves its stack, steps off the carrier, and frees that carrier to run another virtual thread. That unmount is the entire trick that lets a handful of OS threads serve millions of virtual ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pinning is when the unmount can't happen.&lt;/strong&gt; The virtual thread blocks but stays mounted, and its carrier sits there doing nothing useful for the whole duration. One pinned carrier is a rounding error. But the default carrier pool is only as big as your core count, so if a hot path pins routinely, you pin &lt;em&gt;every&lt;/em&gt; carrier at once — and then no virtual thread anywhere makes progress. That's not a slowdown; it's scheduler starvation, and from the outside it looks a lot like a deadlock. You can raise the ceiling with &lt;code&gt;-Djdk.virtualThreadScheduler.parallelism=N&lt;/code&gt;, but that only delays the moment of exhaustion. It doesn't fix anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two causes — and it really is just two
&lt;/h2&gt;

&lt;p&gt;There are exactly two situations where the JVM cannot unmount a blocked virtual thread:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Blocking inside &lt;code&gt;synchronized&lt;/code&gt; (JDK 21 through 23).&lt;/strong&gt; Up to and including JDK 23, an object monitor is tied to the carrier thread that entered it. If a virtual thread blocks — or calls &lt;code&gt;Object.wait()&lt;/code&gt; — while holding a monitor, the JVM can't move it off the carrier without breaking monitor ownership, so it pins. This is by far the most common cause in real code, because a blocking call buried inside a &lt;code&gt;synchronized&lt;/code&gt; method is trivial to write and invisible at the call site. And the monitor doesn't have to be &lt;em&gt;yours&lt;/em&gt;: &lt;code&gt;synchronized&lt;/code&gt; inside a library, or inside the JDK itself, pins exactly the same way. &lt;code&gt;ConcurrentHashMap.computeIfAbsent&lt;/code&gt; runs your mapping function under an internal bin lock — put a blocking call inside it and you've pinned a carrier without a single &lt;code&gt;synchronized&lt;/code&gt; keyword in your own code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Native frames.&lt;/strong&gt; When a virtual thread has a native method (JNI) or a foreign downcall (the Foreign Function &amp;amp; Memory API) on its stack and it blocks, the JVM can't capture and restore the native frame, so it pins. This one has no &lt;code&gt;synchronized&lt;/code&gt; to blame — and it is &lt;em&gt;not&lt;/em&gt; fixed by JDK 24. It also hides in a place nobody expects: class initialization runs through native frames, so a blocking call inside a static initializer pins even on the newest JDKs.&lt;/p&gt;

&lt;p&gt;Just as important is what's &lt;strong&gt;not&lt;/strong&gt; on the list: ordinary blocking I/O through the JDK (&lt;code&gt;Socket&lt;/code&gt;, &lt;code&gt;InputStream&lt;/code&gt;, &lt;code&gt;Files&lt;/code&gt;), &lt;code&gt;BlockingQueue&lt;/code&gt;, &lt;code&gt;ReentrantLock&lt;/code&gt;, &lt;code&gt;CompletableFuture&lt;/code&gt;, &lt;code&gt;Thread.sleep()&lt;/code&gt; — all of it was re-plumbed for Loom and unmounts cleanly. Pinning is a short, specific list, which is exactly why it's detectable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The canonical bug
&lt;/h2&gt;

&lt;p&gt;Nearly every real pin I've read in a dump is some flavor of a cache or rate limiter guarding a slow call with &lt;code&gt;synchronized&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PriceService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HashMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Looks harmless. On JDK 21-23 it pins the carrier for the whole HTTP call.&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;synchronized&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="nf"&gt;lookup&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;symbol&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;computeIfAbsent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;symbol&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;httpClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;quote&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;   &lt;span class="c1"&gt;// &amp;lt;-- blocks while holding the monitor&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every cache miss blocks on the network &lt;em&gt;while holding the monitor&lt;/em&gt;. On JDK 21–23 that virtual thread pins its carrier for the entire round trip. Run a few hundred concurrent requests and you've pinned every carrier; the rest of the workload queues behind a monitor that never unmounts. That's my 420-requests-per-second incident in five lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  What JDK 24 changed (JEP 491)
&lt;/h2&gt;

&lt;p&gt;JDK 24 shipped &lt;a href="https://openjdk.org/jeps/491" rel="noopener noreferrer"&gt;JEP 491, "Synchronize Virtual Threads without Pinning"&lt;/a&gt;. It reworked monitor ownership so the monitor is associated with the virtual thread itself rather than its carrier — which means a virtual thread &lt;em&gt;can&lt;/em&gt; now unmount while blocked inside &lt;code&gt;synchronized&lt;/code&gt;, while waiting to enter one, or while parked in &lt;code&gt;Object.wait()&lt;/code&gt;. The most common cause of pinning simply goes away on JDK 24+, with no code change.&lt;/p&gt;

&lt;p&gt;Two practical consequences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On JDK 24+, the only remaining pins come from native frames — JNI, FFM downcalls, and class initialization.&lt;/li&gt;
&lt;li&gt;The old detection flag &lt;code&gt;-Djdk.tracePinnedThreads&lt;/code&gt; was &lt;strong&gt;removed&lt;/strong&gt; in JDK 24. Don't ship runbooks that depend on it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're on JDK 21–23, though, &lt;code&gt;synchronized&lt;/code&gt; pinning is very much alive, and upgrading is often the single cleanest fix you can make.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to catch it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;On JDK 21–23 — the legacy flag.&lt;/strong&gt; Run with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;java &lt;span class="nt"&gt;-Djdk&lt;/span&gt;.tracePinnedThreads&lt;span class="o"&gt;=&lt;/span&gt;full &lt;span class="nt"&gt;-jar&lt;/span&gt; app.jar
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The JVM prints a stack trace every time a virtual thread pins, and the frame annotated &lt;code&gt;&amp;lt;== monitors:1&lt;/code&gt; is the culprit. That one line is the whole diagnosis. Just remember this flag no longer exists on JDK 24.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everywhere — JFR.&lt;/strong&gt; Since JDK 21 the JVM emits a &lt;code&gt;jdk.VirtualThreadPinned&lt;/code&gt; Flight Recorder event when a virtual thread blocks while pinned. It's enabled by default — but with a &lt;strong&gt;20 ms threshold&lt;/strong&gt;, so short pins are invisible unless you lower it. In JDK 24 the event got better: it's emitted for every pinning occurrence and carries the reason and the carrier's identity. Since native-frame pins still fire it, this is the detection you should wire into production:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;java &lt;span class="nt"&gt;-XX&lt;/span&gt;:StartFlightRecording&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;filename&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;rec.jfr,settings&lt;span class="o"&gt;=&lt;/span&gt;profile &lt;span class="nt"&gt;-jar&lt;/span&gt; app.jar
jfr print &lt;span class="nt"&gt;--events&lt;/span&gt; jdk.VirtualThreadPinned rec.jfr
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;From a thread dump.&lt;/strong&gt; Plain &lt;code&gt;jstack&lt;/code&gt; won't show you virtual threads at all. Use the virtual-thread-aware dump:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;jcmd &amp;lt;pid&amp;gt; Thread.dump_to_file &lt;span class="nt"&gt;-format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;json dump.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It lists the carrier threads and the virtual thread mounted on each. A carrier in the &lt;code&gt;CarrierThreads&lt;/code&gt; group that is blocked while its mounted virtual thread sits in a &lt;code&gt;synchronized&lt;/code&gt; frame (or a native frame) is the visual signature of a pin. Count how many carriers show it versus your pool size — that ratio tells you how close you are to full starvation.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to fix it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Swap &lt;code&gt;synchronized&lt;/code&gt; for &lt;code&gt;ReentrantLock&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;java.util.concurrent.locks.ReentrantLock&lt;/code&gt; is Loom-aware: a virtual thread that blocks on it, or while holding it, unmounts cleanly. This is the direct, version-independent fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upgrade to JDK 24+.&lt;/strong&gt; JEP 491 removes the &lt;code&gt;synchronized&lt;/code&gt; pin entirely. Native-frame pins remain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't hold a lock across an external call.&lt;/strong&gt; Often the honest fix is structural: compute the value outside the critical section and only lock the map update.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For native/FFM pins, isolate the path.&lt;/strong&gt; Run unavoidable blocking native calls on a dedicated platform-thread executor, or size the carrier pool so a few concurrent pins can't starve everything.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's the rewrite of the example. One trap to avoid: don't just move the blocking call into &lt;code&gt;ConcurrentHashMap.computeIfAbsent&lt;/code&gt; — as noted above, its mapping function runs under an internal bin lock, and on JDK 21–23 you'd have rebuilt the same pin one layer down.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PriceService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ConcurrentHashMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ReentrantLock&lt;/span&gt; &lt;span class="n"&gt;lock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ReentrantLock&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="nf"&gt;lookup&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;symbol&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;symbol&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cached&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;lock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;lock&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;                       &lt;span class="c1"&gt;// Loom-aware: unmounts if it blocks&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;symbol&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;    &lt;span class="c1"&gt;// re-check under the lock&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cached&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
            &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;quote&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;quote&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;symbol&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// blocks; carrier is freed&lt;/span&gt;
            &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;symbol&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quote&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;quote&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;lock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;unlock&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This no longer pins anywhere — though it still serializes cache misses behind one lock, which is fix #3's territory: the &lt;em&gt;next&lt;/em&gt; refinement is not holding any lock across the network call at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I ended up automating the read
&lt;/h2&gt;

&lt;p&gt;Doing this analysis by hand — turn on a flag, reproduce, dump, find the carriers, match frames — is fine once. It's tedious by the tenth incident, and worse, half the tooling depends on remembering to enable something &lt;em&gt;before&lt;/em&gt; the problem happens. So I built a tool that does the read on any thread dump you give it: it finds the carriers, checks what's mounted on each, flags the pinned ones with the offending frame, and reports pinned-carriers-versus-pool-size — the number that tells you whether you're one bad path away from starvation. It's &lt;a href="https://threadmine.dev/en/analyze" rel="noopener noreferrer"&gt;ThreadMine&lt;/a&gt;; the web analyzer is free and takes a dump with no signup. Full disclosure: it's my project — I got tired of reading dumps by hand, so I automated the part I kept repeating. And a fair caveat: a dump is a snapshot, so for intermittent pinning, JFR is still the better signal.&lt;/p&gt;

&lt;p&gt;If you want the deeper reference — carriers, JEP 491, the full detection matrix — I keep it updated here: &lt;a href="https://threadmine.dev/en/resources/virtual-thread-pinning" rel="noopener noreferrer"&gt;virtual thread pinning&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Pinning is the one Loom failure mode that cancels your scalability story without a single error in the logs. The rules are short: only &lt;code&gt;synchronized&lt;/code&gt; (pre-JDK 24) and native frames pin; detect with &lt;code&gt;jdk.tracePinnedThreads&lt;/code&gt; on 21–23 and the &lt;code&gt;jdk.VirtualThreadPinned&lt;/code&gt; JFR event everywhere; fix with &lt;code&gt;ReentrantLock&lt;/code&gt;, an upgrade, or by not holding locks across slow calls. Know the shape, and it stops being invisible.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Felipe Maschio is the founder of &lt;a href="https://threadmine.dev/en" rel="noopener noreferrer"&gt;ThreadMine&lt;/a&gt;, a free JVM thread dump analyzer that detects deadlocks, thread leaks, pool exhaustion, CPU spikes and virtual thread pinning.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>java</category>
      <category>jvm</category>
      <category>performance</category>
      <category>concurrency</category>
    </item>
  </channel>
</rss>
