Introduction
ThreadPoolExecutor rejects tasks when both the thread pool is at maximumPoolSize and the work queue is full. The default AbortPolicy throws RejectedExecutionException, causing request failures. This happens during traffic spikes when the pool cannot keep up with the submission rate, and tasks pile up faster than threads can process them.
Symptoms
java.util.concurrent.RejectedExecutionException: Task java.util.concurrent.FutureTask rejected from java.util.concurrent.ThreadPoolExecutor- Requests fail with 500 errors during traffic spikes
pool-1-thread-50(at maximum threads) with queue at capacity- Task rejection rate increases with traffic
ThreadPoolExecutorstate: Running, poolSize=max, queueSize=max
java.util.concurrent.RejectedExecutionException: Task com.example.Task@5f184fc6
rejected from java.util.concurrent.ThreadPoolExecutor[Running, pool size = 50,
active threads = 50, queued tasks = 1000, completed tasks = 45230]
at java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:2065)
at java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:833)Common Causes
- Fixed thread pool with bounded queue fills up under load
Executors.newFixedThreadPool()with unbounded LinkedBlockingQueue (causes OOM instead)- Tasks take too long (database locks, slow external calls)
- Thread pool size too small for the workload
- No rejection handling configured
Step-by-Step Fix
- 1.Configure proper pool sizing and queue:
- 2.```java
- 3.// WRONG - unbounded queue can cause OOM
- 4.ExecutorService executor = Executors.newFixedThreadPool(50);
- 5.// Uses LinkedBlockingQueue with Integer.MAX_VALUE capacity
// CORRECT - bounded queue with explicit configuration ThreadPoolExecutor executor = new ThreadPoolExecutor( 20, // corePoolSize 50, // maximumPoolSize 60, TimeUnit.SECONDS, // keepAliveTime new ArrayBlockingQueue<>(500), // bounded queue new ThreadFactoryBuilder().setNameFormat("worker-%d").build(), new ThreadPoolExecutor.CallerRunsPolicy() // rejection policy ); ```
- 1.Use CallerRunsPolicy for backpressure:
- 2.```java
- 3.// CallerRunsPolicy: rejected task runs in the submitting thread
- 4.// This naturally slows down task submission (backpressure)
- 5.ThreadPoolExecutor executor = new ThreadPoolExecutor(
- 6.10, 50, 60, TimeUnit.SECONDS,
- 7.new ArrayBlockingQueue<>(200),
- 8.new ThreadPoolExecutor.CallerRunsPolicy()
- 9.);
// When pool is saturated, the calling thread executes the task // HTTP requests slow down, load balancer detects and routes elsewhere ```
- 1.Implement custom rejection handler with metrics:
- 2.```java
- 3.ThreadPoolExecutor executor = new ThreadPoolExecutor(
- 4.10, 50, 60, TimeUnit.SECONDS,
- 5.new ArrayBlockingQueue<>(200),
- 6.(r, e) -> {
- 7.// Custom rejection handler
- 8.log.warn("Task rejected! Pool: {}/{}, Queue: {}",
- 9.e.getPoolSize(), e.getMaximumPoolSize(), e.getQueue().size());
// Record metric for alerting metrics.counter("threadpool.rejected").inc();
// Option: retry after delay try { Thread.sleep(100); e.execute(r); // Try again once } catch (InterruptedException | RejectedExecutionException ex) { log.error("Task permanently rejected", ex); } } ); ```
- 1.Monitor thread pool health:
- 2.```java
- 3.ScheduledExecutorService monitor = Executors.newSingleThreadScheduledExecutor();
- 4.monitor.scheduleAtFixedRate(() -> {
- 5.log.info("ThreadPool stats - active: {}, pool: {}, queue: {}, completed: {}",
- 6.executor.getActiveCount(),
- 7.executor.getPoolSize(),
- 8.executor.getQueue().size(),
- 9.executor.getCompletedTaskCount());
// Alert if queue is > 80% full if (executor.getQueue().remainingCapacity() < 100) { alertService.warn("Thread pool queue nearly full"); } }, 0, 30, TimeUnit.SECONDS); ```
- 1.Size thread pool correctly:
- 2.```java
- 3.// CPU-bound tasks: threads = number of cores
- 4.int cpuThreads = Runtime.getRuntime().availableProcessors();
// I/O-bound tasks: threads = cores * (1 + wait_time/compute_time) // E.g., if tasks spend 90% waiting: threads = cores * 10 int ioThreads = Runtime.getRuntime().availableProcessors() * 10;
// Use bounded queue: queue_size = expected_burst_rate * avg_task_time int queueSize = 500; // Based on load testing ```
Prevention
- Never use
Executors.newFixedThreadPool()without understanding the unbounded queue risk - Always use
ArrayBlockingQueueorLinkedBlockingQueue(int capacity)with explicit bounds - Choose
CallerRunsPolicyfor natural backpressure, or custom handler for observability - Monitor thread pool metrics in production dashboards
- Load test with traffic exceeding expected peak to verify rejection behavior
- Set
allowCoreThreadTimeOut(true)for intermittent workloads to reduce resource usage - In Spring Boot, configure via
spring.task.execution.pool.*properties