# vt-pinning Companion code for the ankurm.com post *"Diagnosing Virtual Thread Pinning in Production: JFR Events, jcmd, and Real Fixes."* Fourth module in `java-core-examples`, the Java-core / concurrency series. [JEP 491](https://openjdk.org/jeps/491) (GA in JDK 24) fixed the pinning caused by `synchronized`. It did not touch the pinning caused by native code - a virtual thread executing a JNI native method (or an FFM downcall) whose native frame calls back into blocking Java code still pins its carrier, on every JDK version, because a continuation cannot be frozen across a native stack frame. This module reproduces that with a real, tiny JNI library (not asserted from documentation) and works through the diagnostics that still catch it once the old `-Djdk.tracePinnedThreads` flag stops reporting anything at all. ## Versions this was built and tested against | Component | Version | Notes | |---|---|---| | JDK (primary) | 25.0.4.1+1 (Temurin, LTS) | Has JEP 491; native pinning still reproduces. | | JDK (comparison) | 21.0.10 (OpenJDK) | Pre-JEP-491, for the before/after. | | gcc | system default | Builds the JNI native library; not committed, built by `scripts/build-native.sh`. | | Maven | 3.9.11 | Compiles the Java classes only - no JMH in this module. | | Hardware | 2 vCPU x86-64 VM | Same sandbox as the rest of this series. | ## Quickstart ```bash export JDK25_HOME=/path/to/jdk-25 export JDK21_HOME=/path/to/jdk-21 export JAVA_HOME=$JDK21_HOME # any JDK's jni.h works for the native build ./scripts/build-native.sh $JDK25_HOME/bin/javac --release 25 -d target/classes $(find src/main/java -name "*.java") $JDK25_HOME/bin/java --enable-native-access=ALL-UNNAMED -Djava.library.path=target/native \ -cp target/classes com.ankurm.vtpinning.NativePinningDemo ``` `scripts/run-all.sh` regenerates every file in `output/` (needs both `JDK25_HOME` and `JDK21_HOME` set, plus `gcc` on `PATH`). ## What's in here | File | What it shows | |---|---| | `src/main/c/vtpinning.c` | One JNI native function, shared by every demo class: calls back into a static Java method on the same native stack frame, so the callback's blocking happens with native frames still present. | | `.../MonitorPinningDemo.java` | `synchronized` + `Thread.sleep` on a virtual thread - the case JEP 491 fixed. | | `.../NativePinningDemo.java` | The case JEP 491 did not fix: a JNI native method whose callback blocks. | | `.../ThreadDumpPinnedDemo.java` | One pinned and one unpinned virtual thread running concurrently, for a `jcmd Thread.dump_to_file` capture. | | `.../PinningThroughputDemo.java` | The wall-time cost of native pinning vs. plain blocking, on a capped carrier pool. | | `.../BoundedNativeCallDemo.java` | The fix: routing a pinning native call through a small dedicated executor instead of calling it directly from the shared carrier pool. | | `output/01` | `MonitorPinningDemo` on JDK 21 vs JDK 25, with `-Djdk.tracePinnedThreads=full`. | | `output/02` | `NativePinningDemo` on JDK 21 vs JDK 25, same flag - still fires on 21, silent on 25. | | `output/03` | The `jdk.VirtualThreadPinned` JFR event, captured and printed with `jfr print`, for the same native pin on JDK 25. | | `output/04` | A `jcmd Thread.dump_to_file -format=json` snapshot taken mid-pin, pinned and unpinned virtual thread entries side by side. | | `output/05` | `PinningThroughputDemo`: 4 virtual threads / 2 carriers, native vs plain. | | `output/06` | `BoundedNativeCallDemo`: direct vs bounded-executor routing, effect on unrelated work. | ## Reading the numbers honestly (2-vCPU sandbox) **The old diagnostic flag doesn't just go quiet for the case JEP 491 fixed - it's silent for the case JEP 491 left alone too** (`output/02`). `-Djdk.tracePinnedThreads=full` reports `reason:NATIVE` on JDK 21 for the exact same native call that still measurably pins on JDK 25 (confirmed independently via the JFR event in `output/03`, and via the throughput cost in `output/05`). A team that kept the flag in their JVM args through an upgrade to JDK 24+ gets no signal from it for either kind of pinning going forward - not just the kind that stopped mattering. **A `jcmd` thread dump distinguishes pinned from unmounted with one field** (`output/04`): a pinned virtual thread's JSON entry carries a `"carrier"` field naming the platform thread's `tid`, and its stack includes `VirtualThread.parkOnCarrierThread`. An unmounted (not pinned) blocked virtual thread has neither - no `carrier` field at all, and a plain `parkNanos` in its stack. This needs no JFR recording running and works against a single dump taken after the fact. **Native pinning genuinely serializes work on a capped carrier pool** (`output/05`): 4 virtual threads each blocking ~1000ms on 2 carriers finish in ~2015ms through the native path (two carrier-bound batches) versus ~1022ms through plain `Thread.sleep` (all four unmount and share the 2 carriers freely). This isn't a benchmark artifact - it's the same mechanism JEP 491 fixed for monitors, just for a case it didn't touch. **Routing the pinning call through a small dedicated executor protects everything else** (`output/06`): with 2 carriers, 2 concurrent native-pinning calls, and 6 unrelated 100ms-sleep virtual threads competing for the same pool, calling the native code directly starves the unrelated work until ~924ms - both carriers are pinned the whole time. Routing the same native calls through a 2-thread dedicated executor (via `Future.get()`, which parks normally with no native frame on *that* thread's own stack) lets the unrelated work finish in ~126ms while the native calls run to completion in the background. The pinning cost doesn't go away, but it stops being everyone else's problem. ## License MIT - see the [repo-wide LICENSE](../LICENSE).