<?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>What jstack Misses When Your Java Service Uses Virtual Threads</title>
      <dc:creator>Felipe Maschio</dc:creator>
      <pubDate>Sun, 18 Oct 2026 00:51:56 +0000</pubDate>
      <link>https://dev.to/maschiojv/what-jstack-misses-when-your-java-service-uses-virtual-threads-23be</link>
      <guid>https://dev.to/maschiojv/what-jstack-misses-when-your-java-service-uses-virtual-threads-23be</guid>
      <description>&lt;p&gt;Take a Spring Boot service with virtual threads enabled, a HikariCP pool of 10 connections, and roughly 5,000 requests in flight. Latency is climbing. You grab a thread dump and find 43 platform threads, most of the scheduler workers waiting in &lt;code&gt;ForkJoinPool.awaitWork&lt;/code&gt;, and very little CPU activity.&lt;/p&gt;

&lt;p&gt;If you used &lt;code&gt;jstack&lt;/code&gt;, the requests you're looking for may never have made it into the file. On JDK 21 through 25, the traditional dump doesn't list virtual threads. You can spend quite a while reading the scheduler's stacks without seeing the requests waiting for your database.&lt;/p&gt;

&lt;p&gt;I'll use that connection-pool scenario throughout this article. The numbers and dump excerpts are illustrative. I run ThreadMine, and its support for these formats needs some explanation too; I'll get to that after the commands.&lt;/p&gt;

&lt;p&gt;If you'd rather watch the flow end to end first, this two-minute walkthrough goes from capturing the dump to sharing the report:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/mV39ax1wj5I" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  The carrier can be idle while the request waits
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;jstack&lt;/code&gt; and &lt;code&gt;jcmd &amp;lt;pid&amp;gt; Thread.print&lt;/code&gt; show platform threads. That includes the carriers, the scheduler workers on which virtual threads run. When a virtual thread unmounts to wait for I/O or another resource, its carrier is free to run something else.&lt;/p&gt;

&lt;p&gt;If most requests are waiting, there may be very little for the carriers to do. Low CPU and idle workers are entirely compatible with a large backlog.&lt;/p&gt;

&lt;p&gt;The scheduler uses a &lt;code&gt;ForkJoinPool&lt;/code&gt;, with default parallelism based on the processors available to the JVM. You can change it with &lt;code&gt;jdk.virtualThreadScheduler.parallelism&lt;/code&gt;, but I'd leave that alone until I knew what the requests were waiting for. More workers won't help requests queued behind the same ten database connections.&lt;/p&gt;

&lt;p&gt;A carrier with a virtual thread mounted can include this line in the traditional dump:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Carrying virtual thread #212
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From JDK 24 on, the dump also prints that virtual thread's own stack right below the carrier, under a &lt;code&gt;Mounted virtual thread #212&lt;/code&gt; line. Either way it's useful for identifying the mounted thread. For the thousands of unmounted threads, you need another dump.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use Thread.dump_to_file
&lt;/h2&gt;

&lt;p&gt;This command has been available since JDK 21:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;jcmd 4127 Thread.dump_to_file /tmp/dump.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The JVM writes the file, so &lt;code&gt;/tmp/dump.txt&lt;/code&gt; is a path in the target process's filesystem. This matters in containers. The process also needs permission to write there; the &lt;a href="https://threadmine.dev/en/resources/how-to-take-a-thread-dump" rel="noopener noreferrer"&gt;capture guide&lt;/a&gt; covers those details.&lt;/p&gt;

&lt;p&gt;Plain text is the default. For JSON:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;The documented format names are &lt;code&gt;plain&lt;/code&gt; and &lt;code&gt;json&lt;/code&gt;. Existing files aren't overwritten unless you pass &lt;code&gt;-overwrite&lt;/code&gt;. Use a new filename for each capture so there's something to compare later.&lt;/p&gt;

&lt;p&gt;The output also depends on the JDK version. JDK 21 through 24 give you the thread ID, name, a &lt;code&gt;virtual&lt;/code&gt; marker, and the stack. JDK 25 adds state, timestamps, and lock information. A shortened JDK 25 entry might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#412 "" virtual WAITING 2026-10-14T19:03:11.489120400Z
    at java.base/java.lang.VirtualThread.park(VirtualThread.java:582)
    ...
    at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:179)
    at com.example.orders.OrderRepository.load(OrderRepository.java:48)
    ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's the request, waiting to acquire a connection. The empty name is common for virtual threads; an application can assign a name if it needs one.&lt;/p&gt;

&lt;p&gt;For our example, suppose the file contains 5,004 virtual threads and 4,962 occurrences of &lt;code&gt;HikariPool.getConnection&lt;/code&gt;. You can check those counts without loading the dump into anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-cE&lt;/span&gt; &lt;span class="s1"&gt;'^#[0-9]+ ".*" virtual'&lt;/span&gt; dump.txt
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'HikariPool.getConnection'&lt;/span&gt; dump.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second command counts matching lines. Open a few of the stacks to check the call path, especially if the method occurs more than once per thread.&lt;/p&gt;

&lt;p&gt;At this point, I'd look at connection hold time. If each request holds a connection for 18 ms, ten connections would take roughly nine seconds to clear a backlog of 5,000 requests, with no new arrivals. That gives us a plausible scale for the delay. It doesn't calculate p99, but it tells us where to investigate.&lt;/p&gt;

&lt;p&gt;Check slow queries and transaction scope before changing the pool size. A connection held during an unrelated HTTP call, for example, reduces the capacity available to every request behind it. The database may support more concurrency, or it may already be struggling. The dump helps locate the queue; pool and database metrics help explain it.&lt;/p&gt;

&lt;p&gt;Virtual threads make waiting cheaper. The database still has to do the work.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd compare in the next dump
&lt;/h2&gt;

&lt;p&gt;For a slowdown that lasts minutes, take another capture after about 30 seconds. Look at the large stack groups and compare thread IDs within them.&lt;/p&gt;

&lt;p&gt;If the same requests are still waiting in the same place, investigate why they aren't progressing. If the IDs have changed but the group stays large, requests may be completing while new ones refill the queue. Those situations can look almost identical if you only compare totals.&lt;/p&gt;

&lt;p&gt;I wouldn't read much into 5,004 threads becoming 5,011. I'd be more interested in whether connection acquisition time is rising and whether throughput has stopped keeping up with arrivals. I've been fooled by a single dump more than once, and the second one has always been cheap.&lt;/p&gt;

&lt;p&gt;The state labels need the same treatment. &lt;code&gt;WAITING&lt;/code&gt; is expected in a service doing I/O, and it doesn't tell you by itself whether a virtual thread released its carrier. &lt;code&gt;RUNNABLE&lt;/code&gt; doesn't establish CPU saturation either. Start with the stack and the resource involved, then check the metrics that could explain the wait.&lt;/p&gt;

&lt;h2&gt;
  
  
  JSON helps when there are several executors
&lt;/h2&gt;

&lt;p&gt;For a single pile of requests waiting on HikariCP, the text file is enough to get started. JSON becomes useful when you need to separate work created by different executors or structured-concurrency scopes. It groups threads into containers.&lt;/p&gt;

&lt;p&gt;On JDK 25, an entry for a mounted virtual thread can also include its carrier's ID. Here's a reduced container with one thread:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"container"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"java.util.concurrent.ThreadPerTaskExecutor@3f1e7a2c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"parent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;root&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"owner"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"threads"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"tid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"412"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-14T19:03:11.489120400Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"virtual"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"RUNNABLE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"stack"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"carrier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"37"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"threadCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run these against the complete JDK 25 JSON file to count virtual-thread entries with a carrier and list the container sizes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;jq &lt;span class="s1"&gt;'[.threadDump.threadContainers[].threads[] | select(.virtual == true and .carrier != null)] | length'&lt;/span&gt; dump.json
jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.threadDump.threadContainers[] | "\(.threadCount)\t\(.container)"'&lt;/span&gt; dump.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dump is collected while threads keep running. That carrier count therefore isn't a measurement of how many carriers were busy at one instant. The container breakdown is what I'd use first to find where the threads are accumulating.&lt;/p&gt;

&lt;p&gt;Keep the JDK version with the file. Some fields used here aren't available in older releases. Also check for &lt;code&gt;-Djdk.trackAllThreads=false&lt;/code&gt;, which can leave some virtual threads out of the dump.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pinning depends on the JDK version
&lt;/h2&gt;

&lt;p&gt;A request waiting for a connection and a virtual thread blocking its carrier call for different investigations. For the second case, look for &lt;code&gt;VirtualThread.parkOnCarrierThread&lt;/code&gt;: it's the path where a virtual thread parks while retaining the carrier.&lt;/p&gt;

&lt;p&gt;On JDK 21 through 23, this code can take that path when the HTTP call blocks:&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;synchronized&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="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&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;send&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;BodyHandlers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofString&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;The relevant part of the stack can look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#37 "" virtual
      java.base/jdk.internal.misc.Unsafe.park(Native Method)
      java.base/java.lang.VirtualThread.parkOnCarrierThread(VirtualThread.java:675)
      java.base/java.lang.VirtualThread.park(VirtualThread.java:593)
      ...
      com.example.price.PriceService.lookup(PriceService.java:32)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With JDK 24 and JEP 491, holding a monitor no longer causes this pinning. Native frames and class initialization can still be involved in other cases. Check the runtime version before recommending changes to &lt;code&gt;synchronized&lt;/code&gt; code.&lt;/p&gt;

&lt;p&gt;For pinning that comes and goes, I'd use JFR. The &lt;code&gt;jdk.VirtualThreadPinned&lt;/code&gt; event has been enabled by default with a 20 ms threshold since JDK 21. A recording gives you frequency and duration, which a couple of dumps can easily miss. The old &lt;code&gt;jdk.tracePinnedThreads&lt;/code&gt; option was removed in JDK 24.&lt;/p&gt;

&lt;p&gt;Finding &lt;code&gt;parkOnCarrierThread&lt;/code&gt; is useful evidence of that particular parking path. Its absence doesn't rule out every form of pinning. There's more on the version differences in the &lt;a href="https://threadmine.dev/en/resources/virtual-thread-pinning" rel="noopener noreferrer"&gt;pinning reference&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on ThreadMine
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://threadmine.dev/en/analyze" rel="noopener noreferrer"&gt;ThreadMine&lt;/a&gt; reads the text file that &lt;code&gt;Thread.dump_to_file&lt;/code&gt; writes, in both shapes it has had so far: the JDK 21 to 24 one, with no state in the header and no &lt;code&gt;at&lt;/code&gt; on the frames, and the JDK 25 one, which has both. Virtual threads come through as virtual threads, empty names stay empty, and a thread sitting in &lt;code&gt;parkOnCarrierThread&lt;/code&gt; is flagged as pinned. I'll be honest about how recent that is: until this week the parser silently dropped every &lt;code&gt;#22 "" virtual&lt;/code&gt; line, and I only found out while checking this article against real dumps from JDK 21 through 25. The JSON form is still not accepted, so paste the text; the per-container counts are the one thing &lt;code&gt;jq&lt;/code&gt; still does that the analyzer doesn't.&lt;/p&gt;

&lt;p&gt;The change I'd make to a team's incident runbook is straightforward: when the service uses virtual threads, capture &lt;code&gt;Thread.dump_to_file&lt;/code&gt; as well as the traditional dump. You want the request stacks available before anyone starts drawing conclusions from idle carriers.&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.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  References
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://openjdk.org/jeps/444" rel="noopener noreferrer"&gt;JEP 444: Virtual Threads&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://openjdk.org/jeps/491" rel="noopener noreferrer"&gt;JEP 491: Synchronize Virtual Threads without Pinning&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.oracle.com/en/java/javase/25/docs/specs/man/jcmd.html" rel="noopener noreferrer"&gt;JDK 25 jcmd command reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.oracle.com/java/technologies/javase/25all-relnotes.html" rel="noopener noreferrer"&gt;JDK 25 release notes: thread dump lock information&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.oracle.com/en/java/javase/25/core/virtual-threads.html" rel="noopener noreferrer"&gt;Virtual threads in JDK 25&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>java</category>
      <category>jvm</category>
      <category>performance</category>
      <category>debugging</category>
    </item>
    <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>
