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.
2.6 KiB
README · next: The self-invocation trap
1. What @Async actually does
@Async is not a keyword and it is not a thread. It is a marker that
AsyncAnnotationBeanPostProcessor
looks for while the context is being built. When it finds one, it wraps the bean in a proxy. The
proxy's version of the method does not call your code; it wraps your code in a Callable, hands
that to an AsyncTaskExecutor, and returns immediately.
Three consequences follow from that sentence, and between them they explain almost every
@Async question ever asked:
- The proxy is the whole mechanism. A call that does not go through the proxy is not asynchronous. See chapter 2 and chapter 3.
- The return value has to be produced before your code runs. So the return type is constrained, and exceptions have nowhere obvious to go. See chapter 4.
- Which executor gets the
Callableis resolved separately, by type and then by name. It is not necessarily the one you configured. See chapter 7.
@EnableAsync is not automatic
Spring Boot auto-configures the executor. It does not enable the annotation. Without
@EnableAsync somewhere in the context, no post-processor is installed, no proxy is created, and
every @Async method in the application runs on its caller's thread. There is no warning at any
log level, because from Spring's point of view nothing unusual has happened — you have a bean
with an annotation nobody asked it to process.
AsyncDemoApplication carries the annotation for exactly this reason.
The evidence in this module
Every claim in these chapters is asserted by a test and captured in docs/output/. The
measurement is always the same one: the name of the thread the method body actually ran on,
returned from the method itself.
caller thread : main (virtual=false)
service.annotated() : task-5 (virtual=false)
service.viaSelfInvocation() : main (virtual=false)
service.viaSelfReference() : task-6 (virtual=false)
Timing cannot tell you this. A method that runs synchronously in 3 ms and a method that runs on a
pool thread in 3 ms look identical from the outside, which is why @Async failures survive so
long in production.
next: The self-invocation trap