Java / JVM
java.util.concurrent.RejectedExecutionException: Task rejected from ThreadPoolExecutor
Written and reviewed by Sahil Srivastav
java.util.concurrent.RejectedExecutionException: Task java.util.concurrent.FutureTask@6b1e2d rejected from java.util.concurrent.ThreadPoolExecutor@3f2a1b[Running, pool size = 8, active threads = 8, queued tasks = 1000, completed tasks = 45211]What this error actually means
An executor refused a submission. Read the bracketed state in the message first, because it contains the diagnosis: pool size, active threads, queued tasks, and crucially whether the executor is `Running`, `Shutdown`, or `Terminated`.
If the state is `Running` with active threads at maximum and the queue at its bound, this is saturation and the rejection is the pool working as designed. A bounded queue plus a rejection policy is how you get backpressure; without them an overloaded system consumes memory until it dies. The exception is the pool telling you demand exceeds capacity — information, not malfunction.
If the state is `Shutdown` or `Terminated`, this is a lifecycle bug instead. Something is still submitting work to a pool that was closed — usually a shutdown-ordering problem where a component stops before the producers that feed it.
Causes, most common first
- 1Genuine saturation: arrival rate above service rate. Little’s law, not a bug. If tasks arrive faster than `poolSize / serviceTime`, the queue fills and rejections begin. Usually triggered by a downstream slowdown that lengthened service time rather than by a traffic increase.
- 2Submitting to an executor that has been shut down. Shutdown ordering is wrong: the pool stops while producers are still running. Common during application shutdown, context refresh, or when a per-tenant component is disposed while its callers live on.
- 3Queue bound far too small for normal burstiness. A queue of 10 on a bursty workload rejects during ordinary spikes even though average throughput is fine. The bound should absorb burst size, not equal average concurrency.
- 4Threads blocked rather than working. Active threads sit at maximum but are parked on I/O with no timeout, or waiting on a lock, or starved by nested submissions to the same pool. Capacity is nominally present and effectively zero.
- 5Core pool size of zero with a bounded queue. A subtle `ThreadPoolExecutor` behaviour: with a bounded queue, new threads beyond core size are only created once the queue is full. Misconfigured core and max sizes make a pool that queues when you expected it to scale.
When you see it
- Bursts of rejections at traffic peaks that clear when load falls (saturation)
- Rejections that start at shutdown or after a context refresh and never stop (lifecycle)
- Queue depth pinned at its maximum with active threads at the pool maximum
- Downstream latency rose shortly before rejections began, because slow tasks occupy threads longer
- Scheduled or retry tasks disappear silently if the rejection is caught and logged at debug level
How to diagnose it
Step 1
Parse the state in the exception message
The message already tells you pool size, active count, queue depth, completed count, and lifecycle state. `Running` with a full queue means capacity; `Shutdown` means ordering. Do not proceed before reading it.
Step 2
Instrument the pool properly
Export queue depth, active count, and rejection count continuously. Queue depth is the leading indicator — it rises for a while before the first rejection, which is your alarm window.
Metrics.gauge("executor.queue.depth", pool, p -> p.getQueue().size());
Metrics.gauge("executor.active", pool, ThreadPoolExecutor::getActiveCount);Step 3
Check what the active threads are actually doing
Take a thread dump during rejections. All pool threads on the same socket read means the fix is a downstream timeout. All waiting on a task they submitted themselves means a starvation deadlock.
jcmd <pid> Thread.print | grep -A 12 "<pool-name-prefix>"Step 4
Confirm lifecycle ordering if the state is Shutdown
Find who calls `shutdown()` and verify every producer has stopped first. Log both events with timestamps and compare; the overlap window is the bug.
The fix
For saturation, choose a rejection policy deliberately — this is a product decision, not a technical default. `CallerRunsPolicy` pushes work back onto the submitting thread, which naturally slows the producer and is the right choice for internal pipelines where slowing down is preferable to dropping. `AbortPolicy` (the default) surfaces the rejection so you can return HTTP 503 and let the client retry, which is right at a request boundary. `DiscardPolicy` silently drops work and is acceptable only for genuinely disposable tasks like metrics samples.
Never fix saturation by making the queue unbounded. That converts a visible rejection into an invisible latency and memory problem: tasks accumulate, the queue eats the heap, and by the time you notice, every queued task is answering a request whose client gave up minutes ago. A bounded queue is a feature.
Size from measurement. Pool size should follow the bottleneck — roughly core count for CPU-bound work, and for I/O-bound work `concurrency = targetThroughput * serviceTime`. Set the queue bound to absorb your observed burst size, then let the policy handle the rest.
If threads are blocked rather than working, add timeouts to every outbound call. A pool whose threads wait forever has zero effective capacity no matter how you size it.
For the lifecycle case, fix the ordering: stop accepting new work, then stop producers, then `shutdown()` the pool and `awaitTermination` with a timeout. If late submissions are legitimate and unavoidable, catch the rejection at the boundary and degrade explicitly rather than letting it propagate as a 500.
// Unbounded queue: rejections disappear, and so does your heap
new ThreadPoolExecutor(8, 8, 60, SECONDS, new LinkedBlockingQueue<>());
// Bounded queue + explicit backpressure + named threads
var pool = new ThreadPoolExecutor(
8, 8, 60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1_000),
new ThreadFactoryBuilder().setNameFormat("order-dispatch-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy());
// Shutdown in the right order
acceptingWork = false;
producers.stop();
pool.shutdown();
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) pool.shutdownNow();How to stop it coming back
- Alarm on queue depth crossing a fraction of its bound, not on the rejection itself — depth gives you minutes of warning
- Make rejection policy an explicit, reviewed choice for every pool; the default being silent-abort surprises people
- Name every pool; unnamed `pool-N-thread-M` threads make incident diagnosis dramatically slower
- Never let a task submitted to a pool block waiting on another task in the same pool
- Test the shutdown path under load, which is the only condition that reveals ordering bugs
FAQ
Should I just use an unbounded queue?
No. It trades a loud, actionable rejection for unbounded memory growth and unbounded latency. Tasks pile up behind work whose callers have already timed out, and the failure mode becomes an OutOfMemoryError with no indication of which subsystem overloaded.
Which rejection policy should I choose?
`CallerRunsPolicy` for internal pipelines where slowing the producer is correct. `AbortPolicy` at request boundaries, mapped to HTTP 503 with `Retry-After`. `DiscardOldestPolicy` only where fresher data supersedes stale, such as live telemetry. `DiscardPolicy` only for work nobody will miss.
Why are new threads not created even though max is higher than core?
With a bounded queue, `ThreadPoolExecutor` only grows past core size once the queue is full. So the effective order is: fill core threads, fill queue, then grow to max. Expecting threads to be added before queueing is one of the most common misconfigurations.
Can this happen with a healthy downstream and modest traffic?
Yes — if pool threads submit work to the same pool and wait for it. Every thread holds a slot while waiting for a task that cannot be scheduled. Throughput goes to zero at low load, and the rejection message shows a full queue with all threads active but idle.
Related
Other errors engineers hit next to this one
- java.lang.OutOfMemoryError: GC overhead limit exceeded
- java.util.ConcurrentModificationException
- OutOfMemoryError: unable to create new native thread
- Found one Java-level deadlock (thread dump)
- FATAL: sorry, too many clients already
- Sessions stuck in "idle in transaction"
- ERROR: deadlock detected
- ERROR: canceling statement due to statement timeout