Why Does a ThreadPoolExecutor Keep Three Active Threads? The Hidden Logic Explained
This article demystifies Java ThreadPoolExecutor behavior, showing why non‑core threads may stay alive, how tasks are assigned in a round‑robin fashion, and the exact source‑code mechanisms that determine thread recycling and active‑thread counts.
Background
A friend asked why a ThreadPoolExecutor sometimes keeps non‑core threads alive even after the keep‑alive timeout. The article uses a simple scenario to explore this behavior.
ThreadPool Configuration
Original configuration example:
ExecutorService executorService = new ThreadPoolExecutor(40, 80, 1, TimeUnit.MINUTES,
new LinkedBlockingQueue<>(100),
new DefaultThreadFactory("test"),
new ThreadPoolExecutor.DiscardPolicy());For the demonstration a smaller pool is used:
ExecutorService executorService = new ThreadPoolExecutor(2, 3, 30, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(2),
new DefaultThreadFactory("test"),
new ThreadPoolExecutor.DiscardPolicy());Key Questions
Can the pool hold up to 5 tasks?
If each task runs for 1 second and 5 tasks are submitted immediately, will the active thread count be 3?
If no new tasks arrive for 30 seconds, will the active thread count drop to 2?
The answers are all “yes”. The article then asks what happens when a task is submitted every 3 seconds after the initial burst.
Demo Code
public class ThreadTest {
@Test
public void test() throws InterruptedException {
ThreadPoolExecutor executorService = new ThreadPoolExecutor(2, 3, 30, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(2), new DefaultThreadFactory("test"),
new ThreadPoolExecutor.DiscardPolicy());
// Print pool info every 2 seconds
ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1);
scheduledExecutorService.scheduleAtFixedRate(() -> {
System.out.println("=====================================thread-pool-info:" + new Date() + "=====================================");
System.out.println("CorePoolSize:" + executorService.getCorePoolSize());
System.out.println("PoolSize:" + executorService.getPoolSize());
System.out.println("ActiveCount:" + executorService.getActiveCount());
System.out.println("KeepAliveTime:" + executorService.getKeepAliveTime(TimeUnit.SECONDS));
System.out.println("QueueSize:" + executorService.getQueue().size());
}, 0, 2, TimeUnit.SECONDS);
try {
// Submit 5 tasks to reach maximum threads
for (int i = 0; i < 5; i++) {
executorService.execute(new Task());
}
} catch (Exception e) {
e.printStackTrace();
}
// Sleep 10 seconds to observe state
Thread.sleep(10000);
// Submit a task every 3 seconds
while (true) {
Thread.sleep(3000);
executorService.submit(new Task());
}
}
static class Task implements Runnable {
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "-executing task");
}
}
}The program prints that the active thread count stays at 3 even after the 30‑second idle period.
Why Round‑Robin Scheduling?
Log analysis shows that tasks are not assigned randomly; they follow a round‑robin order derived from the waiting queue of the underlying AQS (AbstractQueuedSynchronizer). The waiting queue order is:
Thread[test-1-3,5,main]
Thread[test-1-2,5,main]
Thread[test-1-1,5,main]
When a new task arrives, ThreadPoolExecutor#execute places it into the work queue, and the wake‑up sequence follows the queue order via ConditionObject#awaitNanos, LockSupport.unpark, and transferForSignal. Hence the observed deterministic ordering.
How Non‑Core Threads Are Reclaimed
The reclamation logic resides in ThreadPoolExecutor#getTask. When timed is true (i.e., allowCoreThreadTimeOut || wc > corePoolSize), the worker calls workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS). If the poll times out, the worker exits and processWorkerExit removes it from the pool, effectively recycling the thread.
Because wc (the current worker count) exceeds corePoolSize in the demo (3 > 2), timed becomes true, allowing the non‑core thread to be terminated after the keep‑alive period.
Key Takeaways
Non‑core threads are reclaimed only after they have been idle for keepAliveTime and the total worker count exceeds the core size.
Task assignment follows the order of the AQS waiting queue, resulting in round‑robin execution rather than random selection.
Understanding the source‑code paths ( execute, getTask, processWorkerExit) clarifies why active thread counts may remain unchanged in certain scenarios.
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.
Su San Talks Tech
Su San, former staff at several leading tech companies, is a top creator on Juejin and a premium creator on CSDN, and runs the free coding practice site www.susan.net.cn.
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.
