When a ThreadPoolExecutor cannot accept a task — both the queue is full and the pool is at maximumPoolSize (or the pool has been shut down) — it invokes a RejectedExecutionHandler. The JDK ships four, and you can write your own.
The four built-in policies
- AbortPolicy (the default) — throws
RejectedExecutionException. Loud and fail-fast; the caller must catch it. Good when dropping work silently would be a bug. - CallerRunsPolicy — runs the task on the calling thread instead of a pool thread. This is the elegant one: the caller is busy running the rejected task, so it can't submit more, which throttles the producer and creates natural back-pressure. The submission rate self-limits to what the pool can drain.
- DiscardPolicy — silently drops the task. No exception, no log. Only acceptable when losing tasks is genuinely fine (e.g. best-effort metrics).
- DiscardOldestPolicy — drops the oldest queued task and retries the new one. Useful for "latest value wins" workloads; dangerous if older tasks matter.
ThreadPoolExecutor pool = new ThreadPoolExecutor(
4, 8, 60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
new ThreadPoolExecutor.CallerRunsPolicy()); // back-pressure on overload
// Custom: bounded blocking instead of dropping
RejectedExecutionHandler blockingHandler = (task, executor) -> {
try {
executor.getQueue().put(task); // blocks the submitter until space frees
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RejectedExecutionException(e);
}
};
Choosing one
Rejection only happens with a bounded queue (an unbounded queue never fills, so the handler is never called). For request-serving systems, CallerRunsPolicy is the usual choice because it degrades latency gracefully instead of either dropping requests or exploding memory. The custom blocking handler above gives true producer back-pressure for ingestion pipelines.