Spring’s TaskExecutor abstracts task submission. The common
ThreadPoolTaskExecutor is a managed JavaBean wrapper around
ThreadPoolExecutor; its pool, queue, rejection, and lifecycle choices still
determine system capacity.
Defining and Naming an Executor #
Name executors by workload so callers select the intended resource:
@Bean("reportExecutor")
ThreadPoolTaskExecutor reportExecutor() {
var executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(24);
executor.setQueueCapacity(100);
executor.setKeepAliveSeconds(30);
executor.setThreadNamePrefix("report-");
executor.setRejectedExecutionHandler(
new ThreadPoolExecutor.CallerRunsPolicy());
return executor;
}Spring initializes and shuts down a ThreadPoolTaskExecutor as a managed bean.
Returning its concrete type preserves lifecycle configuration and monitoring
methods. Separate executors isolate workloads with different latency,
blocking, priority, or downstream-capacity requirements.
Pool, Queue, and Saturation Behavior #
The underlying executor follows a specific admission sequence:
- Create workers until
corePoolSizeis reached. - Queue new tasks while the work queue accepts them.
- Create workers up to
maxPoolSizeonly after the queue refuses an item. - Apply the rejection policy when both pool and queue are saturated.
An unbounded queue therefore prevents growth beyond the core size and converts
overload into growing latency and memory use. A bounded queue makes overload
observable. AbortPolicy rejects with an exception, CallerRunsPolicy applies
backpressure by running on the submitting thread, and discard policies lose
work and require an explicit product decision.
Core/max size, queue capacity, and keepalive must follow measured task service time, blocking ratio, arrival bursts, and downstream limits. A larger pool cannot increase a database’s connection capacity.
@Async Selection and Failures
#
Enable method interception with @EnableAsync. Select a named executor where
the application has more than one:
@Async("reportExecutor")
public CompletableFuture<Report> render(ReportRequest request) {
return CompletableFuture.completedFuture(renderer.render(request));
}Without an annotation qualifier, the infrastructure looks for a unique
TaskExecutor or an Executor bean named taskExecutor; AsyncConfigurer
can provide an explicit default. Proxy-based @Async has the same
self-invocation constraint as other Spring AOP advice.
Failures from Future or CompletableFuture methods are observed through that
result. A void async method has no result channel, so configure an
AsyncUncaughtExceptionHandler; logging alone is not a delivery or retry
guarantee.
Context Propagation #
Executor tasks run on another thread. Thread-local MDC, security, locale, and
observation context do not transfer automatically. A TaskDecorator can
capture selected state when a task is submitted, restore it around execution,
and clear it in finally. Copy only required context because stale or secret
state leaking to a reused worker is a correctness and security defect.
Graceful Shutdown #
Shutdown policy must match task semantics. Configure whether the executor waits for queued work and set a finite await-termination period. During service shutdown, first stop accepting new requests, then let accepted work finish within the deadline, and finally cancel or persist unfinished work. In-memory executor queues are not durable.
Spring Boot and Virtual Threads #
Spring Boot 3.2 and later can use Java 21 virtual threads for its auto-configured async executor with:
spring.threads.virtual.enabled=trueVirtual threads reduce the cost of one blocking thread per task, but they do not bound concurrent calls to databases, HTTP services, or other scarce resources. Use semaphores, connection pools, or workload admission limits for that purpose. Many pool-sizing properties do not apply when Boot selects a per-task virtual-thread executor.
On JDK 21 through 23, blocking while holding a monitor or in some native code can pin a virtual thread to its carrier. JDK 24’s JEP 491 removes monitor-based pinning, but native and foreign-function boundaries still require measurement. Use Java Flight Recorder and thread dumps to diagnose the JDK version actually deployed rather than assume virtual threads make all blocking harmless.
Comments
Read or comment on GitHub (sign-in required to comment)