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.
71 lines
3.0 KiB
Markdown
71 lines
3.0 KiB
Markdown
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)
|