# async Companion project for **@Async in Spring Boot 4: Executors, Virtual Threads and the Self-Invocation Trap** on [ankurm.com](https://ankurm.com). Everything here is asserted by a test and captured under [`docs/output/`](docs/output). The measurement is always the same: the name of the thread the method body actually ran on, returned by the method itself. Timing cannot distinguish a fast synchronous call from an asynchronous one; a thread name can. ## Verified stack | Component | Version | Source of the number | |---|---|---| | JDK | 25.0.4.1+1 (Temurin) | `java -version` | | Spring Boot | 4.1.1 | `maven-metadata.xml` on Maven Central | | Spring Framework | 7.0.9 | `spring-boot-dependencies-4.1.1.pom` | | micrometer context-propagation | Boot-managed | `spring-boot-dependencies-4.1.1.pom` | | JDK used for the pinning comparison | 21.0.12.1+1 (Temurin) | `java -version` | ## Quickstart ```bash mvn test # 22 tests, all of the transcripts scripts/run-all.sh # regenerates every docs/output/ file scripts/run-all.sh /path/to/jdk21/bin/java # adds the JDK 21 vs 25 pinning comparison ``` ## Documentation | Chapter | What it settles | |---|---| | [01 What `@Async` actually does](docs/01-what-async-actually-does.md) | The proxy is the whole mechanism, and `@EnableAsync` is not automatic | | [02 The self-invocation trap](docs/02-the-self-invocation-trap.md) | Why an internal call runs inline, and the three fixes | | [03 What the proxy cannot see](docs/03-what-the-proxy-cannot-see.md) | `final` and `private` methods, and how to tell that case from a self-invocation | | [04 Return types and exceptions](docs/04-return-types-and-exceptions.md) | A plain return type throws; it does not return null | | [05 Pool sizing](docs/05-pool-sizing.md) | Why `max-size` does nothing until `queue-capacity` is bounded | | [06 Context propagation](docs/06-context-propagation.md) | `spring.task.execution.propagate-context`, new in Boot 4.1.0 | | [07 Which executor runs it](docs/07-which-executor-runs-it.md) | Two `Executor` beans and `@Async` quietly uses neither | | [08 Virtual threads and pinning](docs/08-virtual-threads-and-pinning.md) | JEP 491 measured on JDK 21 against JDK 25 | ## Captured output | File | What it shows | |---|---| | [`self-invocation.txt`](docs/output/self-invocation.txt) | The same method on `task-5`, on `main`, and on `task-6` | | [`visibility.txt`](docs/output/visibility.txt) | `final` is inert; `protected` is not | | [`return-types.txt`](docs/output/return-types.txt) | The `IllegalArgumentException` a plain return type throws | | [`executor-report.txt`](docs/output/executor-report.txt) | Stock pool numbers, read off the live bean | | [`executor-one-custom.txt`](docs/output/executor-one-custom.txt) | Boot backing off in favour of one custom `Executor` | | [`executor-two-custom.txt`](docs/output/executor-two-custom.txt) | Two custom executors and neither is used | | [`executor-force-mode.txt`](docs/output/executor-force-mode.txt) | `spring.task.execution.mode=force` restoring it | | [`pool-unbounded-queue.txt`](docs/output/pool-unbounded-queue.txt) | 16 tasks, 4 threads, `max-size=12` ignored | | [`pool-bounded-queue.txt`](docs/output/pool-bounded-queue.txt) | The same 16 tasks on 12 threads | | [`virtual-threads.txt`](docs/output/virtual-threads.txt) | `SimpleAsyncTaskExecutor`, `virtual=true`, pool properties inert | | [`context-propagation.txt`](docs/output/context-propagation.txt) | A `ThreadLocal` surviving the hop | | [`pinning-probe.txt`](docs/output/pinning-probe.txt) | 4806 ms on JDK 21, 301 ms on JDK 25 | | [`tests.txt`](docs/output/tests.txt) | The test run behind all of the above | ## The one diagnostic `ExecutorDiagnostics` prints every `Executor` bean in the context with its real core size, max size and queue capacity. It is the fastest way to answer "which executor is actually running this". Delete it before shipping.