When a ThreadPool Queue Is Full: How ThreadPoolExecutor Expands and Handles Rejection
The article explains the exact order in which ThreadPoolExecutor expands—core threads, queueing, non‑core threads, then rejection—illustrates the behavior with a runnable example, compares the four built‑in RejectedExecutionHandler strategies, debunks common misconceptions, and offers production‑grade configuration and monitoring best practices.
What Happens When the Queue Is Full?
Consider a ThreadPoolExecutor with corePoolSize=2, maximumPoolSize=4, a bounded LinkedBlockingQueue of capacity 2, and the default AbortPolicy. Submitting six 10‑second tasks produces the following output:
public class ThreadPoolDemo {
public static void main(String[] args) {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
2, 4, 60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(2),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy());
for (int i = 1; i <= 6; i++) {
final int taskId = i;
try {
executor.execute(() -> {
System.out.println("任务" + taskId + " 开始执行,线程:" + Thread.currentThread().getName());
try { Thread.sleep(10000); } catch (InterruptedException e) {}
});
System.out.println("提交任务" + taskId + "成功");
} catch (RejectedExecutionException e) {
System.out.println("任务" + taskId + "被拒绝了!");
}
}
System.out.println("当前活跃线程数:" + executor.getActiveCount());
System.out.println("队列大小:" + executor.getQueue().size());
}
} 提交任务1成功
任务1 开始执行,线程:pool-1-thread-1
提交任务2成功
任务2 开始执行,线程:pool-1-thread-2
提交任务3成功 ← 入队
提交任务4成功 ← 入队
任务5 开始执行,线程:pool-1-thread-3 ← 队列满,创建非核心线程
提交任务5成功
任务6 开始执行,线程:pool-1-thread-4 ← 队列满,创建非核心线程
提交任务6成功
当前活跃线程数:4
队列大小:2Observations:
Tasks 1‑2 create the two core threads.
Tasks 3‑4 are queued because the core threads are busy.
When the queue becomes full, tasks 5‑6 trigger creation of non‑core threads, expanding the pool from 2 to 4.
A seventh task would cause RejectedExecutionException because the pool has reached maximumPoolSize and the queue is full.
Task Submission Flow and Expansion Order
The core of ThreadPoolExecutor.execute() follows three distinct steps:
public void execute(Runnable command) {
if (command == null) throw new NullPointerException();
int c = ctl.get();
// 1. Core thread creation
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true)) return;
c = ctl.get();
}
// 2. Queueing
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
if (!isRunning(recheck) && remove(command)) reject(command);
else if (workerCountOf(recheck) == 0) addWorker(null, false);
return;
}
// 3. Queue full → try non‑core thread, else reject
else if (!addWorker(command, false))
reject(command);
}The expansion sequence is therefore:
Core thread creation (if workerCount < corePoolSize).
Task enqueue (if the pool is running and the queue accepts the task).
Non‑core thread creation (when the queue is full but workerCount < maximumPoolSize).
Rejection policy execution (when both thread count equals maximumPoolSize and the queue is full).
Why Queue Before Expanding?
Doug Lea designed the pool to use the fewest threads possible. Core threads handle the steady load; the queue buffers bursts. Only when the queue is truly full does the pool allocate temporary non‑core threads, which are later reclaimed after keepAliveTime expires.
addWorker() Details
private boolean addWorker(Runnable firstTask, boolean core) {
retry:
for (int c = ctl.get();;) {
if (runStateAtLeast(c, SHUTDOWN) &&
(runStateAtLeast(c, STOP) || firstTask != null || workQueue.isEmpty()))
return false;
for (;;) {
if (workerCountOf(c) >= (core ? corePoolSize : maximumPoolSize))
return false;
if (compareAndIncrementWorkerCount(c)) break retry;
c = ctl.get();
if (runStateAtLeast(c, SHUTDOWN)) continue retry;
}
}
Worker w = null;
boolean workerStarted = false;
try {
w = new Worker(firstTask);
Thread t = w.thread;
if (t != null) {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try { workers.add(w); } finally { mainLock.unlock(); }
t.start();
workerStarted = true;
}
} finally {
if (!workerStarted) addWorkerFailed(w);
}
return workerStarted;
}Key points: corePoolSize limits core threads; maximumPoolSize limits non‑core threads.
If the limit is exceeded, addWorker returns false and execute falls back to the rejection handler.
Each worker repeatedly obtains tasks via getTask().
Four Built‑In Rejection Policies
AbortPolicy (default)
public static class AbortPolicy implements RejectedExecutionHandler {
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
throw new RejectedExecutionException("Task " + r.toString() + " rejected from " + e.toString());
}
}Behavior: throws RejectedExecutionException.
Typical scenario: core business logic where dropping tasks is unacceptable.
Pros: fast failure, caller can handle the exception.
Cons: uncaught exception can crash the service.
CallerRunsPolicy
public static class CallerRunsPolicy implements RejectedExecutionHandler {
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
if (!e.isShutdown()) {
r.run(); // execute in the submitting thread
}
}
}Behavior: the submitting thread (often a request‑handling thread) runs the task.
Typical scenario: tasks must not be lost and a slowdown is acceptable.
Pros: no task loss.
Cons: the caller thread is blocked, which can reduce throughput and even make a web service unavailable.
DiscardPolicy
public static class DiscardPolicy implements RejectedExecutionHandler {
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
// silently discard
}
}Behavior: silently drops the task.
Typical scenario: non‑critical work such as logging or metrics.
Pros: simplest, no impact on the caller.
Cons: task loss is invisible and may cause data inconsistency.
DiscardOldestPolicy
public static class DiscardOldestPolicy implements RejectedExecutionHandler {
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
if (!e.isShutdown()) {
e.getQueue().poll(); // discard the oldest queued task
e.execute(r); // retry the new task
}
}
}Behavior: removes the oldest waiting task and retries the current one.
Typical scenario: the newest task is more important than older ones (e.g., latest price update).
Pros: guarantees execution of the newest task.
Cons: older task is lost; the retry may trigger another rejection.
Policy Comparison (list form)
AbortPolicy : throws exception; task is not lost; suited for core business; highest recommendation.
CallerRunsPolicy : caller executes task; no loss; suitable when slowdown is tolerable; moderate recommendation.
DiscardPolicy : silent drop; task is lost; suitable for non‑critical work; low recommendation.
DiscardOldestPolicy : drops oldest, runs newest; task loss of oldest; rarely used.
Common Misconceptions
Queue‑full → non‑core thread creation is true, but only after core threads are busy.
Setting maximumPoolSize does not guarantee expansion; an unbounded queue (e.g., new LinkedBlockingQueue<>()) never fills, so step 3 is never reached.
Core threads are lazily created; getActiveCount() may be zero after construction. Use prestartAllCoreThreads() to eagerly start them. shutdown() does not discard queued tasks; it stops new submissions and lets existing and queued tasks finish. shutdownNow() attempts to interrupt running tasks and returns the pending tasks.
Production Best Practices
Thread‑Pool Parameter Tuning
There is no universal formula; tune based on workload type.
CPU‑bound tasks : corePoolSize = CPU cores + 1, maximumPoolSize = CPU cores * 2.
IO‑bound tasks : corePoolSize = CPU cores * 2, maximumPoolSize = CPU cores * 4 (or higher).
Queue size should reflect acceptable waiting time and traffic spikes.
A classic estimate: threads = CPU * (1 + waitTime / computeTime).
Always Use Bounded Queues
Unbounded queues can cause OOM and render maximumPoolSize ineffective. Prefer LinkedBlockingQueue or ArrayBlockingQueue with an explicit capacity.
Custom Rejection Handler with Alerting
public class CustomRejectedPolicy implements RejectedExecutionHandler {
private static final Logger log = LoggerFactory.getLogger(CustomRejectedPolicy.class);
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
log.error("Task rejected! active={}, queue={}, max={}",
executor.getActiveCount(), executor.getQueue().size(), executor.getMaximumPoolSize());
// Example alert (DingTalk, WeChat, etc.)
AlertService.sendAlert("ThreadPool rejection",
String.format("active=%d, queue=%d", executor.getActiveCount(), executor.getQueue().size()));
// Optional downgrade handling
if (r instanceof PersistableTask) {
taskRepository.save((PersistableTask) r);
} else {
throw new RejectedExecutionException("ThreadPool full, task rejected");
}
}
}In production, at minimum log the rejection and send an alert so the issue is visible before users notice.
Name Threads for Easier Debugging
ThreadFactory threadFactory = new ThreadFactoryBuilder()
.setNameFormat("order-pool-%d")
.setDaemon(false)
.setUncaughtExceptionHandler((t, e) -> log.error("Thread {} uncaught", t.getName(), e))
.build();Logs will show order-pool-3 instead of the generic pool-1-thread-3.
Monitor Key Metrics
activeCount– currently running threads. poolSize – total thread count. queue.size() – pending tasks. completedTaskCount – finished tasks. taskCount – total submitted tasks.
Expose them via Micrometer for Prometheus/Grafana:
@Bean
public MeterBinder threadPoolMetrics(ThreadPoolExecutor orderExecutor) {
return registry -> {
registry.gauge("threadpool.active.count", Tags.of("name", "order"), orderExecutor, ThreadPoolExecutor::getActiveCount);
registry.gauge("threadpool.queue.size", Tags.of("name", "order"), orderExecutor, e -> e.getQueue().size());
registry.gauge("threadpool.max.size", Tags.of("name", "order"), orderExecutor, ThreadPoolExecutor::getMaximumPoolSize);
};
}Set alerts for queue usage > 80 %, thread count reaching maximumPoolSize, and any task rejection.
Graceful Shutdown
@PreDestroy
public void shutdown() {
log.info("Shutting down thread pool...");
executor.shutdown();
try {
if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
log.warn("Pool did not shut down in time, forcing...");
executor.shutdownNow();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
executor.shutdownNow();
}
log.info("Thread pool closed");
}This ensures in‑flight and queued tasks complete before the service stops.
Full Summary
When a ThreadPoolExecutor 's queue is full, the pool follows a four‑step expansion order:
Thread count < corePoolSize → create a core thread.
Core threads busy → enqueue the task.
Queue full → create a non‑core thread (up to maximumPoolSize).
Thread count = maximumPoolSize and queue full → invoke the configured RejectedExecutionHandler.
The built‑in rejection policies range from fast failure ( AbortPolicy) to silent drop ( DiscardPolicy). Understanding this flow prevents common pitfalls such as configuring a large maximumPoolSize with an unbounded queue (the pool will never expand) or assuming core threads are eagerly created (they are lazy by default).
Production‑grade usage requires:
Bounded queues to avoid OOM and to make maximumPoolSize meaningful.
Parameter tuning based on CPU‑bound vs. IO‑bound workloads, using the classic threads = CPU * (1 + wait/compute) estimate as a starting point.
Custom rejection handlers that log detailed information and send alerts.
Explicit thread naming for easier troubleshooting.
Metric collection (active count, pool size, queue size, completed tasks, total tasks) and alerting on abnormal values.
Graceful shutdown to ensure no task is lost during service stop.
By following these guidelines, developers move from merely setting pool parameters to confidently tuning, diagnosing, and operating thread pools in real‑world Java services.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Java Tech Workshop
Focused on Java backend technologies, sharing fundamentals, multithreading, JVM, the Spring ecosystem, microservices, distributed systems, high concurrency, source‑code analysis, and practical experience. Continuously delivers high‑quality original content, interview guides, and learning roadmaps to help Java developers progress from beginner to advanced, enhancing technical skills and core competitiveness.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
