Fix java.util.concurrent.RejectedExecutionException When ThreadPool Is Full or Shutdown

intermediateโ˜• Java2026-05-17| Java 8+, any OS (Linux, Windows, macOS), Spring Boot, Jakarta EE, or standalone Java applications using ExecutorService or ThreadPoolExecutor

Error Message

java.util.concurrent.RejectedExecutionException: Task java.util.concurrent.FutureTask rejected from java.util.concurrent.ThreadPoolExecutor
#java#concurrency#threadpool#executor-service#rejected-execution

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() or shutdownNow().
  • Queue full: increase queue capacity, add more max threads, or switch to a CallerRunsPolicy rejection 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() or executor.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 raises RejectedExecutionException. 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 RejectedExecutionException should appear in logs.
  • Expose metrics via JMX or Micrometer and monitor executor.queueSize and executor.activeCount in 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, CallerRunsPolicy is the right default in most cases. It slows producers naturally instead of dropping work.
  • Always use a bounded queue. Unbounded queues trade a clear RejectedExecutionException for a silent OOM crash.
  • Log every rejection in production with a custom RejectedExecutionHandler โ€” you'll thank yourself later.

Related Error Notes