<?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: Carlos Exposito</title>
    <description>The latest articles on DEV Community by Carlos Exposito (@frodygr).</description>
    <link>https://dev.to/frodygr</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%2F2858798%2Fe05df701-f1d8-47a6-b658-5051f4d44c21.jpg</url>
      <title>DEV Community: Carlos Exposito</title>
      <link>https://dev.to/frodygr</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/frodygr"/>
    <language>en</language>
    <item>
      <title>Understanding Carrier Thread Pinning in Java 21 Virtual Threads (And How to Catch It Before Production)</title>
      <dc:creator>Carlos Exposito</dc:creator>
      <pubDate>Fri, 04 Sep 2026 13:10:23 +0000</pubDate>
      <link>https://dev.to/frodygr/understanding-carrier-thread-pinning-in-java-21-virtual-threads-and-how-to-catch-it-before-4oa3</link>
      <guid>https://dev.to/frodygr/understanding-carrier-thread-pinning-in-java-21-virtual-threads-and-how-to-catch-it-before-4oa3</guid>
      <description>&lt;p&gt;When Java 21 dropped, a lot of teams (including mine) were eager to flip the switch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;spring.threads.virtual.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pitch for Virtual Threads (JEP 444) sounded like an architectural silver bullet: user-mode threads managed by the runtime instead of the operating system, dropping thread memory footprints from 1MB down to kilobytes, and allowing millions of concurrent tasks without complex reactive code.&lt;/p&gt;

&lt;p&gt;And for 90% of straightforward I/O tasks, it works out of the box. &lt;/p&gt;

&lt;p&gt;Then high load hits staging, and suddenly you notice latency spikes, HTTP timeouts, and connection pools acting weird. You check CPU and memory usage, and they look completely relaxed. &lt;/p&gt;

&lt;p&gt;What actually happened? You ran into &lt;strong&gt;Carrier Thread Pinning&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Here is a practical breakdown of what pinning is, why it happens, and how to catch it before it hits your production users.&lt;/p&gt;




&lt;h2&gt;
  
  
  How Project Loom Actually Schedules Work
&lt;/h2&gt;

&lt;p&gt;Virtual threads don't run on bare metal by themselves. The JVM mounts a virtual thread onto an underlying OS platform thread managed by an internal FIFO &lt;code&gt;ForkJoinPool&lt;/code&gt;. That platform thread is called the &lt;strong&gt;Carrier Thread&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Under normal circumstances:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A virtual thread runs its task on a carrier.&lt;/li&gt;
&lt;li&gt;It hits a blocking I/O operation (like an HTTP call or reading from a socket).&lt;/li&gt;
&lt;li&gt;Loom unmounts the virtual thread from the carrier thread.&lt;/li&gt;
&lt;li&gt;The carrier thread is now free to pick up another waiting virtual thread.&lt;/li&gt;
&lt;li&gt;Once the I/O operation finishes, the virtual thread is placed back in the queue and resumes on whatever carrier thread is available next.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This unmounting mechanism is why virtual threads can scale so well.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where Things Go Wrong: Pinning
&lt;/h3&gt;

&lt;p&gt;Pinning occurs when a virtual thread &lt;strong&gt;cannot be unmounted&lt;/strong&gt; from its carrier during a blocking operation.&lt;/p&gt;

&lt;p&gt;Because the virtual thread is stuck to the carrier, that OS thread cannot pick up any other work. If your server has 8 CPU cores, the default carrier pool size is usually 8. If 8 virtual threads get pinned simultaneously waiting on a slow external service or database call, &lt;strong&gt;your entire thread pool is starved&lt;/strong&gt;. &lt;/p&gt;

&lt;p&gt;Thousands of other virtual threads might be waiting in memory, but zero carrier threads are available to run them.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Main Culprits
&lt;/h2&gt;

&lt;p&gt;According to JEP 444, pinning primarily happens in two places:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The classic &lt;code&gt;synchronized&lt;/code&gt; block
&lt;/h3&gt;

&lt;p&gt;This is where most teams get caught. If a virtual thread acquires a monitor lock via &lt;code&gt;synchronized&lt;/code&gt; and blocks inside, the JVM cannot unmount its execution frame:&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="c1"&gt;// Risky inside high-concurrency virtual thread flows:&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;synchronized&lt;/span&gt; &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="nf"&gt;fetchOrderData&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;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// If this HTTP call takes 300ms, the entire carrier thread is locked up for 300ms.&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;HttpResponse&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;BodyHandlers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofByteArray&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;body&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;Older libraries and legacy JDBC drivers frequently wrapped socket operations inside &lt;code&gt;synchronized&lt;/code&gt; blocks.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Native calls (JNI / FFM)
&lt;/h3&gt;

&lt;p&gt;If a virtual thread enters native code via JNI or the Foreign Function &amp;amp; Memory API and blocks there, it stays pinned to its carrier until the native call returns.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Fix: Moving to &lt;code&gt;ReentrantLock&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The fix for monitor pinning is straightforward: swap &lt;code&gt;synchronized&lt;/code&gt; for &lt;code&gt;ReentrantLock&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The concurrency primitives inside &lt;code&gt;java.util.concurrent&lt;/code&gt; were refactored in OpenJDK to support unmounting cleanly:&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;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="kt"&gt;byte&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="nf"&gt;fetchOrderData&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;id&lt;/span&gt;&lt;span class="o"&gt;)&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="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// While waiting on I/O, the virtual thread will unmount properly.&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;HttpResponse&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;BodyHandlers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofByteArray&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;body&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;(Note: OpenJDK's Loom team has been working on removing the &lt;code&gt;synchronized&lt;/code&gt; pinning restriction in newer builds via JEP 491, but if you run Java 21 LTS, this remains an active concern).&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  How to Detect Pinning in Your Applications
&lt;/h2&gt;

&lt;p&gt;You do not want to wait for thread starvation to show up in production logs. Here are the three practical ways to spot it:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. JVM Flags for Quick Checks
&lt;/h3&gt;

&lt;p&gt;When testing locally or in a staging environment, start your service 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;short &lt;span class="nt"&gt;-jar&lt;/span&gt; my-service.jar
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;short&lt;/code&gt;: Prints single-line notifications showing the method that pinned the carrier.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;full&lt;/code&gt;: Dumps full stack traces.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep in mind that &lt;code&gt;full&lt;/code&gt; can easily spam your logs if an active pinning loop is present.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. JDK Flight Recorder (JFR)
&lt;/h3&gt;

&lt;p&gt;If you use JFR in production, look for the event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;jdk.VirtualThreadPinned
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can profile your application and inspect the recordings in JDK Mission Control or Grafana to see which classes trigger the event most often.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Real-Time Telemetry &amp;amp; Actuator Metrics (LoomDoctor)
&lt;/h3&gt;

&lt;p&gt;JVM flags are fine for manual debugging, and JFR is great for post-mortems, but in microservice architectures you usually want live metrics, Prometheus alerts, and health indicators.&lt;/p&gt;

&lt;p&gt;To solve this for our services, I created an open-source diagnostics tool called &lt;strong&gt;&lt;a href="https://github.com/FrodyGr/LoomDoctor" rel="noopener noreferrer"&gt;LoomDoctor&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It hooks into the runtime to track carrier pinning occurrences, monitors carrier pool saturation, and exposes the data through a native Spring Boot Actuator endpoint (&lt;code&gt;/actuator/loomdoctor&lt;/code&gt;):&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;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"WARNING"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"carrierThreads"&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;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"active"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"pinned"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&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;"starvationRisk"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"HIGH"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"pinningRatio"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"62.5%"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"detectedPinningSources"&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;"class"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"com.example.service.LegacyPaymentService"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"processTransaction"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"cause"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SYNCHRONIZED_MONITOR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"occurrences"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1420&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="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;If you want to see how pinning and pool starvation behave in real time without setting up code, I also put together an &lt;a href="https://frodygr.github.io/playground/#loomdoctor" rel="noopener noreferrer"&gt;Interactive Virtual Thread Simulator&lt;/a&gt; in my portfolio labs.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick Production Checklist
&lt;/h2&gt;

&lt;p&gt;Before rolling out &lt;code&gt;spring.threads.virtual.enabled=true&lt;/code&gt; across critical services:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Check legacy dependencies&lt;/strong&gt;: Verify that third-party SDKs or older drivers don't use &lt;code&gt;synchronized&lt;/code&gt; around network calls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not oversize connection pools&lt;/strong&gt;: Virtual threads do not mean your database can handle 50,000 active connections. Size your HikariCP pool based on database capacity, not thread count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;ReentrantLock&lt;/code&gt;&lt;/strong&gt; where locks surround blocking operations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Instrument carrier pools&lt;/strong&gt;: Add alerts on carrier thread saturation, not just CPU and heap usage.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Have you hit carrier thread pinning in your Java 21 projects yet? How did your team catch it?&lt;/p&gt;

</description>
      <category>java</category>
      <category>programming</category>
      <category>springboot</category>
      <category>performance</category>
    </item>
  </channel>
</rss>
