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.8 KiB
prev: Return types and exceptions · README · next: Context propagation
5. Pool sizing, and why max-size usually does nothing
The stock executor, printed from the live bean in
docs/output/executor-report.txt:
applicationTaskExecutor -> org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor
corePoolSize=8 maxPoolSize=2147483647 queueCapacity=2147483647 threadNamePrefix=task-
Eight core threads, an effectively unbounded maximum, and an effectively unbounded queue.
ThreadPoolTaskExecutor delegates to java.util.concurrent.ThreadPoolExecutor, whose growth
rule is the part people misremember. It is not "grow under load". It is:
- fewer than
corePoolSizethreads → create a thread; - otherwise → offer the task to the queue;
- only if the queue refuses → create a thread, up to
maxPoolSize; - otherwise → reject.
An unbounded queue never refuses. So with the defaults, step 3 is unreachable and maxPoolSize
is decoration.
Two runs of the same 16 blocking tasks, differing in one property:
| properties | distinct threads used |
|---|---|
core-size=4, max-size=12 |
4 (output) |
core-size=4, max-size=12, queue-capacity=4 |
12 (output) |
Triple the concurrency, one line of YAML. The corollary is that raising max-size alone — the
usual response to a slow async pipeline — changes nothing at all.
What to set instead
- Set
queue-capacitydeliberately. It is the backpressure boundary. Unbounded means an incident consists of a heap filling up rather than tasks being rejected, which is the worse of the two failure modes because it takes the whole process with it. - Size
core-sizeto the work, not to the CPU. Async work in most Spring applications is I/O bound, so the useful number is closer to "how many concurrent downstream calls will that service tolerate" than toRuntime.availableProcessors(). - Decide what rejection means. The default policy is
AbortPolicy, so a full queue and a full pool produceRejectedExecutionExceptionat the call site — synchronously, in whichever thread called the@Asyncmethod. If you want the caller to absorb the load instead, useCallerRunsPolicy, and understand that you have just made the method synchronous under saturation.
Shutdown
spring.task.execution.shutdown.await-termination is false by default. On shutdown, tasks that
are still queued are simply dropped. If your async work is "send the email" rather than "warm the
cache", set it to true and give
spring.task.execution.shutdown.await-termination-period a bound.
next: Context propagation