A RejectedExecutionHandler is the callback that a ThreadPoolExecutor calls when execute() cannot accept a task, either because the executor has been shut down or because all threads are busy and the work queue is full. The handler decides what happens to the rejected task, and by default it throws a RejectedExecutionException.
We set a RejectedExecutionHandler when a thread pool has a bounded queue and we must choose what to do with the overflow, for example run the task in the caller’s thread, or log it and tell the user to try again later.
The following example creates a pool of 2 threads with room for 2 waiting tasks and sends it 6 thumbnail jobs from a photo upload app. The pool uses CallerRunsPolicy, so the thread that calls execute() runs the jobs that do not fit.
ThreadPoolExecutor pool = new ThreadPoolExecutor(
2, 2, // 2 threads
0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(2), // room for 2 waiting jobs
new ThreadPoolExecutor.CallerRunsPolicy()); // the handler for rejected jobs
pool.execute(new ThumbnailJob("photo-1")); // runs on pool-1-thread-1
pool.execute(new ThumbnailJob("photo-2")); // runs on pool-1-thread-2
pool.execute(new ThumbnailJob("photo-3")); // waits in the queue
pool.execute(new ThumbnailJob("photo-4")); // waits in the queue
pool.execute(new ThumbnailJob("photo-5")); // rejected, so main runs it
Notice that photo-5 is not lost. The pool has no free thread and no free queue slot, so it calls the handler, and CallerRunsPolicy runs the job in the main thread.
Next, we see when a ThreadPoolExecutor rejects a task and compare the four built-in policies on the same pool. After that, we write custom handlers that log the rejection or wait for free queue space, and look at rejections after shutdown().
1. When Does ThreadPoolExecutor Reject a Task?
A ThreadPoolExecutor rejects a task in two cases. In both cases, execute() calls the method rejectedExecution(Runnable, ThreadPoolExecutor) of its handler.
- The executor has been shut down, so it does not accept new tasks.
- The executor is saturated, i.e. it already runs its maximum number of threads and its bounded work queue is full.
For a new task, the method execute() checks the pool in a fixed order, and the first step that works wins.
- While the pool has fewer than corePoolSize threads, it starts a new thread for the task.
- When all core threads are busy, it puts the task in the work queue.
- When the queue is full, it starts an extra thread, up to maximumPoolSize threads.
- When the pool already has maximumPoolSize threads, it rejects the task.

A pool with an unbounded queue never rejects a task while it runs. The pools from Executors.newFixedThreadPool() and Executors.newSingleThreadExecutor() use an unbounded LinkedBlockingQueue, so the queue always accepts the task, and these pools reject tasks only after shutdown(). To get rejections because of load, we create the ThreadPoolExecutor ourselves with a bounded queue such as an ArrayBlockingQueue.
A bounded queue is the safer choice for production. For example, a photo upload app gets a burst of 10,000 uploads after a marketing email. With an unbounded queue, all 10,000 thumbnail jobs wait in memory, and the last users wait many minutes for their thumbnails. With a bounded queue, the handler can reject the overflow, so the app tells those users to try again later.
2. The Four Built-in Rejection Policies
The class ThreadPoolExecutor has four nested classes that implement RejectedExecutionHandler. We pass one of them to the constructor or to setRejectedExecutionHandler(). If we pass none, the executor uses AbortPolicy.
| Policy | What happens to the rejected task | After shutdown() |
|---|---|---|
| AbortPolicy (default) | execute() throws RejectedExecutionException | throws RejectedExecutionException |
| CallerRunsPolicy | the thread that called execute() runs the task | the task is discarded without any message |
| DiscardPolicy | the task is dropped without any message | the task is dropped without any message |
| DiscardOldestPolicy | the oldest task in the queue is dropped, and execute() is retried | the task is discarded without any message |
The following examples use the pool from the intro with 2 threads and a queue of 2, and send it 6 jobs, photo-1 to photo-6. Each ThumbnailJob takes about half a second and prints the name of the thread that ran it. The examples run on Java 25, and the code compiles on Java 21 or later.
2.1. AbortPolicy
AbortPolicy throws a RejectedExecutionException, which is an unchecked exception, so the compiler does not remind us to catch it. Without a catch, the exception stops the code that submits the jobs, for example the HTTP request that handles the upload. So we catch it where we call execute() and turn it into a clear answer for the user.
ThreadPoolExecutor pool = new ThreadPoolExecutor(2, 2, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(2), new ThreadPoolExecutor.AbortPolicy());
for (int i = 1; i <= 6; i++) {
ThumbnailJob job = new ThumbnailJob("photo-" + i);
try {
pool.execute(job);
} catch (RejectedExecutionException e) {
System.out.println(job.photo() + " rejected: " + e.getClass().getSimpleName());
}
}
photo-5 rejected: RejectedExecutionException
photo-6 rejected: RejectedExecutionException
photo-1 done by pool-1-thread-1
photo-2 done by pool-1-thread-2
photo-4 done by pool-1-thread-2
photo-3 done by pool-1-thread-1
completed tasks = 4
We can see that photo-5 and photo-6 fail at once, whereas the four accepted jobs finish normally. The exception message names the task and the state of the pool, for example Task ThumbnailJob[photo=photo-5] rejected from java.util.concurrent.ThreadPoolExecutor@….
2.2. CallerRunsPolicy
CallerRunsPolicy runs the rejected task in the thread that called execute(). While that thread runs the task, it cannot submit more tasks, so the submission rate drops by itself. This effect is called backpressure, and the Javadoc describes the policy as a “simple feedback control mechanism”.
ThreadPoolExecutor pool = new ThreadPoolExecutor(2, 2, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(2), new ThreadPoolExecutor.CallerRunsPolicy());
photo-1 done by pool-2-thread-1
photo-5 done by main
photo-2 done by pool-2-thread-2
photo-3 done by pool-2-thread-1
photo-4 done by pool-2-thread-2
photo-6 done by pool-2-thread-1
completed tasks = 5
The main thread ran photo-5 itself. When main finished, the pool threads had taken photo-3 and photo-4 from the queue, so photo-6 found a free queue slot. In some runs, the timing overlaps and main runs photo-6 too. The count getCompletedTaskCount() includes only the jobs that the pool threads ran.
The cost of CallerRunsPolicy is that the caller becomes slow. In a web app, the caller is a request thread, so an upload request that runs a thumbnail job takes as long as the job.
2.3. DiscardPolicy
DiscardPolicy does nothing, so the rejected task is gone and nobody is told. The Javadoc says that the policy is designed “only for those rare cases in which task completion is never relied upon”, such as a cache refresh that the next request triggers again.
ThreadPoolExecutor pool = new ThreadPoolExecutor(2, 2, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(2), new ThreadPoolExecutor.DiscardPolicy());
photo-1 done by pool-3-thread-1
photo-2 done by pool-3-thread-2
photo-4 done by pool-3-thread-2
photo-3 done by pool-3-thread-1
completed tasks = 4
The jobs photo-5 and photo-6 never ran, and the output has no line about them. DiscardPolicy has one more trap with submit(), which returns a Future. The Future of a discarded task never completes, so a call to get() without a timeout waits forever. We always call get() with a timeout on a pool that discards tasks.
ThreadPoolExecutor single = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(1), new ThreadPoolExecutor.DiscardPolicy());
single.submit(new ThumbnailJob("photo-1")); // runs
single.submit(new ThumbnailJob("photo-2")); // waits in the queue
Future<?> dropped = single.submit(new ThumbnailJob("photo-3")); // discarded by the handler
try {
dropped.get(1, TimeUnit.SECONDS);
} catch (TimeoutException e) {
boolean done = dropped.isDone(); // false, the Future never completes
}
2.4. DiscardOldestPolicy
DiscardOldestPolicy removes the task at the head of the queue, which is the oldest waiting task, and calls execute() again with the new task. The retry can be rejected again, in which case the handler drops the next oldest task.
ThreadPoolExecutor pool = new ThreadPoolExecutor(2, 2, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(2), new ThreadPoolExecutor.DiscardOldestPolicy());
photo-1 done by pool-4-thread-1
photo-2 done by pool-4-thread-2
photo-5 done by pool-4-thread-1
photo-6 done by pool-4-thread-2
completed tasks = 4
This time photo-3 and photo-4 are missing, because photo-5 pushed out photo-3 and photo-6 pushed out photo-4. The policy fits data where only the latest value matters, for example a job that refreshes the live score on a sports page. The Javadoc calls the policy “rarely acceptable” for other work, because the dropped task disappears without a log entry, and a caller that waits on its Future waits forever.
3. Writing a Custom RejectedExecutionHandler
The interface RejectedExecutionHandler has one method, rejectedExecution(Runnable r, ThreadPoolExecutor executor). The executor calls it in the thread that called execute(), so the handler can throw an exception to that caller or make it wait.
We write a custom handler when none of the four policies fits. The two most common cases are a handler that records the rejection before it throws, and a handler that makes the caller wait for queue space.
3.1. Logging and Counting Rejections
A rejection is a sign that the pool is too small for the load, so we want to see it in the logs and in the metrics. The following handler logs the task together with the state of the pool and counts the rejections. After that, it throws a RejectedExecutionException, so the caller still learns about the failure.
public class LoggingRejectionHandler implements RejectedExecutionHandler {
private static final Logger LOG = Logger.getLogger(LoggingRejectionHandler.class.getName());
private final AtomicLong rejectedCount = new AtomicLong();
@Override
public void rejectedExecution(Runnable task, ThreadPoolExecutor executor) {
long count = rejectedCount.incrementAndGet();
String reason = executor.isShutdown() ? "executor is shut down" : "pool and queue are full";
LOG.warning("Rejected " + task + " (" + reason + "), active = " + executor.getActiveCount()
+ ", queued = " + executor.getQueue().size() + ", rejected so far = " + count);
throw new RejectedExecutionException("Rejected: " + task);
}
}
When the task comes from submit(), the executor wraps it in a FutureTask, so the handler gets the FutureTask, not our ThumbnailJob, and the log line shows the FutureTask. The exception comes out of submit(), so the caller never gets a Future that it could wait on. The AtomicLong counter is safe here, because several threads can call the handler at the same time. In the demo, the code that calls execute() catches the exception and prints the message for the upload page.
WARNING: Rejected ThumbnailJob[photo=photo-5] (pool and queue are full), active = 2, queued = 2, rejected so far = 1
upload page shows: try again later (photo-5)
WARNING: Rejected ThumbnailJob[photo=photo-6] (pool and queue are full), active = 2, queued = 2, rejected so far = 2
upload page shows: try again later (photo-6)
photo-1 done by pool-1-thread-1
photo-2 done by pool-1-thread-2
photo-4 done by pool-1-thread-2
photo-3 done by pool-1-thread-1
rejected = 2
3.2. Waiting for Free Queue Space
Sometimes no task may be lost and the caller may wait a little, for example a nightly import job that reads a file and sends one task per row. A handler can put the rejected task into the queue with offer(task, timeout, unit), which waits until a slot is free or the timeout ends.
public class WaitForSpaceHandler implements RejectedExecutionHandler {
private final long timeoutMillis;
public WaitForSpaceHandler(long timeoutMillis) {
this.timeoutMillis = timeoutMillis;
}
@Override
public void rejectedExecution(Runnable task, ThreadPoolExecutor executor) {
if (executor.isShutdown()) {
throw new RejectedExecutionException("Executor is shut down: " + task);
}
try {
boolean added = executor.getQueue().offer(task, timeoutMillis, TimeUnit.MILLISECONDS);
if (!added) {
throw new RejectedExecutionException("No queue space after " + timeoutMillis + " ms: " + task);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RejectedExecutionException("Interrupted while waiting: " + task, e);
}
}
}
With a 2-second timeout, the same 6 jobs all run. The caller waits about half a second at photo-5, which is the time the first jobs need to free a queue slot.
photo-1 accepted after 16 ms
photo-2 accepted after 68 ms
photo-3 accepted after 69 ms
photo-4 accepted after 69 ms
photo-1 done by pool-1-thread-1
photo-5 accepted after 533 ms
photo-2 done by pool-1-thread-2
photo-6 accepted after 568 ms
...
completed tasks = 6
We always wait with a timeout and check isShutdown() first. A plain put() waits forever when the pool threads hang, and a task that the handler adds to the queue of a shut-down pool may never run. The handler also puts the task into the queue itself, so it skips the step that starts extra threads up to maximumPoolSize. For this reason, the handler fits pools where corePoolSize equals maximumPoolSize. Another way to limit the submission rate is a Semaphore in front of the executor.
4. Tasks Rejected After shutdown()
After we call shutdown(), the executor finishes the running and queued tasks but accepts no new ones. Every later call to execute() goes to the handler, also for the pools from Executors.newFixedThreadPool(), which never reject tasks before the shutdown.
For example, an app stops during a deployment and closes its thumbnail pool, while one last upload request still sends a job. With the default AbortPolicy, the request gets a RejectedExecutionException, and a custom handler gets the task with isShutdown() returning true.
ThreadPoolExecutor pool = (ThreadPoolExecutor) Executors.newFixedThreadPool(2);
pool.setRejectedExecutionHandler((task, executor) ->
System.out.println("Handler got " + task + ", isShutdown = " + executor.isShutdown()));
pool.shutdown();
pool.execute(new ThumbnailJob("photo-3")); // Handler got ThumbnailJob[photo=photo-3], isShutdown = true
Notice the right column of the table in section 2. After a shutdown, CallerRunsPolicy and DiscardOldestPolicy discard the task without any message, so a job that we expect to run in the caller never runs. If tasks sent during a shutdown matter, we use AbortPolicy or a custom handler that logs them.
ThreadPoolExecutor callerRuns = (ThreadPoolExecutor) Executors.newFixedThreadPool(2);
callerRuns.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
callerRuns.shutdown();
callerRuns.execute(new ThumbnailJob("photo-4")); // nothing runs, nothing is printed
long completed = callerRuns.getCompletedTaskCount(); // 0
5. RejectedExecutionHandler FAQs
5.1. Why Does My newFixedThreadPool() Never Call the Handler?
The pool from Executors.newFixedThreadPool() uses a LinkedBlockingQueue without a capacity limit, so the queue accepts every task, and the handler runs only after shutdown(). To reject tasks under load, we create a ThreadPoolExecutor with a bounded queue, as in the intro example.
5.2. Does submit() Use the RejectedExecutionHandler Too?
Yes. The method submit() wraps the task in a FutureTask and calls execute(), so the same handler runs, and with AbortPolicy the exception comes out of submit(). The handler gets the FutureTask instead of our task object, as we saw in section 3.1. The methods invokeAll() and invokeAny() also go through execute().
5.3. What Is the Default Policy of Spring’s ThreadPoolTaskExecutor?
Spring’s ThreadPoolTaskExecutor uses AbortPolicy by default, the same as ThreadPoolExecutor. Its default queue capacity is Integer.MAX_VALUE, so it rejects tasks under load only after we set a smaller queue capacity with setQueueCapacity(). We change the policy with setRejectedExecutionHandler() before we call initialize().
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(2);
executor.setQueueCapacity(2);
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
5.4. Can a Virtual Thread Executor Reject Tasks?
Only after a shutdown. The executor from Executors.newVirtualThreadPerTaskExecutor() starts a new virtual thread for every task and has no queue, so it never runs out of space. It is not a ThreadPoolExecutor and has no RejectedExecutionHandler, so a task sent after shutdown() always throws a RejectedExecutionException. To limit the load on a virtual thread executor, we use a Semaphore.
6. Conclusion
A ThreadPoolExecutor calls its RejectedExecutionHandler when the executor is shut down, or when its threads and its bounded queue are all in use. Pools with an unbounded queue, such as newFixedThreadPool(), reject tasks only after shutdown().
The default AbortPolicy throws a RejectedExecutionException, which we catch where we call execute(). CallerRunsPolicy slows the caller down instead of losing work, whereas DiscardPolicy and DiscardOldestPolicy lose tasks without a trace and leave their Future objects incomplete.
When none of these fits, we write our own handler, for example one that logs and counts the rejections, or one that waits for queue space with a timeout. Keep in mind that CallerRunsPolicy and DiscardOldestPolicy discard tasks without any message after a shutdown.
7. References
- RejectedExecutionHandler Javadoc (Java 25)
- ThreadPoolExecutor Javadoc (Java 25)
- ThreadPoolExecutor.CallerRunsPolicy Javadoc (Java 25)
- RejectedExecutionException Javadoc (Java 25)
- Executors Javadoc (Java 25)
- ThreadPoolTaskExecutor Javadoc (Spring Framework)
Happy Learning !!
Hi I had an experiment on the program it showing ( RejectedTaskHandler: The task [name=Rejected task] has been rejected pool-1-thread-3: Task Task-2: Created on 2020-01-29T12:25:39.400) and the same task also running in console (pool-1-thread-3: Task Task-2: Doing a task during 2 seconds)