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.

Su San Talks Tech
Su San Talks Tech
Su San Talks Tech
Why Does a ThreadPoolExecutor Keep Three Active Threads? The Hidden Logic Explained

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.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

AQSround robinThreadPoolExecutorThread RecyclingCore ThreadsNon-Core Threads
Su San Talks Tech
Written by

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.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.