How do you configure the thread pool behind @Async, and what's the classic ThreadPoolTaskExecutor sizing gotcha?
answer
- ThreadPoolTaskExecutor: core / max / queueCapacity
- grows past core ONLY when queue is full
- default queue = unbounded => max ignored
- bound the queue to reach maxPoolSize
- no bean => SimpleAsyncTaskExecutor (not a pool)
basics
~10 sDefine a ThreadPoolTaskExecutor bean and tune corePoolSize, maxPoolSize, and queueCapacity. The gotcha: with an unbounded queue the pool never grows past corePoolSize, so maxPoolSize is effectively ignored until the queue is bounded.
solid answer
~40 sYou provide a `ThreadPoolTaskExecutor` bean and set `corePoolSize`, `maxPoolSize`, `queueCapacity`, `keepAliveSeconds`, `threadNamePrefix`, and a rejection policy. The sizing gotcha comes from how the underlying `ThreadPoolExecutor` grows: it only creates threads beyond `corePoolSize` **after the queue is full**. `ThreadPoolTaskExecutor`'s default `queueCapacity` is effectively unbounded (`Integer.MAX_VALUE`), so the queue never fills, so the pool **never grows to maxPoolSize** — tasks just pile up in memory. To actually use `maxPoolSize` you must set a bounded `queueCapacity`. Also decide a `RejectedExecutionHandler` for when both pool and queue are saturated (default aborts with an exception). Give threads a `threadNamePrefix` for debuggable logs. If you don't define any executor, Spring core falls back to `SimpleAsyncTaskExecutor`, which is not a pool at all.
code
java · 16 lines@Configuration
@EnableAsync
public class AsyncConfig {
@Bean("taskExecutor")
public ThreadPoolTaskExecutor taskExecutor() {
var ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(8);
ex.setMaxPoolSize(16);
ex.setQueueCapacity(100); // bounded, so pool can grow to 16
ex.setThreadNamePrefix("async-");
ex.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
ex.initialize();
return ex;
}
}go deeper
Knows you configure a ThreadPoolTaskExecutor with core/max/queue settings.
Can explain the queue-fills-before-pool-grows rule and why the unbounded default hides maxPoolSize.
Chooses rejection policies and sizes pools by workload (I/O vs CPU), and knows the SimpleAsyncTaskExecutor fallback risk.
Designs for back-pressure and graceful shutdown, and reasons about Boot's applicationTaskExecutor defaults and virtual-thread options.
**Where the pool comes from.** `@Async` needs a `TaskExecutor` to run tasks. You supply one by declaring a bean, most commonly `org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor`, which wraps a JDK `java.util.concurrent.ThreadPoolExecutor`. **Key knobs on ThreadPoolTaskExecutor.** - `corePoolSize` — threads kept alive even when idle (default 1). - `maxPoolSize` — hard ceiling on threads (default `Integer.MAX_VALUE`). - `queueCapacity` — size of the work queue (default `Integer.MAX_VALUE`, i.e. effectively unbounded `LinkedBlockingQueue`). - `keepAliveSeconds` — idle timeout for threads above the core size. - `threadNamePrefix` — names threads (e.g. `async-1`) so stack traces and logs are readable. - `setRejectedExecutionHandler(...)` — what to do when saturated (default `AbortPolicy` → throws `RejectedExecutionException`). - `setAllowCoreThreadTimeOut`, `setWaitForTasksToCompleteOnShutdown`, `setAwaitTerminationSeconds` — lifecycle/graceful-shutdown controls. **The growth algorithm (why the gotcha exists).** A JDK `ThreadPoolExecutor` admits work in this order: (1) if fewer than `corePoolSize` threads exist, start a new thread; (2) else try to **enqueue** the task; (3) only if the queue is **full** does it create threads up to `maxPoolSize`; (4) if the queue is full **and** `maxPoolSize` is reached, the task is **rejected**. Consequence: if `queueCapacity` is unbounded (the default), step 3 never triggers — the queue always accepts more, so the pool stays at `corePoolSize` forever and `maxPoolSize` is meaningless. Under load you get one busy core thread and an ever-growing queue (a slow-motion memory leak / latency spike) instead of the parallelism you expected. **Fix:** set a bounded `queueCapacity` so the pool can escalate to `maxPoolSize`, and choose a rejection policy for overflow (`CallerRunsPolicy` applies natural back-pressure by running the task on the submitting thread). **Config example.** ```java @Bean("taskExecutor") public ThreadPoolTaskExecutor taskExecutor() { var ex = new ThreadPoolTaskExecutor(); ex.setCorePoolSize(8); ex.setMaxPoolSize(16); ex.setQueueCapacity(100); // bounded => maxPoolSize is reachable ex.setThreadNamePrefix("async-"); ex.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); ex.initialize(); return ex; } ``` **If you configure nothing.** Spring **core** (without Boot) falls back to `SimpleAsyncTaskExecutor`, which does **not** pool threads — it spins up a brand-new thread for every task (optionally throttled by a concurrency limit). Fine for tests, dangerous in production because unbounded thread creation can exhaust the machine. (Since Spring 6.1 `SimpleAsyncTaskExecutor` can be switched to virtual threads.) **Spring Boot** improves on this by auto-configuring a real `ThreadPoolTaskExecutor` named `applicationTaskExecutor` (aliased `taskExecutor`) with `spring.task.execution.*` properties, so Boot users already get a pool. **Sizing guidance.** For I/O-bound async work (the common case), pools can be larger than core count because threads spend time blocked. For CPU-bound work, keep it near the number of cores. Always bound the queue and pick an explicit rejection policy so overload fails predictably instead of ballooning memory.
- Your executor has corePoolSize=4, maxPoolSize=50, and the default queue. Under heavy load only 4 threads ever run. Why?The default queueCapacity is unbounded, so tasks always enqueue and the pool never needs to grow beyond core size. Set a bounded queueCapacity so the queue can fill and trigger creation of threads up to maxPoolSize.
- What does CallerRunsPolicy give you compared to the default AbortPolicy?AbortPolicy throws RejectedExecutionException when saturated. CallerRunsPolicy instead runs the rejected task on the submitting thread, which slows the producer and provides natural back-pressure instead of dropping/erroring.
saying these in an interview costs you the question
- Believing maxPoolSize is honored regardless of queue capacity
- Thinking the default @Async executor in plain Spring is a pool (it's SimpleAsyncTaskExecutor)
- Leaving queueCapacity unbounded and expecting parallelism to scale