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.
This commit is contained in:
2026-09-01 23:27:47 +05:30
commit 9b3fe88813
53 changed files with 1891 additions and 0 deletions

View File

@@ -0,0 +1,70 @@
prev: [Which executor runs it](07-which-executor-runs-it.md) · [README](../README.md)
# 8. Virtual threads, and the pinning advice that expired
```yaml
spring:
threads:
virtual:
enabled: true
```
`applicationTaskExecutor` becomes a `SimpleAsyncTaskExecutor` over virtual threads. From
[`docs/output/virtual-threads.txt`](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`](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](../README.md)