TL;DR
You're submitting tasks to a ThreadPoolExecutor that's either already shut down or has a full queue with no free threads. Which fix applies depends on what's actually happening:
- Executor shutdown: stop submitting after calling
shutdown()orshutdownNow(). - Queue full: increase queue capacity, add more max threads, or switch to a
CallerRunsPolicyrejection handler.
What Triggers This Exception
The full stack trace looks like this:
java.util.concurrent.RejectedExecutionException: Task java.util.concurrent.FutureTask rejected from java.util.concurrent.ThreadPoolExecutor[Running, pool size = 10, active threads = 10, queued tasks = 100, completed tasks = 2048]
at java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:2063)
at java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:833)
at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1365)
Two scenarios trigger this error:
- Cause A โ executor is shut down: Someone called
executor.shutdown()orexecutor.shutdownNow(), but tasks keep arriving after that point. This often happens with singleton executors during app shutdown hooks โ the hook fires while producers are still running. - Cause B โ queue saturated: Every thread is busy AND the blocking queue has hit its limit. With the default
AbortPolicy, the next task thrown in immediately raisesRejectedExecutionException. No retries, no waiting.
Diagnosing Which Case You Have
Read the exception message carefully. Shutting down or Terminated in the state field means Cause A. Running with queued tasks at the limit means Cause B.
Still not sure? Add this before your submit call:
// Log executor state before submitting
ThreadPoolExecutor tpe = (ThreadPoolExecutor) executor;
System.out.println("Active: " + tpe.getActiveCount());
System.out.println("Queue size: " + tpe.getQueue().size());
System.out.println("Is shutdown: " + tpe.isShutdown());
Fix A: Executor Is Shut Down
Guard every submission with an isShutdown() check, or restructure your shutdown sequence so producers stop first.
// Bad โ submitting after shutdown
executor.shutdown();
executor.submit(someTask); // throws RejectedExecutionException
// Good โ check before submitting
if (!executor.isShutdown()) {
executor.submit(someTask);
}
Shared singletons (e.g., a Spring @Bean executor) need extra care. The @PreDestroy hook must run after all producers have stopped, not racing with them. A 30-second grace period is a reasonable starting point:
@PreDestroy
public void destroy() {
executor.shutdown();
try {
if (!executor.awaitTermination(30, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}
}
Fix B: Queue Is Full โ Choose a Rejection Policy
Java ships with four built-in RejectedExecutionHandler implementations. Pick based on what your app should do when the pool can't keep up:
Option 1: CallerRunsPolicy (most common fix)
The thread that submitted the task runs it directly instead of handing it off. This creates natural backpressure โ producers slow down when workers are overwhelmed. It's a great default for batch jobs and processing pipelines.
ThreadPoolExecutor executor = new ThreadPoolExecutor(
4, // core threads
10, // max threads
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(500),
new ThreadPoolExecutor.CallerRunsPolicy() // (500),
new ThreadPoolExecutor.DiscardOldestPolicy()
);
Option 3: DiscardPolicy
Silently drops the incoming task. Only use this when losing work is genuinely acceptable โ fire-and-forget analytics events, for example.
new ThreadPoolExecutor(
4, 10, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(500),
new ThreadPoolExecutor.DiscardPolicy()
);
Option 4: Custom policy with logging
In any system that handles real traffic, log the rejection before deciding what to do. Silent drops are debugging nightmares when something goes wrong at 3 AM.
RejectedExecutionHandler loggingPolicy = (task, executor) -> {
log.warn("Task rejected: {} | Pool: active={}, queue={}",
task,
executor.getActiveCount(),
executor.getQueue().size());
// Optionally retry or push to a dead-letter queue
};
ThreadPoolExecutor executor = new ThreadPoolExecutor(
4, 10, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(500),
loggingPolicy
);
Fix C: Resize the Pool or Queue
Sometimes the real fix is just giving the pool more room. If profiling shows your workload legitimately needs more concurrency, bump the parameters:
ThreadPoolExecutor executor = new ThreadPoolExecutor(
8, // more core threads
32, // higher max threads
120L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(2000) // larger queue
);
// Dynamic resize at runtime โ no restart needed
executor.setCorePoolSize(16);
executor.setMaximumPoolSize(64);
One thing to avoid: unbounded queues (new LinkedBlockingQueue<>() with no capacity argument). They absorb pressure silently until the JVM runs out of heap โ then you get an OutOfMemoryError instead of a RejectedExecutionException. OOMs are much harder to trace than a clear rejection. Set a limit and deal with the rejection explicitly.
Spring Boot Configuration
Spring's ThreadPoolTaskExecutor wraps ThreadPoolExecutor and works the same way. Configure it in your bean definition:
@Bean
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(32);
executor.setQueueCapacity(500);
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setThreadNamePrefix("async-");
executor.initialize();
return executor;
}
Or tune the defaults via application.properties without touching Java code:
spring.task.execution.pool.core-size=8
spring.task.execution.pool.max-size=32
spring.task.execution.pool.queue-capacity=500
Verifying the Fix
Don't just restart and hope. Hammer the executor under load and watch what happens:
- Submit tasks at peak rate โ no
RejectedExecutionExceptionshould appear in logs. - Expose metrics via JMX or Micrometer and monitor
executor.queueSizeandexecutor.activeCountin real time. - With
CallerRunsPolicy, you'll notice the submitting thread slow down under pressure. That's backpressure working as intended โ not a bug.
Here's a quick stress test with 100 tasks on a small pool (2 core, 4 max, queue of 10). All 100 should finish cleanly:
ExecutorService executor = new ThreadPoolExecutor(
2, 4, 30L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(10),
new ThreadPoolExecutor.CallerRunsPolicy()
);
for (int i = 0; i {
Thread.sleep(100);
System.out.println("Done: " + taskId + " on " + Thread.currentThread().getName());
return null;
});
}
executor.shutdown();
If all 100 tasks complete without exception, the fix is solid.
Summary
- Check
isShutdown()first โ submitting to a dead executor always throws. - For saturated pools,
CallerRunsPolicyis the right default in most cases. It slows producers naturally instead of dropping work. - Always use a bounded queue. Unbounded queues trade a clear
RejectedExecutionExceptionfor a silent OOM crash. - Log every rejection in production with a custom
RejectedExecutionHandlerโ you'll thank yourself later.

