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.
3.2 KiB
prev: Context propagation · README · next: Virtual threads and pinning
7. Which executor actually runs it
Two independent decisions are involved, and conflating them is the source of most of the surprise.
Decision one — does Boot create applicationTaskExecutor? TaskExecutorConfigurations gates
it on OnExecutorCondition, an AnyNestedCondition whose arms are "there is no Executor bean"
and "spring.task.execution.mode is force". So any Executor bean of your own removes
Boot's — along with its properties, its thread-name prefix, and its context-propagation
decorator.
Decision two — which executor does @Async resolve? AsyncExecutionAspectSupport asks the
bean factory for a unique bean of type TaskExecutor. Failing that, it looks for a bean named
exactly taskExecutor. Failing that, it falls back to a plain SimpleAsyncTaskExecutor.
Three contexts, all captured:
| context | executor beans | @Async ran on |
|---|---|---|
| stock (output) | applicationTaskExecutor |
task-1 |
one custom Executor (output) |
myExecutor |
mine-1 |
two custom Executors (output) |
reportsExecutor, emailsExecutor |
SimpleAsyncTaskExecutor-1 |
The third row is the one that hurts. Neither of your carefully sized two-thread pools is used.
SimpleAsyncTaskExecutor starts a brand new platform thread for every single call and has no
bound, so an application that was throttled to two concurrent report generations is now
unthrottled, and the symptom is thread exhaustion under load rather than anything at the point of
the change.
It is not entirely silent — the interceptor logs it, once, at INFO:
o.s.a.i.AnnotationAsyncExecutionInterceptor : More than one TaskExecutor bean found within
the context, and none is named 'taskExecutor'. Mark one of them as primary or name it
'taskExecutor' (possibly as an alias) in order to use it for async processing:
[reportsExecutor, emailsExecutor]
INFO, on first use, in the middle of startup noise. In practice nobody sees it.
The three ways out
@Async("reportsExecutor")— name the executor at each call site. Explicit, and it survives someone adding a third executor later. This is the right answer when the executors genuinely differ in purpose.spring.task.execution.mode=force— Boot createsapplicationTaskExecutoralongside yours, and@Asyncresolves it (output). Use it when your extraExecutorbeans exist for something other than@Asyncand you did not mean to disturb it.- Name one of them
taskExecutor, or mark it@Primary. Works, and reads like an accident to the next person.
The diagnostic
ExecutorDiagnostics prints every Executor bean, its class, and the real pool numbers off the
live object. Two lines of it answer questions that otherwise take an afternoon:
Bean named 'taskExecutor' present: false
Bean named 'applicationTaskExecutor' present: false
Delete it before shipping. It is a diagnostic, not a feature.