Files
spring-async-demo/async/docs/08-virtual-threads-and-pinning.md
Ankur Mhatre 9b3fe88813 Add the async module
Every @Async behaviour that surprises people, asserted by a test and captured
to docs/output/: the self-invocation trap, what CGLIB cannot override, the
IllegalArgumentException a plain return type throws, the unbounded queue that
makes max-size decoration, spring.task.execution.propagate-context (new in
Boot 4.1.0), the two Executor beans that leave @Async on an unpooled
SimpleAsyncTaskExecutor, and JEP 491 measured on JDK 21 against JDK 25.
2026-09-01 23:32:16 +05:30

3.0 KiB
Raw Blame History

prev: Which executor runs it · README

8. Virtual threads, and the pinning advice that expired

spring:
  threads:
    virtual:
      enabled: true

applicationTaskExecutor becomes a SimpleAsyncTaskExecutor over virtual threads. From docs/output/virtual-threads.txt:

applicationTaskExecutor : org.springframework.core.task.SimpleAsyncTaskExecutor
@Async ran on           : task-1 (virtual=true)

Note that the thread-name prefix is unchanged, so task-1 alone does not tell you which world you are in. Thread.currentThread().isVirtual() does.

What you gave up

The pool properties are still bound and now mean nothing. Boot's own metadata says so for each one: "Doesn't have an effect if virtual threads are enabled." core-size, max-size, queue-capacity, keep-alive — all inert. The test in VirtualThreadsTest sets core-size=4 and max-size=12 precisely to show that they are accepted and ignored.

More importantly, a SimpleAsyncTaskExecutor has no queue and, by default, no concurrency limit. Cheap threads are not free downstream capacity: ten thousand concurrent @Async calls to a service with a twenty-connection pool is ten thousand threads queueing on a semaphore. If you want a bound, set spring.task.execution.simple.concurrency-limit, and decide whether spring.task.execution.simple.reject-tasks-when-limit-reached should be true (fail fast) or left false (block the caller).

The pinning advice

Nearly everything written about virtual threads before 2025 tells you to avoid synchronized, because a virtual thread that blocks while holding a monitor pins its carrier. JEP 491, delivered in JDK 24, removed that.

PinningProbe runs 32 virtual threads, each sleeping 300 ms, with the scheduler limited to two carrier threads — once with the sleep inside a synchronized block on an uncontended private monitor, once without. If pinning happens, the guarded run must take about 32 / 2 × 300 ms = 4800 ms. The same class file, run on both JVMs (docs/output/pinning-probe.txt):

java.version : 21.0.12.1
no monitor held            :   313 ms
blocked inside synchronized:  4806 ms

java.version : 25.0.4.1
no monitor held            :   312 ms
blocked inside synchronized:   301 ms

4806 ms against the 4800 ms the arithmetic predicts, then the whole effect gone.

What has not changed: a virtual thread still pins its carrier while executing a native frame or inside a class initialiser. And synchronized is still a mutual-exclusion lock, so a contended monitor still serialises your work — JEP 491 removed the carrier-thread cost, not the lock. ReentrantLock remains preferable where you want fairness, timeouts or tryLock; it is no longer required merely to avoid pinning.

If you are on JDK 21 — still an LTS, still perfectly reasonable — the old advice is your advice, and the 4806 ms above is what it is protecting you from.

README