Why Your Java ThreadPool Threads Aren’t Releasing and How Shutdown Fixes It
The article investigates a Java application that accumulated nearly a thousand waiting threads without high CPU or memory usage, identifies a custom FixedThreadPool as the cause, explains how thread pools become GC roots, and demonstrates that calling shutdown or shutdownNow properly releases both threads and the pool.
During a routine check the author noticed an application with over 900 threads, most of them in a waiting state, yet CPU and memory usage remained low. The thread dump showed that a large number of threads belonged to a pool whose name started with "pool".
Further investigation revealed that the thread pool was created with a custom new FixedThreadPool() implementation rather than the standard Executors factory, which made it hard to locate via a simple search.
The author reproduced the issue with a minimal demo:
private static void threadDontGcDemo() {
ExecutorService executorService = Executors.newFixedThreadPool(10);
executorService.submit(() -> {
System.out.println("111");
});
}Why the ThreadPool and Its Threads Were Not Reclaimed
Even after the method finished, the executorService variable went out of scope, but the threads remained because the pool was never shut down. The JVM treats a running thread as a garbage‑collection root, so as long as the thread objects are reachable, the pool cannot be collected.
The GC root chain is thread → workers → thread‑pool. Only when the thread objects become unreachable can the pool be reclaimed.
Calling shutdown() or shutdownNow() triggers the pool’s shutdown logic, which ultimately interrupts idle workers. The relevant source code is:
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 shutdown process works by interrupting threads that are in waiting or time_waiting states, causing them to throw an InterruptedException. The worker’s runWorker method catches this interruption and eventually calls processWorkerExit:
private void runWorker(Worker w) {
Thread wt = Thread.currentThread();
Runnable task = w.firstTask;
w.firstTask = null;
w.unlock(); // allow interrupts
boolean completedAbruptly = true;
try {
while (task != null || (task = getTask()) != null) {
w.lock();
// handle interruption and shutdown state
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);
}
}
completedAbruptly = false;
} finally {
processWorkerExit(w, completedAbruptly);
}
}
private Runnable getTask() {
boolean timedOut = false;
for (;;) {
int c = ctl.get();
int rs = runStateOf(c);
if (rs >= SHUTDOWN && (rs >= STOP || workQueue.isEmpty())) {
decrementWorkerCount();
return null;
}
int wc = workerCountOf(c);
boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;
if ((wc > maximumPoolSize || (timed && timedOut)) && (wc > 1 || workQueue.isEmpty())) {
if (compareAndDecrementWorkerCount(c))
return null;
continue;
}
try {
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
workQueue.take();
if (r != null)
return r;
timedOut = true;
} catch (InterruptedException retry) {
timedOut = false;
}
}
}
private void processWorkerExit(Worker w, boolean completedAbruptly) {
if (completedAbruptly) // If abrupt, then workerCount wasn't adjusted
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 processWorkerExit removes the worker from the workers set, the worker object loses its reference from the GC root chain, allowing it to be collected. Once all workers are removed and the request‑handling thread finishes, the thread‑pool object itself becomes unreachable and is also reclaimed.
Key Takeaways
The shutdownNow method interrupts idle workers, causing waiting or time‑waiting threads to throw an exception. processWorkerExit removes the worker from the workers collection, breaking the GC‑root chain and allowing the worker to be collected.
When the workers set becomes empty and the request‑handling thread ends, the thread‑pool object itself becomes eligible for garbage collection.
Conclusion
For locally created thread pools that are not managed as Spring beans, always invoke shutdown() or shutdownNow() after use. Failing to do so leaves both the threads and the pool lingering in memory, potentially leading to resource exhaustion.
A running thread is considered a so‑called garbage collection root and is one of those things keeping stuff from being garbage collected
macrozheng
Dedicated to Java tech sharing and dissecting top open-source projects. Topics include Spring Boot, Spring Cloud, Docker, Kubernetes and more. Author’s GitHub project “mall” has 50K+ stars.
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.
