On This Page
You called shutdown(). The JVM is still there.
That's the version of the problem I get asked about most, so start with the one that needs no pool at all:
ExecutorService p = Executors.newFixedThreadPool(2);
p.submit(() -> log("webhook sent"));
log("main finished");
[ 10 ms] main main finished
[ 11 ms] pool-1-thread-1 webhook sent
Both lines print and main returns. The process doesn't exit. Two seconds in, jcmd <pid> Thread.print shows this:
"pool-1-thread-1" #18 prio=5 os_prio=0 cpu=1.64ms elapsed=2.37s nid=351 waiting on condition
java.lang.Thread.State: WAITING (parking)
at jdk.internal.misc.Unsafe.park(Native Method)
- parking to wait for <0x00000000a851e448> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:371)
...
(Trimmed. OpenJDK 21.0.12.1, 2 vCPU x86_64. Only one pool thread exists because I only submitted one task.) A worker is parked, waiting for the next task from the queue, and it's not a daemon thread. The JVM won't exit while any non-daemon thread lives. If you've tried executorservice shutdown fixes before and are reading this because one didn't work, keep going. There's more than one reason a pool refuses to die, and shutdown() only deals with the first.
Same program, newCachedThreadPool() instead, still no shutdown call: the process lived 60.12 seconds and then exited by itself. Nobody sends that one to the on-call. They just notice deploys take a minute longer than they should.
What each call promises
Three calls, three different promises. People mix them up because the names sound like "stop, then stop harder, then stop and wait".
shutdown() says: no new tasks, finish everything already submitted, including what's still in the queue. It doesn't wait for any of it. It returns at once.
shutdownNow() says: no new tasks, throw away the queue and hand it back to me as a List<Runnable>, and try to stop what's running by interrupting it. "Try" is the Javadoc's word too. Anything that doesn't respond to an interrupt keeps going.
close() (Java 19) says: shutdown(), then wait. However long it takes. It's the only one of the three that blocks by default, and I'll come back to that, because it's where I was wrong.
Neither of the first two waits. For that you need awaitTermination, which gives up after the timeout you pass and tells you with a false. So the only way to know the pool is really finished is a true from awaitTermination or isTerminated(). Calling shutdown() and moving on tells you nothing about whether the work happened.
Seven small programs and a stopwatch
One file, Lab.java, with a mode argument for each experiment. Everything below ran on OpenJDK 21.0.12.1 (Ubuntu build), x86_64, 2 cores, no container. I did not run it on JDK 25 or on ARM. The effects here are about thread state and timing, not memory ordering, so I'd expect the same output, but I haven't seen it.
You need java, jcmd and a terminal. No Maven. Compile once with javac Lab.java.
Experiment 1: the hang. java Lab hang in one terminal. In another, jps for the PID, then jcmd <pid> Thread.print | grep -A7 pool-1. Expect the parked worker above. Then plain kill <pid> (SIGTERM). It dies. The JVM isn't stuck, it's just not finished.
I got this one wrong for a while, by the way. My first "does it hang" check was timeout 5 java Lab.java hang | grep -v Picked; echo rc=${PIPESTATUS[0]}, which printed rc=0 and made me think the program exited. The exit status was from the wrong place; I'd read it after the subshell ended. The hang was real the whole time. Test a hang with timeout 5 java Lab hang > out.txt; echo $? and look for 124.
Experiment 2: shutdown vs shutdownNow. java Lab semantics shutdown, then java Lab semantics shutdownNow. Two workers, six tasks of 400 ms each, and I call the shutdown 100 ms in. With shutdown():
[ 146 ms] main after shutdown(): isShutdown=true isTerminated=false queued=4 active=2
[ 147 ms] main late submit -> RejectedExecutionException
[ 457 ms] dispatch-18 task 1 done, interrupted=false
...
[ 1260 ms] dispatch-18 task 5 done, interrupted=false
[ 1262 ms] dispatch-817 task 6 done, interrupted=false
[ 1264 ms] main awaitTermination -> true, poolSize=0
All six ran. Not one task saw an interrupt. The four queued ones started after shutdown() returned. With shutdownNow():
[ 131 ms] dispatch-18 task 1 INTERRUPTED
[ 131 ms] dispatch-817 task 2 INTERRUPTED
[ 136 ms] main shutdownNow() returned 4 never-started tasks; isTerminated=false
[ 137 ms] main awaitTermination -> true, poolSize=0
Two interrupted, four returned, pool finished in a millisecond. Those four are your lost work. If you call shutdownNow() and don't look at what it returned, you've dropped them without a log line.
Experiment 3: shutdown on an idle pool. java Lab afterexec. Two workers, both idle after finishing a task each. 100 ms after shutdown() the live pool threads went from 2 to 0 and isTerminated() was true. So shutdown() doesn't leave idle workers parked: they leave. I assume the pool interrupts workers that are waiting for work, but I didn't read the source for this one, and the Javadoc says nothing about idle workers. What I measured is the effect.
Experiment 4: the task that swallows interrupts. java Lab stuck shutdownNow. One task is a retry loop with catch (InterruptedException e) { /* swallowed */ }, the other sleeps for a minute and exits when interrupted. After shutdownNow():
[ 221 ms] settle-697 polite task stopped on interrupt
[ 2230 ms] main awaitTermination(2s) -> false active=1 poolSize=1
[ 2423 ms] settle-828 stuck task finally exiting
[ 2425 ms] main after quit flag: awaitTermination -> true
One pool thread alive and busy, forever, until I flipped the flag the loop checks. If you've read Java thread interruption and InterruptedException, this is the same swallowed interrupt, only now it holds a whole pool open.
Experiment 5: close(). This is the one worth running twice. java Lab stuck close, then java -DinterruptCloser=true Lab stuck close. A separate thread calls close() on the pool from experiment 4. Stack of that thread after 1.5 seconds, both times:
jdk.internal.misc.Unsafe.park(Native Method)
java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:269)
java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(AbstractQueuedSynchronizer.java:1797)
java.util.concurrent.ThreadPoolExecutor.awaitTermination(ThreadPoolExecutor.java:1475)
java.util.concurrent.ExecutorService.close(ExecutorService.java:417)
In the first run nothing interrupts anything. close() called shutdown(), so the polite task, a 60-second sleep, was never told to stop. The program was still running when my 20-second timeout killed it. In the second run I interrupted the closing thread. That made close() call shutdownNow(): the polite task stopped at once. The closing thread stayed in TIMED_WAITING anyway, with its interrupt flag cleared, because the swallowing task was still running. close() returned within 100 ms of my flipping the quit flag the swallowing task checks.
Experiment 6: SIGTERM and a shutdown hook. java Lab hook, wait for READY, kill -TERM <pid> about 0.7 seconds after launch.
[ 897 ms] my-hook hook: begin, done=4
[ 1231 ms] my-hook hook: awaitTermination(1s) -> true done=8
[ 1232 ms] my-hook hook: shutdownNow dropped 0 queued tasks, done=8
Exit status 143. The hook finished the queue before the process went. Do the same with kill -9 and none of the hook lines print. That's the whole story of hooks: they run for SIGTERM, Ctrl-C and System.exit, and never for SIGKILL, which is what most orchestrators send after the grace period.
Cleanup: jps, then kill any Lab still listed.
How often: the outcomes are deterministic, the millisecond figures aren't. I ran experiments 2, 4, 6 and 7 once or twice each and experiment 5 three times, and the shape never changed. That's a handful of runs on one 2-core machine, not a statistic. Expect different timings and task-start order on yours.
The pool has four states, and each call moves it
A ThreadPoolExecutor keeps its lifecycle in one atomic integer, along with the worker count. The states in order are RUNNING, SHUTDOWN, STOP, TIDYING, TERMINATED, and the Javadoc for isTerminated() and friends maps onto them like this:
RUNNING --shutdown()--> SHUTDOWN (queue still drains, no new tasks)
RUNNING --shutdownNow()-> STOP (queue dropped, workers interrupted)
SHUTDOWN --shutdownNow()-> STOP
SHUTDOWN/STOP --queue empty and 0 workers--> TIDYING --> TERMINATED
The names and the order are from my memory of the source; I'm not quoting a Javadoc table, so check ThreadPoolExecutor.java at your JDK's tag if you rely on the exact constants. What I did verify is each edge in the table, from the outside: isShutdown() true while tasks still run (experiment 2, isTerminated=false right after the call), shutdownNow() returning the queue, and awaitTermination only turning true once the last worker had left.
One consequence people don't expect: isShutdown() and isTerminated() are not "stopping" and "stopped". isShutdown() is true the instant you ask, with work still queued. If you gate "are we done?" on it, you'll be wrong by however long the queue takes.
A late execute() or submit() after shutdown() throws RejectedExecutionException under the default handler. That's your signal that intake is still open somewhere after you started closing. Stop the intake first, then close the pool.
Reading ExecutorService.close() in the bytecode
I wanted to see what close() really does, because my mental model, "shutdown plus a timeout", was wrong. The source file wasn't on this machine (src.zip missing), so I disassembled the interface method with javap -c -p java.util.concurrent.ExecutorService on 21.0.12.1.
It reads: if already terminated, return. Otherwise call shutdown(), then loop on awaitTermination(1, TimeUnit.DAYS). If that throws InterruptedException, call shutdownNow() once, remember that it happened, keep looping. When it finally exits the loop, if it was interrupted, re-assert the interrupt flag on the way out. The Javadoc for close() (@since 19) says the same in prose, including that waiting continues after shutdownNow(). It has no timeout parameter. That one-day constant is the only upper bound there is.
That matches experiment 5 line for line, and it's why I'd say close() is the polite path with no escape hatch. Try-with-resources on an executor is lovely for a batch job where you control every task. Put it around a pool that runs somebody else's code, and one task that ignores interruption holds your thread, and your deploy, for up to a day. I don't mean that as a criticism of close(). It's a sensible default for scoped use. I mean it's not a graceful-shutdown policy.
It took me most of two days, on and off, to get this straight, and a lot of it was being sure that the interrupt I sent to the closing thread was what stopped the polite task. It wasn't the thread I interrupted that mattered. It was that close() reacted to the interrupt by calling shutdownNow(), which then interrupted the workers. Two hops. That's article material by itself, which is why it's here.
Version notes, so nobody gets surprised after an upgrade:
Java 5 ExecutorService, shutdown / shutdownNow / awaitTermination
Java 19 ExecutorService extends AutoCloseable; default close()
JDK 25 latest GA on openjdk.org/projects/jdk when I checked; the page itself was last updated 2025-12-04, so treat that as stale
I did not run any of this on 22 or later. ThreadPoolExecutor.finalize() is deprecated for removal in the 21 Javadoc, and the class's "Reclamation" section says a pool with no references and no threads may be collected without an explicit shutdown. That's the exception that proves the rule: a fixed pool that has started a core thread has a live thread, so it can't be collected, and you've leaked it.
Where this hurts in real deployments
The never-shut-down pool. A new something in a request path, a pool per call, used once. Each leaves a few parked threads behind until the JVM dies. It doesn't show in any test, because tests exit through System.exit or a runner that kills threads. It shows as a rising Thread.activeCount(). I'd treat any Executors.new... outside a long-lived singleton or a try-with-resources as a review comment.
Calling shutdownNow() and ignoring the result. The list it returns is every task that never started. Log its size. If those are payments or webhooks, requeue them. Dropping them silently is the most common form of data loss I see around executors, and it looks like "some requests vanished during the deploy".
Swallowed interrupts, again. Experiment 4. If a task loops on catch (InterruptedException e) {} or on a blocking call that doesn't respond to interrupts, neither shutdownNow() nor the interrupt from close() will stop it. Blocking socket reads and synchronized waits are the usual ones; the full matrix is in the interrupt article. The fix is in the task, not in the shutdown code.
Hooks that wait on a pool that waits on the hook. A shutdown hook runs on its own thread while the JVM is already shutting down. If it calls awaitTermination on a pool whose tasks need a resource that another hook already closed (a connection pool, a logger), you get a hang or a pile of exceptions at the worst moment. Order matters, and hooks run concurrently with no ordering guarantee.
"Graceful" with a 30-second grace and a 60-second wait. Kubernetes sends SIGTERM, waits terminationGracePeriodSeconds (30 by default; verify for your cluster), then sends SIGKILL. If your hook waits 60 seconds for the queue, it's killed halfway. Budget the wait to fit inside the grace period. That's Kubernetes, not Java, so I haven't tested it here.
The shutdown routine I'd write now, and what each step costs
The Javadoc for ExecutorService has a shutdownAndAwaitTermination sample: shutdown(), wait 60 seconds, shutdownNow(), wait 60 seconds, complain on stderr if it still hasn't stopped. It handles InterruptedException by calling shutdownNow() again and restoring the flag. It's close to what I use. I change two things: I keep the leftover tasks, and I make the waits parameters. A small version (experiment 7, java Lab helper):
static List<Runnable> shutdownAndAwait(ExecutorService p, long graceMs, long forceMs) {
p.shutdown(); // stop intake, let queued work drain
try {
if (p.awaitTermination(graceMs, TimeUnit.MILLISECONDS)) return List.of();
List<Runnable> leftovers = p.shutdownNow(); // give up on the queue, interrupt workers
if (!p.awaitTermination(forceMs, TimeUnit.MILLISECONDS))
log("pool still has running tasks after shutdownNow");
return leftovers;
} catch (InterruptedException e) {
List<Runnable> leftovers = p.shutdownNow();
Thread.currentThread().interrupt(); // restore the flag; don't swallow it
return leftovers;
}
}
Run with six 500 ms tasks on two workers, a 700 ms grace period and a 300 ms force period:
[ 710 ms] pool-1-thread-1 cut short
[ 710 ms] pool-1-thread-2 cut short
[ 754 ms] main helper took 726 ms, leftovers=2, terminated=true
Tasks 1 and 2 finished, 3 and 4 were interrupted mid-run, 5 and 6 came back as leftovers. 726 ms against a budget of 1,000. Why it works: shutdown() first gives the in-flight and queued work its chance, and the second phase only fires when the first one fails, so a healthy pool never gets interrupted.
The choices:
| Choice | What you get | What it costs |
|---|---|---|
shutdown() alone |
Queue drains, no interruption | No deadline; you don't know when it's done |
shutdown() + awaitTermination |
Known deadline | Must decide what a false means |
| two-phase helper | Deadline, then interrupt, then leftovers | Tasks must honour interrupts, or phase two does nothing |
shutdownNow() straight away |
Fast | Lost queue, interrupted work |
try-with-resources (close()) |
Scope-bound cleanup | Unbounded wait; interrupt path still needs cooperating tasks |
| shutdown hook | Runs on SIGTERM and System.exit |
Not on SIGKILL; races other hooks |
What isn't a fix: setDaemon(true) on the pool's threads. It makes the JVM exit regardless, which is exactly how you lose queued work with no log line. The hang goes away because the thing you wanted to finish gets killed. Making pool threads daemon is only right when nothing they do matters if it's cut off.
Services: stop accepting work (close the listener or flip a readiness flag), then run the two-phase routine with a budget smaller than your grace period, then log leftovers.size(). Spring's graceful-shutdown settings belong to the Spring Boot series; underneath, they come down to the same calls you've just seen.
Checking it before a deploy does
In a thread dump, an executor that didn't shut down looks like what's at the top of this article: workers named pool-N-thread-M (or whatever your factory called them; see how ThreadPoolExecutor names and admits tasks) in WAITING (parking), for a pool no one is using. Take two dumps a few seconds apart. If the same workers sit parked and nothing is queued, the pool is idle and un-closed. Name your threads, or you'll count pool-37-thread-1 and have no idea who owns it.
Signals worth alerting on, with the caveat that I haven't verified the Micrometer names against current docs: live thread count that rises with request count; a pool whose getActiveCount() is still above zero after your shutdown routine logged "terminated"; shutdownNow() leftovers above zero; process exit time longer than your expected drain.
The test that would have caught each of these: a unit test that builds your component, calls its stop(), and asserts awaitTermination(2, SECONDS) returns true, with a task in flight that blocks on a latch. And a second test with a task that swallows interrupts, to prove your timeout, not your luck, ends the pool. For hangs, run jcmd <pid> Thread.print in CI after stop(), and fail on any non-daemon thread that isn't yours or the JVM's.
Upgrading: moving from 17 to 21 adds close() (Java 19) and the virtual-thread executor. Retest anything where someone converted a manual shutdown() into try-with-resources, because the wait is now unbounded. JDK 22+ I haven't run.
Keep these
shutdown()stops intake and drains the queue. It doesn't wait, and it doesn't interrupt running tasks (they all printedinterrupted=false).shutdownNow()returns the tasks that never started. Count them, log them, requeue them.close()isshutdown()then a loop onawaitTermination(1, DAYS). On interrupt it callsshutdownNow()and keeps waiting.- Only a
truefromawaitTerminationmeans the pool is finished. A task that swallowsInterruptedExceptioncan hold it open indefinitely, and that bug is in the task. - A hook runs on SIGTERM and never on SIGKILL. Fit the wait inside the grace period.
Short answers to the usual questions
Does shutdown() interrupt running tasks? No. In my run all six tasks finished and none saw an interrupt flag. It stops new submissions and lets the queue drain. Use shutdownNow() to interrupt (Java 5 and later; same on 21).
What's the difference between shutdown() and close()? close() (Java 19) calls shutdown() and then waits for termination, with no timeout. On interrupt it switches to shutdownNow() and carries on waiting. shutdown() returns immediately.
Why doesn't my Java program exit after the work is done? Probably an ExecutorService that was never shut down. Its worker threads are non-daemon. A cached pool exits by itself after 60 seconds of idleness (60.12 s measured); a fixed pool never does. See how to configure a thread pool for sizing and shutdown together.
Do I need to shut down a pool on JVM exit? If the process is being terminated anyway, the OS reclaims the threads. You shut down for the work, not the memory: queued tasks and in-flight tasks that would otherwise be cut off. Add a hook for SIGTERM, and expect nothing from SIGKILL.