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.
3.0 KiB
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.