Files
spring-async-demo/async/docs/05-pool-sizing.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

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:

  1. fewer than corePoolSize threads → create a thread;
  2. otherwise → offer the task to the queue;
  3. only if the queue refuses → create a thread, up to maxPoolSize;
  4. 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-capacity deliberately. 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-size to 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 to Runtime.availableProcessors().
  • Decide what rejection means. The default policy is AbortPolicy, so a full queue and a full pool produce RejectedExecutionException at the call site — synchronously, in whichever thread called the @Async method. If you want the caller to absorb the load instead, use CallerRunsPolicy, 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