<?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>Masking a JVM thread dump without breaking the analysis</title>
      <dc:creator>Felipe Maschio</dc:creator>
      <pubDate>Tue, 08 Sep 2026 02:21:26 +0000</pubDate>
      <link>https://dev.to/maschiojv/masking-a-jvm-thread-dump-without-breaking-the-analysis-c0b</link>
      <guid>https://dev.to/maschiojv/masking-a-jvm-thread-dump-without-breaking-the-analysis-c0b</guid>
      <description>&lt;p&gt;A few weeks ago I posted on r/java about detecting virtual thread pinning from a plain thread dump. The technical discussion was fine. The comment that stuck with me had nothing to do with pinning: "the problem isn't taking the dump, it's that uploading a production dump to a third-party site is against policy". Not a preference. Policy. And the person saying it was right.&lt;/p&gt;

&lt;p&gt;I run ThreadMine, a hosted thread dump analyzer, so that comment describes my own product. This article is about what I built in response: a small open-source CLI that rewrites the dump on your machine before it goes anywhere, in a way that keeps the analysis working. It's called &lt;code&gt;tm-anon&lt;/code&gt;, it's MIT, and it works with any analyzer, mine included. The interesting part isn't the tool, it's the constraint: you can't just strip everything, because the analyzer needs most of what's in there.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a thread dump actually leaks
&lt;/h2&gt;

&lt;p&gt;Take one line from a real dump:&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="n"&gt;at&lt;/span&gt; &lt;span class="n"&gt;com&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;acme&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;LedgerService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;applyEntry&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LedgerService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;java&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;95&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Package, class, method, source file. Multiply by a few hundred frames and you have a map of your codebase: module names, the vendor you integrate with (&lt;code&gt;com.acme.payment.gateway.AcquirerClient&lt;/code&gt;), which parts of the system talk to which. Thread names are worse, because people put things in them. &lt;code&gt;pgto-worker-3&lt;/code&gt; is harmless; &lt;code&gt;sync-tenant-acme-prod&lt;/code&gt; or a thread named after a request path is a customer identifier sitting in a text file.&lt;/p&gt;

&lt;p&gt;Two dialects leak a lot more than the classic &lt;code&gt;jstack&lt;/code&gt; output. An OpenJ9 javacore carries the full command line with its &lt;code&gt;-D&lt;/code&gt; properties, the complete classpath, local file paths, monitor tables listing your class names, and the loaded-class list. And the JSON dialect of &lt;code&gt;jcmd Thread.dump_to_file&lt;/code&gt; (JDK 21+) has a &lt;code&gt;container&lt;/code&gt; field per thread group that holds the &lt;code&gt;toString()&lt;/code&gt; of the executor or &lt;code&gt;StructuredTaskScope&lt;/code&gt; owning it, something like &lt;code&gt;com.acme.batch.LedgerScope@4f2b1a&lt;/code&gt;. It's one of the few places where one of your classes names itself outside a stack frame, and it's easy to miss if you only think in terms of frames.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "mask everything" doesn't work
&lt;/h2&gt;

&lt;p&gt;A thread dump analyzer, any of them, doesn't read your code. It reads structure and public names. The detectors that matter key on things like &lt;code&gt;java.util.concurrent.ThreadPoolExecutor.getTask&lt;/code&gt; to tell an idle worker from a stuck one, on &lt;code&gt;http-nio-8080-exec-&lt;/code&gt; and &lt;code&gt;ForkJoinPool-1-worker-&lt;/code&gt; to group pool threads, on lock addresses to build the deadlock graph, on thread states, and on the &lt;code&gt;-N&lt;/code&gt; suffix of a pool thread to count how many workers share a prefix. Replace those with opaque tokens and starvation detection, pool grouping and deadlock analysis all go quiet at once.&lt;/p&gt;

&lt;p&gt;So the rule that came out of reading the detector code is short: everything the JDK or a known framework put in the dump stays byte for byte, and everything that's yours becomes a token. The list of what stays, the allowlist, is a JSON file in the repository, with the rationale for each family written next to it, so a reviewer can disagree with an entry and rebuild. That list is the whole design; the rest is plumbing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a name becomes a token
&lt;/h2&gt;

&lt;p&gt;Each identifier is hashed with HMAC-SHA256 under a 256-bit key that lives in a local vault file, and the first 40 bits become the token. Same key, same name, same token, in any dump, forever. That property is what keeps multi-dump comparison working after masking: the thread you were watching on Monday has the same token on Tuesday.&lt;/p&gt;

&lt;p&gt;Keyed HMAC rather than a plain hash is not a stylistic choice. Java identifiers are guessable. &lt;code&gt;SHA-256("com.acme.OrderService")&lt;/code&gt; falls to a dictionary attack in seconds, because the attacker can hash candidate names and compare. With a keyed hash, whoever doesn't hold the vault key can't even test a guess. The vault also stores the reverse map, and &lt;code&gt;tm-anon init --encrypt&lt;/code&gt; seals it with a passphrase (PBKDF2, 600,000 iterations, then AES-256-GCM, both straight from the JDK), so a stolen laptop image is useless without it.&lt;/p&gt;

&lt;p&gt;The token grammar has a shape, &lt;code&gt;p…x…&lt;/code&gt; for a package segment, &lt;code&gt;C…x…&lt;/code&gt; for a class, &lt;code&gt;m…x…&lt;/code&gt; for a method, &lt;code&gt;t…x…&lt;/code&gt; for a thread, and that shape was chosen against the analyzer's own regexes. Each package segment gets its own token so &lt;code&gt;package.Class.method(&lt;/code&gt; still parses and a flame graph still groups by package. The numeric suffix of a pool thread survives, so &lt;code&gt;pgto-worker-1&lt;/code&gt; becomes &lt;code&gt;t426f3xd05a4-1&lt;/code&gt; and the analyzer still sees a pool of N. The &lt;code&gt;x&lt;/code&gt; in the middle keeps the token from ever looking like a hex address or a request id, which is a real thing one of the detectors checks for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The flow, from the point of view of someone who has never run it
&lt;/h2&gt;

&lt;p&gt;You create a vault once per project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;tm-anon init
&lt;span class="go"&gt;Created vault: /home/you/work/tm-anon-vault.json
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then you mask the dump. This writes a new file next to the original:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;tm-anon mask payments-prod.txt
&lt;span class="gp"&gt;masked  payments-prod.txt -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;payments-prod.anon.txt
&lt;span class="go"&gt;lines:  78 preserved, 10 tokenized, 22 stripped, 0 redacted
verify: PASS - no identifier survived and the structure is intact.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;verify&lt;/code&gt; line is the part I'd point a reviewer at. Before writing anything, &lt;code&gt;mask&lt;/code&gt; hands its own output to a separate verifier that re-derives every identifier from the masked text instead of trusting the rewriter, and refuses to write the file if anything that isn't a token, an allowlisted name or pure structure survived. A half-masked dump can't leave the machine by accident, because it never gets written.&lt;/p&gt;

&lt;p&gt;This is what the same frame looks like on each side:&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="n"&gt;at&lt;/span&gt; &lt;span class="n"&gt;com&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;acme&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;LedgerService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;applyEntry&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LedgerService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;java&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;95&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="n"&gt;pb536bxc27ec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pc2564xde165&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pd98ecx128d7&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Cfcbfdx33dfc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;m65719x51697&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Cfcbfdx33dfc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;java&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;95&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;JDK frames, lock addresses, thread states, &lt;code&gt;cpu=&lt;/code&gt; and &lt;code&gt;tid=&lt;/code&gt; fields and blank lines are untouched, because blank lines are what delimit threads and an analyzer that loses them loses the thread count. Lines the analyzer ignores anyway but that leak names for free, like &lt;code&gt;Locked ownable synchronizers&lt;/code&gt;, are removed and replaced by a marker so the structure doesn't shift. A line no rule recognizes is redacted, never passed through.&lt;/p&gt;

&lt;p&gt;You upload the &lt;code&gt;.anon.txt&lt;/code&gt;, run the analysis, download the report, and bring the names back locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;tm-anon unmask threadmine-report.html
&lt;span class="go"&gt;Wrote threadmine-report.unmasked.html
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;unmask&lt;/code&gt; treats its input as opaque text and rewrites every token it finds, so it works on an HTML report, an export JSON, a CSV, or a paragraph an LLM wrote about your dump. The tokens come back inside prose too, which is why the grammar is deliberately distinctive: "the bottleneck is Cfcbfdx33dfc" turns back into "the bottleneck is LedgerService".&lt;/p&gt;

&lt;h2&gt;
  
  
  Does the analysis survive?
&lt;/h2&gt;

&lt;p&gt;That was the question I couldn't answer when I replied on Reddit, so I measured it. I took the dumps in the test corpus (17 at the time), ran each one through the analyzer twice, original and masked, and compared the output: same set of detected problems, same severities, same health score on every one. Two edge cases behave differently and they're written down in the README rather than hidden, for example an application frame that today matches a detection pattern by accident (a fragment without a package, like &lt;code&gt;Consumer.receive&lt;/code&gt;) stops matching once tokenized. The honest claim is "equivalent analysis", not "byte-identical".&lt;/p&gt;

&lt;p&gt;The corpus is also where the tool caught its own worst bug before a user did. JDK 24 changed the text dialect of &lt;code&gt;jcmd Thread.dump_to_file&lt;/code&gt;: where older JDKs print a lock as &lt;code&gt;&amp;lt;0x000000061fcc9b90&amp;gt;&lt;/code&gt;, JDK 24 prints &lt;code&gt;&amp;lt;com.acme.payment.LedgerLock@6e8cf4c6&amp;gt;&lt;/code&gt;. The rule "the contents of angle brackets are an address, keep them verbatim" was correct for every dump I'd looked at and was quietly handing over a class name on the newest JDK. It surfaced while building the test corpus for that dialect, and there's now a test that masks every fixture and runs the verifier over the result, so it can't come back unnoticed. I'd rather tell that story than pretend the first version was right.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a security team can actually check
&lt;/h2&gt;

&lt;p&gt;"We promise the tool doesn't upload anything" is worth nothing to the people who blocked the upload in the first place. What they can act on is a claim they can verify in an afternoon, so the repository is built around three of those.&lt;/p&gt;

&lt;p&gt;The jar has no network code, and a test enforces it: it scans the production sources, the compiled bytecode constant pools and &lt;code&gt;pom.xml&lt;/code&gt; for &lt;code&gt;java.net&lt;/code&gt;, socket channels, &lt;code&gt;javax.net&lt;/code&gt;, &lt;code&gt;jdk.net&lt;/code&gt; and any HTTP dependency, and fails the build on the first hit. There is one dependency in the whole project, JUnit, at test scope, so there's no transitive supply chain to audit. And the allowlist that decides what stays verbatim is a readable JSON file, not a heuristic buried in code.&lt;/p&gt;

&lt;p&gt;The threat model is written as "here is what this does not protect you from". A masked dump still reveals which frameworks you use, the shape of your thread pools and the depth of your package tree, and dumps masked with the same vault are linkable to each other by design. Whoever holds the vault file can reverse everything. This is pseudonymization reversible by its owner, not irreversible anonymization, and the document says so, because the alternative is a security reviewer discovering it on their own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stands
&lt;/h2&gt;

&lt;p&gt;Version 0.4.0 handles the whole HotSpot family (&lt;code&gt;jstack&lt;/code&gt; from JDK 8 to 25, &lt;code&gt;jcmd Thread.print&lt;/code&gt;, both text and JSON forms of &lt;code&gt;Thread.dump_to_file&lt;/code&gt;, ThreadMXBean/VisualVM output, virtual threads), OpenJ9 javacore in a strip mode that removes whole sections rather than masking line by line, Azul Zing and GraalVM native-image dumps. Anything it can't classify is refused rather than half-masked. It ships as a jar that needs Java 21, and as native binaries for Linux, macOS and Windows for people who'd rather not have a JVM on the machine holding the dump.&lt;/p&gt;

&lt;p&gt;Disclosure, once more and explicitly: I'm the founder of &lt;a href="https://threadmine.dev/en/analyze" rel="noopener noreferrer"&gt;ThreadMine&lt;/a&gt;, the analyzer this tool was built to feed, and the page describing how the two fit together is &lt;a href="https://threadmine.dev/en/resources/thread-dump-anonymizer" rel="noopener noreferrer"&gt;here&lt;/a&gt;. The CLI itself is at &lt;a href="https://github.com/maschiojv/threadmine-anonymizer" rel="noopener noreferrer"&gt;github.com/maschiojv/threadmine-anonymizer&lt;/a&gt;, MIT, analyzer-agnostic. If your policy still says no even to a masked dump, I'd genuinely like to know what it would take; that thread on r/java is where this started, and it's still the best feedback I've had on the problem.&lt;/p&gt;

</description>
      <category>java</category>
      <category>jvm</category>
      <category>security</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I made a Claude Code plugin that turns your project into a demo video</title>
      <dc:creator>Felipe Maschio</dc:creator>
      <pubDate>Tue, 21 Jul 2026 21:37:41 +0000</pubDate>
      <link>https://dev.to/maschiojv/i-made-a-claude-code-plugin-that-turns-your-project-into-a-demo-video-3735</link>
      <guid>https://dev.to/maschiojv/i-made-a-claude-code-plugin-that-turns-your-project-into-a-demo-video-3735</guid>
      <description>&lt;p&gt;You know how much work goes into making a decent product demo. I built a Claude Code plugin that does most of it for you.&lt;/p&gt;

&lt;p&gt;Because it runs inside Claude Code, it already knows your project. It can identify the main features, run the app and build the film with one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/product-film
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output is a single HTML file, and that file is the film. Open it in Chrome and you'll get a start screen with one card for each cut. I usually ask for a full demo and a shorter loop for Product Hunt. Each card has its own &lt;code&gt;▶ Watch&lt;/code&gt; and &lt;code&gt;● Record&lt;/code&gt; buttons, and there's a theme-music picker that applies to whichever cut you record.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fksxqcr5aaet3g8dggfb7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fksxqcr5aaet3g8dggfb7.png" alt="The start screen of a generated film: two cards, Full cut and Short loop, each with its own Watch and Record buttons, and a theme-music picker below them." width="799" height="409"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Hit Record and the film captures itself from beginning to end, then drops an MP4 into your downloads. Firefox exports WebM. There's no screen recorder, OBS, editor or timeline involved.&lt;/p&gt;

&lt;p&gt;A few tracks are included, and you can upload your own audio. A friend discovered it doesn't even have to be music: he recorded a narration, uploaded the MP3 and used that instead.&lt;/p&gt;

&lt;p&gt;The bare command is enough, but you can also direct the film in the same message: which flow to show, what to leave out and which screen deserves more time. Since it's still a conversation with Claude Code, the first cut doesn't have to be the final one. You can say, "Drop the pricing scene and hold longer on the deadlock," and it'll generate a new version. You only hit Record when you're happy with it.&lt;/p&gt;

&lt;p&gt;If you want to see the result, &lt;a href="https://threadmine.dev/en#video-demo" rel="noopener noreferrer"&gt;the demo on ThreadMine's landing page&lt;/a&gt; was made entirely with the plugin. The short loop plays on the page, and clicking it opens the full version with music.&lt;/p&gt;

&lt;p&gt;The biggest advantage over AI video generators is that product-film doesn't have to guess what your application looks like. Those tools can make slick videos, but getting them to reproduce a coherent sequence of your actual screens is another matter. You either accept a slightly fictional version of the UI or spend a lot of time feeding them screenshots and correcting the details when they drift.&lt;/p&gt;

&lt;p&gt;Recording yourself clicking through the app works, but the result usually still needs editing. Hiring an editor costs money and takes time. Claude Code already understands the project, so it can skip most of that setup.&lt;/p&gt;

&lt;p&gt;It also doesn't upload your application or captured screens to a separate video service. The plugin works from a real run of your app on your machine.&lt;/p&gt;

&lt;p&gt;In my case, the app is ThreadMine, a thread-dump analyzer. The demo shows a dump being genuinely analyzed: health score F, 30 out of 100, with a real deadlock where &lt;code&gt;DeadLockThread1&lt;/code&gt; waits on &lt;code&gt;DeadLockThread2&lt;/code&gt;, which waits on &lt;code&gt;DeadLockThread1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I didn't choose 30 because it looked dramatic. That's the number the engine returned. Paste the same dump into the live tool and you'll get the same F 30. The video can't show a result the product didn't actually produce.&lt;/p&gt;

&lt;p&gt;Before creating a single frame, the plugin runs the real application with Playwright, clicks through the actual flow and captures the real screens and values. That's the source of truth. The film is then choreographed on top of it: a cursor easing into buttons, captions appearing after each action and a slow push-in on whatever deserves attention.&lt;/p&gt;

&lt;p&gt;Every visual is a pure function of &lt;code&gt;t&lt;/code&gt;. That means the film can seek to any point, verify itself beat by beat and record a take that begins and ends exactly where it should.&lt;/p&gt;

&lt;p&gt;It's open source and MIT licensed. Installation takes two commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/plugin marketplace add alliah-tech/product-film
/plugin install product-film@product-film-marketplace
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One practical tip: open &lt;code&gt;/plugin&lt;/code&gt;, go to Marketplaces and enable auto-update. Claude Code doesn't turn it on by default for third-party marketplaces, and I'm still releasing updates.&lt;/p&gt;

&lt;p&gt;I'd also run it with the most capable model you have and set effort to high. The difficult part isn't writing the code. It's reproducing the real interface without deciding to "improve" it along the way. Weaker models sometimes get creative and prettify parts of the UI, which breaks the one rule that keeps the result from becoming AI slop.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Felipe. I built &lt;a href="https://threadmine.dev" rel="noopener noreferrer"&gt;ThreadMine&lt;/a&gt;, the analyzer used in the example, and &lt;a href="https://github.com/alliah-tech/product-film" rel="noopener noreferrer"&gt;product-film&lt;/a&gt;, the plugin. Happy to answer questions in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>ai</category>
      <category>opensource</category>
      <category>devtools</category>
    </item>
    <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>
