Files
spring-async-demo/async/docs/08-virtual-threads-and-pinning.md
Ankur Mhatre 243cccd4ca 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:27:47 +05:30

71 lines
3.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)