Why Your Java ThreadPool Won’t Release Threads – The Hidden Power of shutdown()

This article explores why a Java thread pool can retain hundreds of waiting threads, explains how the shutdown and shutdownNow methods trigger thread interruption, details the internal worker lifecycle that leads to garbage collection, and provides practical code demos to ensure proper resource cleanup.

MaGe Linux Operations
MaGe Linux Operations
MaGe Linux Operations
Why Your Java ThreadPool Won’t Release Threads – The Hidden Power of shutdown()

Preface

Today I share a long but interesting thought process triggered by an online issue.

While finishing a task at work, I checked SkyWalking out of curiosity and discovered one of our applications had over 900 threads, close to 1000, yet CPU and memory usage were not high.

I immediately sensed something was wrong because the thread count was far too high for a healthy application, so I dumped the CPU and examined the thread group names.

From the thread groups, threads whose names start with pool accounted for 616 entries, all in the waiting state, which looked suspicious. I concluded that this pool was the root cause and began investigating why more than 600 threads remained waiting and could not be released.

The stack trace showed that threads continuously looped to fetch tasks; when no task was available they entered the waiting state, awaiting a wake‑up.

It turned out there were multiple thread pools with identical names, suggesting that the same pool was being created repeatedly without ever being reclaimed.

I searched the code for new ThreadPoolExecutor() and got no results, which was puzzling.

A colleague then sent a screenshot showing the pool was actually created with a custom new FixedTreadPool(), which explains why a search for the standard constructors failed.

Opening the code revealed that I had written the interface two years ago to batch‑process monthly wallet transactions. I used a thread pool to speed up the statistics, but unintentionally created a large number of threads that were never shut down.

Here is a minimal reproduction:

private static void threadDontGcDemo() {
    ExecutorService executorService = Executors.newFixedThreadPool(10);
    executorService.submit(() -> {
        System.out.println("111");
    });
}

Why the threads and the pool are not released

I wondered whether the missing shutdown() call was the culprit. I assumed that once the method returned, the local executorService variable would be eligible for GC, but that proved false.

I wrote a demo that repeatedly creates new thread pools without calling shutdown() and observed the thread count continuously growing in JVisualVM.

When I added a shutdown() call before the method finished, the threads and the pool were reclaimed.

The result: invoking shutdown() causes the thread pool and its threads to be reclaimed.

In Java, an object can be collected only if there is no reachable path from a GC root. A running thread is a GC root, so a thread pool remains reachable as long as its worker threads are alive.

The GC root chain is thread → workers → thread‑pool. When a thread becomes non‑running, it can be collected, and consequently the pool can be collected as well.

When does a thread become eligible for GC?

A running thread (including the WAITING state) is considered a GC root. Only after the thread exits can it be reclaimed.

A running thread is considered a so‑called garbage collection root and is one of those things keeping stuff from being garbage collected.

Thus, calling shutdown() or shutdownNow() interrupts idle workers, forcing waiting threads to throw an exception and exit.

Looking at the source of ThreadPoolExecutor.shutdown() we see it acquires the main lock, advances the run state, and calls interruptIdleWorkers(), which iterates over all workers and invokes Thread.interrupt() on each.

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

private void interruptIdleWorkers() {
    interruptIdleWorkers(false);
}

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 worker’s runWorker method catches the interruption, exits the loop, and finally calls processWorkerExit, which removes the worker from the workers set:

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
}

When all workers are removed and the request‑handling thread finishes, the thread‑pool object itself becomes unreachable and is garbage‑collected.

The shutdownNow method interrupts idle workers, causing waiting or timed‑waiting threads to throw an exception.

The worker that throws the exception is removed from the workers collection, breaking the GC‑root chain and allowing the worker to be reclaimed.

When the workers set becomes empty and the servlet thread ends, the thread‑pool object is also reclaimed.

Final Summary

If you create a thread pool locally (not as a Spring bean), remember to call shutdown() or shutdownNow() to release both the threads and the pool; otherwise they will accumulate and waste resources.

Link: https://juejin.cn/post/7197424371991855159

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.

JavaconcurrencyThreadPoolGarbageCollectionExecutorServiceShutdown
MaGe Linux Operations
Written by

MaGe Linux Operations

Founded in 2009, MaGe Education is a top Chinese high‑end IT training brand. Its graduates earn 12K+ RMB salaries, and the school has trained tens of thousands of students. It offers high‑pay courses in Linux cloud operations, Python full‑stack, automation, data analysis, AI, and Go high‑concurrency architecture. Thanks to quality courses and a solid reputation, it has talent partnerships with numerous internet firms.

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.