{"title":"Test Java Virtual-Thread Code for Pinning and Cancellation","excerpt":"A test that submits a thousand virtual-thread tasks and waits for them proves only that they ended. This guide measures the ten connections behind them instead: a probe that asserts the concurrency bound is reached and never exceeded, a 500 ms deadline that has to reach the wait for a connection, the Semaphore release bug that inflates permits on cancellation, JDK 27 structured concurrency timeouts, and a JFR RecordingStream check for the pinning that survives JEP 491.","canonicalUrl":"https://automationtester.in/blog/automation-tutorials/test-java-virtual-threads-pinning-cancellation","category":{"name":"Automation Tutorials","slug":"automation-tutorials"},"tags":["java","virtual-threads","concurrency-testing","junit","jfr","structured-concurrency"],"author":{"name":"Shashank Rawlani","url":"https://shashank.rawlani.com","linkedin":"https://www.linkedin.com/in/shashankrawlani/"},"datePublished":"2026-10-01T13:30:00.000Z","dateModified":"2026-10-14T11:09:52.936Z","coverImage":{"url":"https://ik.imagekit.io/automationtester/automationtester-in/blog/covers/v3/test-java-virtual-threads-pinning-cancellation.webp","alt":"Dark technical illustration read left to right. A dense field of small green dots fills the left third, brighter toward the middle. Thin green curves run from the field into a tall dashed green gate holding exactly ten stacked rounded slots, each with a short bright green bar inside. A few grey curves leave the bottom of the field, pass beneath the gate and stop at small grey dots against a vertical dashed green line topped by a ringed marker. Below the ten slots an eleventh slot is outlined in dashed orange, fed by a dashed orange curve and followed by a crossed-out orange ring. On the right, lines from all ten slots converge on one large ringed green dot, which sends a thick green line and arrowhead off the right edge, with three green pills beneath it."},"contentHtml":"<div class=\"callout callout-info\"><strong>Quick answer:</strong> A virtual-thread test that submits a thousand tasks and waits for them proves the tasks ended. It proves nothing about the ten connections behind them. Put a probe on the scarce resource and assert its peak equals the bound, give every request a deadline that reaches the wait for that resource, cancel requests mid-flight and assert every permit came back, and record <code class=\"language-text\">jdk.VirtualThreadPinned</code> with a JFR <code class=\"language-text\">RecordingStream</code> during the test. On JDK 24 and later a <code class=\"language-text\">synchronized</code> block no longer pins; native frames and class initialisers still do.</div>\n\n<p>Here is a test that passes on every machine and tells you almost nothing:</p>\n\n<pre class=\"language-java\"><code class=\"language-java\">@Test\nvoid handlesAThousandConcurrentRequests() throws Exception {\n    Set&lt;String&gt; threads = ConcurrentHashMap.newKeySet();\n    List&lt;Future&lt;Order&gt;&gt; results = new ArrayList&lt;&gt;();\n\n    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {\n        for (int i = 0; i &lt; 1_000; i++) {\n            results.add(executor.submit(() -&gt; {\n                threads.add(Thread.currentThread().toString());\n                return orders.load(randomId());\n            }));\n        }\n    }\n\n    assertEquals(1_000, results.size());\n    assertEquals(1_000, threads.size());   // \"a thousand requests in parallel\"\n}</code></pre>\n\n<p>Every assertion in it is true by construction. <code class=\"language-text\">newVirtualThreadPerTaskExecutor()</code> is documented to start \"a new virtual Thread for each task\", and \"the number of threads created by the Executor is unbounded\", so a thousand submissions produce a thousand threads whether the code underneath is correct or not. The <code class=\"language-text\">close()</code> at the end of the try block \"waits until all tasks have completed execution\", so the test cannot observe a request that was slow, stuck, or late. It only finishes later.</p>\n\n<p>What the test was supposed to be about sits behind <code class=\"language-text\">orders.load</code>: a pool of ten connections. JEP 444 is blunt about what the thousand threads add: \"Virtual threads are not faster threads — they do not run code any faster than platform threads. They exist to provide scale (higher throughput), not speed (lower latency).\" The throughput of that test is set by the pool and the query time, and the thread count is a number the executor chose for you.</p>\n\n<div class=\"callout callout-warning\"><strong>Check which JDK your tests run on before reading further.</strong> Pinning behaviour changed in JDK 24 and the structured concurrency API changed again in JDK 27, so the same test means different things on different runtimes. Versions checked on 2026-09-30: JDK 27 is the current feature release (GA 2026-09-15); JDK 25 and JDK 21 are the most recent LTS releases on Oracle's roadmap; structured concurrency is in its seventh preview as JEP 533 in JDK 27; HikariCP's README lists 7.1.0. The program output quoted below came from running the examples on JDK 26.0.2 and JDK 21.0.12.</div>\n\n<h2 id=\"measure-the-ten-connections-not-the-thousand-threads\">Measure the ten connections, not the thousand threads</h2>\n\n<p>Count at the resource that is actually limited, because that is where the bug shows up: eleven queries in flight against a pool of ten, or nine forever because a permit leaked.</p>\n\n<p>For a unit-level test, replace the data source with a double that behaves like a ten-connection pool and records what happened to it. The semaphore in it stands in for the pool. Oracle's virtual threads guide is explicit that a real pool already plays this role: \"Database connection pools themselves serve as a semaphore\", and \"there is no need to add an additional semaphore on top of the connection pool.\" So in production the bound lives in the pool, and in the test the double reproduces it with a counter attached.</p>\n\n<pre class=\"language-java\"><code class=\"language-java\">final class TenConnectionPool implements OrderStore {\n\n    final Semaphore connections = new Semaphore(10, true);\n    final AtomicInteger inFlight = new AtomicInteger();\n    final AtomicInteger peak = new AtomicInteger();\n    final LongAdder servedCount = new LongAdder();\n    final LongAdder rejectedAtDeadline = new LongAdder();\n\n    private final Duration queryTime;\n\n    TenConnectionPool(Duration queryTime) {\n        this.queryTime = queryTime;\n    }\n\n    @Override\n    public Optional&lt;Order&gt; load(OrderId id, Instant deadline) throws InterruptedException {\n        long budget = Duration.between(Instant.now(), deadline).toNanos();\n        if (!connections.tryAcquire(budget, TimeUnit.NANOSECONDS)) {\n            rejectedAtDeadline.increment();\n            return Optional.empty();\n        }\n        try {\n            peak.accumulateAndGet(inFlight.incrementAndGet(), Math::max);\n            Thread.sleep(queryTime);            // the query, as far as the caller can tell\n            servedCount.increment();\n            return Optional.of(new Order(id));\n        } finally {\n            inFlight.decrementAndGet();\n            connections.release();\n        }\n    }\n}</code></pre>\n\n<p>Two details in that double are deliberate. The semaphore is fair: the <code class=\"language-text\">Semaphore</code> Javadoc recommends that \"semaphores used to control resource access should be initialized as fair, to ensure that no thread is starved out from accessing a resource\", and it warns that \"the untimed <code class=\"language-text\">tryAcquire</code> methods do not honor the fairness setting\". The timed form used here does. And the peak is updated after the permit is taken, so it measures queries, not callers waiting in line.</p>\n\n<h2 id=\"a-thousand-requests-a-500ms-deadline-ten-connections\">A thousand requests, a 500 ms deadline, ten connections</h2>\n\n<p>A thousand requests arrive at once, each with a 500 ms deadline, against ten connections whose queries take 50 ms.</p>\n\n<pre class=\"language-java\"><code class=\"language-java\">@Test\nvoid boundsDatabaseConcurrencyAndShedsLoadAtTheDeadline() {\n    var pool = new TenConnectionPool(Duration.ofMillis(50));\n    var service = new OrderService(pool);\n\n    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {\n        for (int i = 0; i &lt; 1_000; i++) {\n            OrderId id = new OrderId(i);\n            executor.submit(() -&gt; service.lookup(id, Instant.now().plusMillis(500)));\n        }\n    }\n\n    long served = pool.servedCount.sum();\n    long shed = pool.rejectedAtDeadline.sum();\n\n    assertEquals(10, pool.peak.get(), \"the bound must be reached, and never exceeded\");\n    assertEquals(1_000, served + shed, \"every request is either served or shed\");\n    assertTrue(served &gt;= 60 &amp;&amp; served &lt;= 100, () -&gt; \"served \" + served);\n    assertEquals(10, pool.connections.availablePermits(), \"every connection came back\");\n    assertEquals(0, pool.inFlight.get());\n}</code></pre>\n\n<p>Run on JDK 26, the numbers were 90 served, 910 shed, a peak of 10 and 520 ms elapsed. That is Little's Law, which JEP 444 uses to motivate the whole feature: ten connections at 50 ms each is 200 queries a second, and half a second of budget holds about a hundred of them. The other nine hundred threads existed, waited, and gave up on time.</p>\n\n<p>Each assertion guards a different failure. Asserting <code class=\"language-text\">peak == 10</code> rather than <code class=\"language-text\">peak &lt;= 10</code> matters, because a peak of one also satisfies the upper bound and means something serialised every query, perhaps a lock held across the call. The served-plus-shed total catches a request that vanished into a <code class=\"language-text\">Future</code> nobody reads. The served range is the Little's Law estimate with room for scheduling noise: it fails if the deadline is ignored (all thousand served, after five seconds) or fires too early.</p>\n\n<p>The cost is real time. The test cannot be faster than its deadline, and the <code class=\"language-text\">Semaphore</code> times its waits against the system clock, so there is no fake clock to inject. Keep one test like this per bounded resource, not one per endpoint.</p>\n\n<h2 id=\"the-pool-default-that-outlives-your-deadline\">The pool default that outlives your deadline</h2>\n\n<p>The double honours the request's deadline because it was written to. A real pool uses its own configuration, and the defaults are rarely aligned with anyone's latency budget. HikariCP's README makes the arithmetic concrete: <code class=\"language-text\">maximumPoolSize</code> defaults to 10, so the pool in this worked example is the default pool. When it is exhausted, \"calls to <code class=\"language-text\">getConnection()</code> will block for up to <code class=\"language-text\">connectionTimeout</code> milliseconds before timing out\", and <code class=\"language-text\">connectionTimeout</code> defaults to 30000.</p>\n\n<p>So a request with a 500 ms deadline, arriving behind nine hundred others, can wait thirty seconds for a connection before anything tells it no. A fixed pool of request threads used to run out first and push back upstream. With a virtual thread per request nothing runs out first, and the pool's wait becomes the request's latency.</p>\n\n<p>So set <code class=\"language-text\">connectionTimeout</code> in the test configuration to production's value, and assert on it once so it cannot drift. Mind its floor too: the README states the \"lowest acceptable connection timeout is 250 ms\", so a deadline shorter than that cannot be enforced by the pool at all and has to be enforced before the call reaches it. Run the same thousand-request shape against a real database container, following the approach from <a href=\"/blog/testcontainers-database-integration-tests-trustworthy\">part five</a>, and compare its served and shed counts with the double's. If they disagree by an order of magnitude, the pool's timeout is the first thing to read.</p>\n\n<h2 id=\"a-permit-released-by-a-request-that-never-held-one\">A permit released by a request that never held one</h2>\n\n<p>Where there is no pool, only a downstream service that takes ten calls at a time, the guide's advice is a <code class=\"language-text\">Semaphore</code> rather than a fixed thread pool. The obvious way to write it hides a mistake no happy-path test finds.</p>\n\n<pre class=\"language-java\"><code class=\"language-java\">// Wrong: if acquire() is interrupted, finally still releases a permit\n// this request never received.\nRate quote(Currency from, Currency to) throws InterruptedException {\n    try {\n        permits.acquire();\n        return rates.fetch(from, to);\n    } finally {\n        permits.release();\n    }\n}\n\n// Right: only a request that acquired a permit can give one back.\nRate quote(Currency from, Currency to) throws InterruptedException {\n    permits.acquire();\n    try {\n        return rates.fetch(from, to);\n    } finally {\n        permits.release();\n    }\n}</code></pre>\n\n<p>The wrong version passes every test that does not cancel anything. The Javadoc for <code class=\"language-text\">release()</code> says: \"There is no requirement that a thread that releases a permit must have acquired that permit by calling <code class=\"language-text\">acquire()</code>.\" Semaphores \"have no notion of ownership\". Each interrupted waiter adds a permit, and the limit of ten quietly becomes a limit of whatever the last cancellation storm left behind.</p>\n\n<p>How large that gets depends on timing, which is why it survives review. Submitting a thousand requests to the wrong version, cancelling them all 120 ms later and reading <code class=\"language-text\">availablePermits()</code> gave 980 on JDK 26 and 20 on JDK 21. Either way, the next burst sends that many concurrent calls to a service that can take ten.</p>\n\n<h2 id=\"cancel-mid-flight-and-count-what-came-back\">Cancel mid-flight and count what came back</h2>\n\n<p>The permit assertion only catches that bug if the test cancels requests while some are waiting and some are working. Build that state on purpose: a query slow enough that every connection is busy and hundreds are queued, then cancel everything.</p>\n\n<pre class=\"language-java\"><code class=\"language-java\">@Test\nvoid cancelledRequestsReleaseEveryConnection() throws Exception {\n    var pool = new TenConnectionPool(Duration.ofSeconds(5));\n    var service = new OrderService(pool);\n    ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();\n\n    List&lt;Future&lt;?&gt;&gt; requests = new ArrayList&lt;&gt;();\n    for (int i = 0; i &lt; 1_000; i++) {\n        OrderId id = new OrderId(i);\n        requests.add(executor.submit(() -&gt; service.lookup(id, Instant.now().plusSeconds(10))));\n    }\n    await().until(() -&gt; pool.inFlight.get() == 10);   // saturated: ten working, the rest queued\n\n    requests.forEach(request -&gt; request.cancel(true));\n    executor.shutdown();\n\n    assertTrue(executor.awaitTermination(2, TimeUnit.SECONDS),\n            \"a task ignored its interrupt and is still holding something\");\n    assertEquals(0, pool.inFlight.get());\n    assertEquals(10, pool.connections.availablePermits());\n}</code></pre>\n\n<p><code class=\"language-text\">await()</code> is whatever polling helper the suite already uses; wait on the pool's state, not on a sleep. With the correct release pattern, the run reported ten in flight before cancellation and, after it, terminated with zero in flight and ten permits.</p>\n\n<p>Three pieces of the contract are doing work here. <code class=\"language-text\">Future.cancel(true)</code> interrupts \"the thread executing this task (when known by the implementation)\", and for a task that has not started, \"this task should never run\". Its return value \"does not necessarily indicate whether the task is now cancelled\", so the test asserts on the resource, not on what <code class=\"language-text\">cancel</code> returned.</p>\n\n<p>The executor is shut down and awaited with a limit instead of closed, because <code class=\"language-text\">close()</code> waits with no limit at all. A task that swallows <code class=\"language-text\">InterruptedException</code> turns a <code class=\"language-text\">close()</code>-based test into a hang; the timed wait turns it into a failure message.</p>\n\n<p>And a real query is not a sleep. JEP 444 specifies that the blocking I/O methods of <code class=\"language-text\">java.net.Socket</code> are interruptible on a virtual thread, and that \"interrupting a virtual thread blocked on a socket will unpark the thread and close the socket.\" If your driver reads through such a socket, a cancelled request can hand a closed connection back to the pool. Whether the pool evicts it is a question for the integration-level version of this test: cancel a batch, then run one more ordinary query and assert it succeeds.</p>\n\n<h2 id=\"scope-timeouts-in-jdk-27-structured-concurrency\">Scope timeouts in JDK 27 structured concurrency</h2>\n\n<p>When one request fans out into several calls, the deadline and the cancellation have to cover all of them. That is what <code class=\"language-text\">StructuredTaskScope</code> is for, and in JDK 27 it is still a preview API: \"Programs can only use <code class=\"language-text\">StructuredTaskScope</code> when preview features are enabled\", which means compiling with <code class=\"language-text\">javac --release 27 --enable-preview</code> and running tests with <code class=\"language-text\">--enable-preview</code>.</p>\n\n<pre class=\"language-java\"><code class=\"language-java\">OrderView view(OrderId id, Duration budget) throws InterruptedException, ExecutionException {\n    try (var scope = StructuredTaskScope.open(\n            Joiner.&lt;Object&gt;allSuccessfulOrThrow(),\n            cf -&gt; cf.withTimeout(budget))) {\n\n        var order = scope.fork(() -&gt; orders.load(id, Instant.now().plus(budget)));\n        var stock = scope.fork(() -&gt; inventory.levelsFor(id));\n        scope.join();\n        return new OrderView(order.get(), stock.get());\n    }\n}\n\n@Test\nvoid aSlowDependencyCancelsTheWholeRequest() {\n    inventory.respondAfter(Duration.ofSeconds(5));\n\n    var failure = assertThrows(ExecutionException.class,\n            () -&gt; service.view(new OrderId(7), Duration.ofMillis(500)));\n\n    assertInstanceOf(StructuredTaskScope.CancelledByTimeoutException.class, failure.getCause());\n    assertEquals(10, pool.connections.availablePermits(), \"the order lookup gave its connection back\");\n    assertEquals(0, inventory.callsInProgress());\n}</code></pre>\n\n<p>The timeout semantics are exactly what the test asserts: \"if the timeout expires before or while waiting in join() then the scope is cancelled, which cancels all incomplete subtasks, and join() throws an ExecutionException with a CancelledByTimeoutException as the cause.\" What makes the last two assertions safe to write immediately after the call is the scope's close behaviour: \"The close method always waits for threads executing subtasks to finish, even if the scope is cancelled.\" When <code class=\"language-text\">view</code> returns, nothing it forked is still running.</p>\n\n<p>That guarantee has a price the test will expose. JEP 533 warns that subtasks that \"block on methods that are not interruptible, may delay the closing of a scope indefinitely.\" A 500 ms budget that takes five seconds to fail is that warning, observed. Time the call and assert an upper bound on it as well as the exception type.</p>\n\n<p>The API also keeps moving between previews. JDK 27 added a third type parameter for the exception <code class=\"language-text\">join()</code> throws, made the stock joiners throw <code class=\"language-text\">ExecutionException</code>, removed <code class=\"language-text\">Joiner.awaitAll()</code> and replaced <code class=\"language-text\">onTimeout()</code> with <code class=\"language-text\">timeout()</code>. Keep scope code behind one method per fan-out, as above, so an upgrade changes that method and not every test that exercises it.</p>\n\n<h2 id=\"pinning-since-jdk-24-and-a-test-that-catches-it\">Pinning since JDK 24, and a test that catches it</h2>\n\n<p>Pinning is a virtual thread that blocks without releasing its carrier platform thread. Before JDK 24 the usual cause was blocking inside <code class=\"language-text\">synchronized</code>. JEP 491, delivered in JDK 24, changed the JVM so that \"virtual threads can acquire, hold, and release monitors, independently of their carriers\", and described that as eliminating \"nearly all cases\" of pinning. JDK 21 is still an LTS release, though, and on it the old behaviour stands.</p>\n\n<p>What remains after JEP 491 is narrower and harder to spot in a code review. The JDK 27 guide says a virtual thread \"is pinned when it runs a native method or a foreign function\", and JEP 491 lists the rest: native code calling back into Java that blocks, blocking while loading a class, \"blocking inside a class initializer\", and waiting for another thread to finish initialising a class.</p>\n\n<p>The diagnostic changed too. The <code class=\"language-text\">jdk.tracePinnedThreads</code> system property from JEP 444 was removed in JDK 24, and JEP 491 is direct about it: \"setting it on the command line will have no effect.\" A flag left in a Gradle <code class=\"language-text\">jvmArgs</code> block from the JDK 21 era is now silent, which is indistinguishable from \"no pinning\" unless you know. What remains is the JFR event <code class=\"language-text\">jdk.VirtualThreadPinned</code>, \"enabled by default with a threshold of 20 ms\", which since JEP 491 also reports the reason for the pin and the carrier thread.</p>\n\n<p>JFR can be read from inside a test. <code class=\"language-text\">RecordingStream.stop()</code>, available since JDK 20, \"stops a started stream and waits until all events in the recording have been consumed\", and its own Javadoc example is a test method asserting that no network events occurred. The same shape works for pinning:</p>\n\n<pre class=\"language-java\"><code class=\"language-java\">static List&lt;RecordedEvent&gt; pinsDuring(ThrowingRunnable workload) throws Throwable {\n    List&lt;RecordedEvent&gt; pins = new CopyOnWriteArrayList&lt;&gt;();\n    try (var recording = new RecordingStream()) {\n        recording.enable(\"jdk.VirtualThreadPinned\").withoutThreshold().withStackTrace();\n        recording.onEvent(\"jdk.VirtualThreadPinned\", pins::add);\n        recording.startAsync();\n        workload.run();\n        recording.stop();\n    }\n    return pins;\n}\n\n@Test\nvoid theDetectorSeesAPinWhenThereIsOne() throws Throwable {\n    // Blocking inside a class initializer still pins on JDK 24+.\n    assertFalse(pinsDuring(() -&gt; runOnVirtualThreads(4, SlowToInitialize::touch)).isEmpty());\n}\n\n@Test\nvoid orderLookupsDoNotPin() throws Throwable {\n    var pins = pinsDuring(() -&gt; runOnVirtualThreads(100, () -&gt; service.lookup(new OrderId(1), soon())));\n    assertTrue(pins.isEmpty(), () -&gt; pins.getFirst().getStackTrace().toString());\n}</code></pre>\n\n<p>The first test is the negative control, and it is not optional. Without it, an empty list is equally consistent with \"no pinning\" and \"the recording never started\". <code class=\"language-text\">SlowToInitialize</code> is a class whose static initialiser sleeps for 100 ms; the four threads that touch it produced five pinned events on JDK 26, with the reason <code class=\"language-text\">Freeze or preempt failed</code>. The same probe with <code class=\"language-text\">Thread.sleep</code> inside <code class=\"language-text\">synchronized</code> reported four events on JDK 21 and none on JDK 26, which is JEP 491 made visible.</p>\n\n<h2 id=\"dumps-for-when-the-test-hangs\">Dumps for when the test hangs</h2>\n\n<p>When one of these tests hangs instead of failing, the traditional thread dump will mislead you. <code class=\"language-text\">jcmd &lt;pid&gt; Thread.print</code> shows platform threads and only the virtual threads currently mounted, and a thousand requests queued on a semaphore are not mounted. Take the newer dump instead:</p>\n\n<pre class=\"language-bash\"><code class=\"language-bash\">jcmd &lt;pid&gt; Thread.dump_to_file -format=json build/dumps/lookup-hang.json\njcmd &lt;pid&gt; Thread.vthread_scheduler\njfr print --events jdk.VirtualThreadPinned,jdk.VirtualThreadSubmitFailed build/test.jfr</code></pre>\n\n<p>The JSON dump lists every thread, virtual ones included, and groups threads forked in a <code class=\"language-text\">StructuredTaskScope</code> under their scope. It is not a consistent snapshot: the guide notes it does not pause the VM and \"doesn't perform deadlock detection\", so read the timestamps before concluding two threads were waiting on each other. Name the request threads with <code class=\"language-text\">Thread.ofVirtual().name(\"lookup-\", 0).factory()</code> so they are searchable in the file. An assistant can summarise which frames nine hundred threads share; the probe's counts are what confirm the diagnosis. To compare JDK 21 and JDK 27 output from the same run, the <a href=\"/tools/diff-checker\">diff checker</a> shows which pins disappeared after the upgrade.</p>\n\n<p>None of this is about JUnit's own parallel test execution, which <a href=\"/blog/debug-junit-parallel-execution-flakes\">part three</a> covers. If both are in play, pin JUnit's parallelism first so the only concurrency that varies is the code's.</p>\n\n<h2 id=\"first-changes-for-a-virtual-thread-suite\">First changes for a virtual-thread suite</h2>\n\n<p>Find the test that proves your virtual-thread code works and delete any assertion about thread counts or thread names. Replace it with a probe on the one resource that request path cannot have more of: connections, permits, sockets to a partner. Assert that the peak equals the bound and that the served and shed counts add up to what you sent.</p>\n\n<p>Then open the pool configuration the tests use and write down its connection timeout next to the request deadline. If the timeout is longer, the deadline is fiction for any request that queues, and that is the first fix.</p>\n\n<p>Last, add the pinning probe with its negative control, run it on the JDK production uses, and delete <code class=\"language-text\">-Djdk.tracePinnedThreads</code> from every build file on JDK 24 or later. Print the peak, the served and shed counts, and the permits after cancellation in the test output, so a regression shows up as a changed number rather than a slower build.</p>\n\n<h2 id=\"questions-about-testing-virtual-thread-code\">Questions about testing virtual-thread code</h2>\n\n<h3 id=\"is-migrating-synchronized-to-reentrantlock-still-needed\">Do I still need to replace synchronized with ReentrantLock?</h3>\n\n<p>Not for pinning on JDK 24 or later. JEP 491 withdraws the earlier advice: \"Once the synchronized keyword no longer pins virtual threads, such migration will no longer be necessary\", and code already migrated need not be reverted. On JDK 21 the advice stands for locks held across blocking calls. Either way, holding a lock while waiting on I/O still serialises every caller, which is what the <code class=\"language-text\">peak == 10</code> assertion exists to catch.</p>\n\n<h3 id=\"how-many-carrier-threads-does-the-scheduler-use\">How many platform threads does the virtual-thread scheduler actually use?</h3>\n\n<p>By default, as many as there are available processors, tunable with <code class=\"language-text\">jdk.virtualThreadScheduler.parallelism</code>. Some blocking operations temporarily add carriers to compensate, up to <code class=\"language-text\">jdk.virtualThreadScheduler.maxPoolSize</code>, and JEP 491 notes a default limit of 256. A test that passes only after raising parallelism has usually found a pinning problem, not a sizing one.</p>\n\n<h2 id=\"virtual-thread-test-references\">Primary references</h2>\n\n<ul>\n<li><a href=\"https://openjdk.org/projects/jdk/\">OpenJDK: JDK Project</a> and <a href=\"https://openjdk.org/projects/jdk/27/\">JDK 27</a>. That JDK 27 reached general availability on 15 September 2026 and JDK 28 is in development, and that JDK 27 ships JEP 533, Structured Concurrency (Seventh Preview).</li>\n<li><a href=\"https://www.oracle.com/java/technologies/java-se-support-roadmap.html\">Oracle Java SE Support Roadmap</a>. That Java SE 21 and 25 are LTS releases and JDK 27 is not, which is why the article treats JDK 21 pinning behaviour as still current for many teams.</li>\n<li><a href=\"https://openjdk.org/jeps/444\">JEP 444: Virtual Threads</a>. Delivered in JDK 21. That virtual threads \"are not faster threads\" and provide throughput rather than lower latency; the Little's Law framing; the advice to limit concurrency with semaphores rather than pooled threads; that socket I/O is interruptible on a virtual thread and interrupting \"will close the socket\"; the scheduler properties <code class=\"language-text\">jdk.virtualThreadScheduler.parallelism</code> and <code class=\"language-text\">maxPoolSize</code>; the original <code class=\"language-text\">jdk.tracePinnedThreads</code> property; <code class=\"language-text\">jcmd Thread.dump_to_file</code>; and the JFR events including <code class=\"language-text\">jdk.VirtualThreadSubmitFailed</code>.</li>\n<li><a href=\"https://openjdk.org/jeps/491\">JEP 491: Synchronize Virtual Threads without Pinning</a>. Delivered in JDK 24. That monitors are now held by virtual threads independently of carriers; the remaining pinning cases (native and foreign-function callbacks, class loading, class initializers, waiting on class initialization); that <code class=\"language-text\">jdk.VirtualThreadPinned</code> now carries the pin reason and carrier thread; that <code class=\"language-text\">jdk.tracePinnedThreads</code> was removed and \"setting it on the command line will have no effect\"; the withdrawn advice to migrate to <code class=\"language-text\">ReentrantLock</code>; and the scheduler's default limit of 256 platform threads.</li>\n<li><a href=\"https://openjdk.org/jeps/533\">JEP 533: Structured Concurrency (Seventh Preview)</a>. Delivered in JDK 27 as a preview API. The JDK 27 changes (third type parameter, <code class=\"language-text\">ExecutionException</code> from the stock joiners, removal of <code class=\"language-text\">awaitAll()</code>, <code class=\"language-text\">timeout()</code> replacing <code class=\"language-text\">onTimeout()</code>); <code class=\"language-text\">withTimeout</code> producing an <code class=\"language-text\">ExecutionException</code> caused by <code class=\"language-text\">CancelledByTimeoutException</code>; that close \"always waits for threads executing subtasks to finish\" and that non-interruptible subtasks \"may delay the closing of a scope indefinitely\"; the <code class=\"language-text\">--enable-preview</code> flags; and scope grouping in the JSON thread dump.</li>\n<li><a href=\"https://docs.oracle.com/en/java/javase/27/core/virtual-threads.html\">Oracle Java SE 27 Core Libraries: Virtual Threads</a>. That a virtual thread is pinned when it runs a native method or foreign function; that \"database connection pools themselves serve as a semaphore\" with no need for another on top; what <code class=\"language-text\">Thread.print</code> and <code class=\"language-text\">Thread.dump_to_file</code> each include, and that the latter is not a consistent snapshot and \"doesn't perform deadlock detection\"; <code class=\"language-text\">Thread.vthread_scheduler</code>; and <code class=\"language-text\">jdk.VirtualThreadPinned</code> being \"enabled by default with a threshold of 20 ms\".</li>\n<li><a href=\"https://docs.oracle.com/en/java/javase/27/docs/api/java.base/java/util/concurrent/Semaphore.html\">Java SE 27 API: Semaphore</a>. That \"there is no requirement that a thread that releases a permit must have acquired that permit\", that semaphores have no notion of ownership, the recommendation to initialise resource-guarding semaphores as fair, that untimed <code class=\"language-text\">tryAcquire</code> ignores fairness, and the timed <code class=\"language-text\">tryAcquire</code> contract.</li>\n<li><a href=\"https://docs.oracle.com/en/java/javase/27/docs/api/java.base/java/util/concurrent/Future.html\">Java SE 27 API: Future</a>. The <code class=\"language-text\">cancel(boolean)</code> contract: an unstarted task \"should never run\", a running one is interrupted when <code class=\"language-text\">mayInterruptIfRunning</code> is true, and the return value \"does not necessarily indicate whether the task is now cancelled\".</li>\n<li><a href=\"https://docs.oracle.com/en/java/javase/27/docs/api/java.base/java/util/concurrent/ExecutorService.html\">Java SE 27 API: ExecutorService</a> and <a href=\"https://docs.oracle.com/en/java/javase/27/docs/api/java.base/java/util/concurrent/Executors.html\">Executors</a>. That <code class=\"language-text\">close()</code> waits until all tasks complete, with no limit; and that <code class=\"language-text\">newVirtualThreadPerTaskExecutor()</code> starts a new virtual thread per task and the thread count \"is unbounded\".</li>\n<li><a href=\"https://docs.oracle.com/en/java/javase/27/docs/api/jdk.jfr/jdk/jfr/consumer/RecordingStream.html\">Java SE 27 API: RecordingStream</a> and <a href=\"https://docs.oracle.com/en/java/javase/27/docs/api/jdk.jfr/jdk/jfr/EventSettings.html\">EventSettings</a>. That <code class=\"language-text\">stop()</code>, since JDK 20, waits until all recorded events have been consumed, with a Javadoc example monitoring a test method; and the <code class=\"language-text\">withoutThreshold()</code> and <code class=\"language-text\">withStackTrace()</code> settings used by the pinning probe.</li>\n<li><a href=\"https://docs.oracle.com/en/java/javase/27/docs/api/java.base/java/util/concurrent/StructuredTaskScope.html\">Java SE 27 API: StructuredTaskScope</a>. That it is a preview API usable only with preview features enabled, that a timeout cancellation \"interrupts the threads executing the subtasks that have not completed\", and the <code class=\"language-text\">Thread.ofVirtual().name(prefix, start).factory()</code> naming pattern.</li>\n<li><a href=\"https://github.com/brettwooldridge/HikariCP/blob/dev/README.md\">HikariCP README</a>. That <code class=\"language-text\">maximumPoolSize</code> defaults to 10, that an exhausted pool blocks <code class=\"language-text\">getConnection()</code> \"for up to <code class=\"language-text\">connectionTimeout</code> milliseconds\", that <code class=\"language-text\">connectionTimeout</code> defaults to 30000 and its \"lowest acceptable connection timeout is 250 ms\". README at release 7.1.0.</li>\n</ul>\n"}