Backend Development 12 min read

Why ThreadPoolExecutor Threads Are Not Reclaimed Without Calling shutdown in Java

The article explains how a Java ThreadPoolExecutor can retain hundreds of waiting threads when shutdown is omitted, analyzes the GC‑root relationship of running threads, and demonstrates through code and JVisualVM screenshots that invoking shutdown or shutdownNow properly interrupts idle workers, removes them from the pool, and allows both the worker threads and the thread‑pool object to be garbage‑collected.

Architecture Digest
Architecture Digest
Architecture Digest
Why ThreadPoolExecutor Threads Are Not Reclaimed Without Calling shutdown in Java

While monitoring a production service the author discovered that a thread pool contained more than 900 threads, most of them in WAITING state, yet CPU and memory usage remained low. This prompted an investigation into why the pool was not releasing threads.

By examining thread‑group names the author identified that the majority of threads belonged to a pool whose name started with pool . A search for new ThreadPoolExecutor() revealed no direct usage, but a screenshot from a colleague showed that the pool was actually created with a custom new FixedTreadPool() , which does not use the standard Executors factory and therefore escaped the initial search.

To reproduce the issue the author wrote a simple demo that repeatedly creates a fixed thread pool without calling shutdown() . Using JVisualVM the thread count kept growing and never decreased, confirming that the pool and its threads were not being reclaimed.

When the same demo was modified to call executorService.shutdown() before the method returned, the JVisualVM view showed that both the worker threads and the pool object disappeared, proving that shutdown triggers proper cleanup.

The underlying reason is that a running thread is a garbage‑collection root. As long as a thread object is reachable (via thread‑>workers‑>threadPool ), the pool cannot be collected. The shutdown() implementation in ThreadPoolExecutor acquires the main lock, advances the run state, and calls interruptIdleWorkers() to interrupt idle threads.

public void shutdown() {
    final ReentrantLock mainLock = this.mainLock;
    mainLock.lock();
    try {
        checkShutdownAccess();
        advanceRunState(SHUTDOWN);
        interruptIdleWorkers();
        onShutdown();
    } finally {
        mainLock.unlock();
    }
    tryTerminate();
}

The interruptIdleWorkers method iterates over all Worker objects and invokes Thread.interrupt() on each, causing waiting threads to throw InterruptedException inside the worker’s run loop.

private void interruptIdleWorkers(boolean onlyOne) {
    final ReentrantLock mainLock = this.mainLock;
    mainLock.lock();
    try {
        for (Worker w : workers) {
            Thread t = w.thread;
            if (!t.isInterrupted() && w.tryLock()) {
                try { t.interrupt(); } catch (SecurityException ignore) {}
                finally { w.unlock(); }
            }
            if (onlyOne) break;
        }
    } finally {
        mainLock.unlock();
    }
}

The core worker loop ( runWorker ) repeatedly obtains tasks via getTask() . When a thread is interrupted while waiting for a task, the interrupt flag causes the waiting call to abort, the exception propagates, and the finally block invokes processWorkerExit .

private void runWorker(Worker w) {
    Thread wt = Thread.currentThread();
    Runnable task = w.firstTask;
    w.firstTask = null;
    w.unlock();
    boolean completedAbruptly = true;
    try {
        while (task != null || (task = getTask()) != null) {
            w.lock();
            if ((runStateAtLeast(ctl.get(), STOP) || (Thread.interrupted() && runStateAtLeast(ctl.get(), STOP))) && !wt.isInterrupted())
                wt.interrupt();
            try {
                beforeExecute(wt, task);
                task.run();
            } finally {
                afterExecute(task, null);
            }
            task = null;
            w.completedTasks++;
            w.unlock();
        }
        completedAbruptly = false;
    } finally {
        processWorkerExit(w, completedAbruptly);
    }
}

The processWorkerExit method removes the finished worker from the workers set, updates task counters, and may start a replacement worker. Once all workers are removed, the pool itself loses all GC‑root references and becomes eligible for garbage collection.

private void processWorkerExit(Worker w, boolean completedAbruptly) {
    if (completedAbruptly) decrementWorkerCount();
    final ReentrantLock mainLock = this.mainLock;
    mainLock.lock();
    try {
        completedTaskCount += w.completedTasks;
        workers.remove(w);
    } finally {
        mainLock.unlock();
    }
    tryTerminate();
    // possibly add a replacement worker
}

In summary, when a thread pool is used locally and not managed as a Spring bean, developers should explicitly invoke shutdown() or shutdownNow() to interrupt idle threads, allow workers to be removed from the pool, and ensure both the threads and the pool object are reclaimed by the garbage collector.

JavaConcurrencyGarbage Collectionthread poolshutdownThreadPoolExecutor
Architecture Digest
Written by

Architecture Digest

Focusing on Java backend development, covering application architecture from top-tier internet companies (high availability, high performance, high stability), big data, machine learning, Java architecture, and other popular fields.

0 followers
Reader feedback

How this landed with the community

login 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.