> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# ExecutorService Shutdown: shutdown, shutdownNow and close()
- URL: https://www.ggorantala.dev/java-executor-shutdown/
- Published: 2026-11-13T17:42:23.000Z
- Updated: 2026-11-13T17:42:23.000Z
- Description: ExecutorService shutdown in Java, tested: what shutdown(), shutdownNow() and close() promise, why a JVM stays alive, and the two-phase routine I use.
- Author: Gopi Gorantala
- Tags: java-concurrency, executor-service, java-threads

## 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:

```java
ExecutorService p = Executors.newFixedThreadPool(2);
p.submit(() -> log("webhook sent"));
log("main finished");

```

```log
[   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:

```log
"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()`:

```log
[  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()`:

```log
[  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()`:

```log
[  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](https://ggorantala.dev/java-thread-interrupt-interruptedexception/?ref=ggorantala.dev), 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:

```log
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.

```log
[  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:

```log
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:

```log
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](https://ggorantala.dev/java-thread-interrupt-interruptedexception/?ref=ggorantala.dev). 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`):

```java
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:

```log
[  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](https://ggorantala.dev/java-threadpoolexecutor/?ref=ggorantala.dev)) 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 printed `interrupted=false`).
- `shutdownNow()` returns the tasks that never started. Count them, log them, requeue them.
- `close()` is `shutdown()` then a loop on `awaitTermination(1, DAYS)`. On interrupt it calls `shutdownNow()` and keeps waiting.
- Only a `true` from `awaitTermination` means the pool is finished. A task that swallows `InterruptedException` can 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](https://ggorantala.dev/how-to-configure-thread-pool-executorservice-java/?ref=ggorantala.dev) 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.