“What is the difference between shutdown() and shutdownNow()?” sounds like an API memorisation question, but interviewers use it to open a deeper discussion: what happens to tasks already queued, why some JVMs never exit, and how to stop a service cleanly during a deployment without dropping half-processed work.
Thread pools that are never shut down leak threads; pools shut down carelessly lose tasks. A senior candidate should explain the lifecycle states, the standard two-phase shutdown, and how task code must cooperate through interruption.
Before you start
You should know how to create pools with Executors or ThreadPoolExecutor, how to submit Runnable and Callable tasks, and what Future represents. Understand that interruption in Java is cooperative: Thread.interrupt() sets a flag and wakes blocking calls such as sleep or BlockingQueue.take, but it cannot force arbitrary code to stop.
The short answer
shutdown() stops the executor from accepting new tasks but lets running and already queued tasks finish. shutdownNow() also stops accepting tasks, removes queued tasks and returns them as a list, and interrupts running tasks, which stop only if they respond to interruption. Neither method blocks; awaitTermination(timeout) waits for the pool to finish. The standard pattern is shutdown, wait with a deadline, then shutdownNow and wait again, restoring the interrupt flag if the waiting thread is itself interrupted.
How it works
An ExecutorService moves through states: running, shutting down, and terminated. ThreadPoolExecutor names them RUNNING, SHUTDOWN, STOP, TIDYING and TERMINATED.
ExecutorService pool = Executors.newFixedThreadPool(4);
pool.submit(task1);
pool.shutdown(); // RUNNING -> SHUTDOWN
pool.submit(task2); // throws RejectedExecutionException
pool.isShutdown(); // true immediately
pool.isTerminated(); // false until queued and running tasks finishshutdownNow() moves straight to STOP: it drains the queue, interrupts every worker thread, and returns the drained tasks so the caller can persist or resubmit them.
Why do JVMs hang? Worker threads created by Executors.newFixedThreadPool are non-daemon. The JVM exits only when all non-daemon threads finish, and idle pool workers wait forever for new work. A main method that submits tasks and returns without shutting down the pool keeps the process alive.
Since Java 19, ExecutorService implements AutoCloseable. Its close() method calls shutdown() and waits for termination, calling shutdownNow() if the waiting thread is interrupted:
try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
pool.submit(() -> fetch("a"));
pool.submit(() -> fetch("b"));
} // blocks here until both tasks completeStep-by-step walkthrough
Step 1: Stop accepting work
void stop(ExecutorService pool) {
pool.shutdown();Upstream components should stop producing first. Otherwise they hit RejectedExecutionException, which the default AbortPolicy throws for any submission after shutdown.
Step 2: Wait for in-flight work with a deadline
try {
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {The timeout should fit inside your platform’s grace period. Kubernetes sends SIGTERM and, by default, kills the container 30 seconds later, so leave room for the second phase.
Step 3: Force the remainder and record what was dropped
List<Runnable> neverStarted = pool.shutdownNow();
log.warn("Forced shutdown; {} queued tasks dropped", neverStarted.size());
if (!pool.awaitTermination(10, TimeUnit.SECONDS)) {
log.error("Pool did not terminate");
}
}
} catch (InterruptedException e) {
pool.shutdownNow();
Thread.currentThread().interrupt(); // preserve the caller's interrupt status
}
}The returned list holds the raw queued objects. With submit, those are FutureTask wrappers, so persist the underlying work items yourself if you need to resume them.
Step 4: Make tasks responsive to interruption
Runnable consumer = () -> {
while (!Thread.currentThread().isInterrupted()) {
try {
Message m = queue.take(); // throws InterruptedException when interrupted
handle(m);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // restore and exit the loop
}
}
};A task that swallows InterruptedException without restoring the flag, or runs a CPU loop without checking isInterrupted(), survives shutdownNow() and blocks termination.
Worked scenario
A Spring Boot service consumes messages on a fixed pool and acknowledges each one after processing. During rolling deployments, operators notice duplicate processing and occasional half-written records. The pool is created in a bean but never stopped explicitly, and the consumer loop catches InterruptedException and continues:
catch (InterruptedException e) {
log.debug("interrupted, continuing"); // flag cleared, loop keeps running
}On shutdown, the JVM receives SIGTERM, the container is killed after the grace period, and in-flight messages die mid-write. Their acknowledgements never happen, so the broker redelivers them.
The fix gives the pool an explicit lifecycle and makes the loop cooperative:
@Bean(destroyMethod = "") // lifecycle handled below
ExecutorService consumerPool() {
return Executors.newFixedThreadPool(8);
}
@PreDestroy
void stopConsumers() {
running.set(false); // stop pulling new messages
stop(consumerPool); // two-phase shutdown from the walkthrough
}The loop checks running and the interrupt flag, restores interruption, and processing is idempotent so the rare redelivery is harmless. Spring Boot’s server.shutdown=graceful and spring.lifecycle.timeout-per-shutdown-phase cover web requests; custom pools still need their own shutdown.
Common mistake
- “awaitTermination shuts down the pool.” It only waits. Without a prior
shutdown(), it simply times out. - “shutdownNow kills threads.” It interrupts them. Code that ignores interruption keeps running.
- Swallowing InterruptedException. Catching it without restoring the flag hides the shutdown request from every caller up the stack.
- Creating a pool per request. Pools are meant to be long-lived; creating and abandoning them leaks threads until the process runs out of memory or native threads.
- Ignoring rejected tasks. After shutdown,
executethrows; with aDiscardPolicy, tasks vanish silently.
Verify the behavior
import static org.junit.jupiter.api.Assertions.*;
import java.util.List;
import java.util.concurrent.*;
import org.junit.jupiter.api.Test;
class ShutdownTest {
@Test
void shutdownNowReturnsQueuedTasksAndInterruptsRunning() throws Exception {
ExecutorService pool = Executors.newSingleThreadExecutor();
CountDownLatch started = new CountDownLatch(1);
Future<?> running = pool.submit(() -> {
started.countDown();
try { Thread.sleep(60_000); } catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
pool.submit(() -> {}); // queued behind the sleeper
started.await();
List<Runnable> dropped = pool.shutdownNow();
assertEquals(1, dropped.size());
assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS));
assertThrows(RejectedExecutionException.class, () -> pool.submit(() -> {}));
assertTrue(running.isDone());
}
}The sleeping task exits quickly because sleep responds to the interrupt, and the queued task comes back unexecuted.
Follow-up questions
What happens to a Future whose task was dropped by shutdownNow? It is never completed by the pool. Callers blocked on get() wait forever unless they use a timeout or you cancel the returned FutureTask objects.
How do you shut down a ScheduledExecutorService? The same way. By default, delayed tasks still run after shutdown(); configure setExecuteExistingDelayedTasksAfterShutdownPolicy(false) on ScheduledThreadPoolExecutor to drop them.
Why might a pool thread die without anyone noticing? An exception inside a task submitted with submit is captured in the Future. If nobody calls get(), the failure is invisible. Use execute with an uncaught exception handler, or always inspect futures.
What does close() do on Java 21 executors? It calls shutdown() and waits until termination, which makes try-with-resources a clean structured scope for short-lived executors.
Interview exercise
A batch job submits 10,000 tasks to a fixed pool of 10 threads, then calls shutdown() and returns from main. Does the JVM exit immediately? Do all tasks run? What if a task blocks forever on a socket read without a timeout?
Answer and reasoning
main returns, but the JVM does not exit yet, because the ten worker threads are non-daemon and still running. shutdown() lets every queued task run, so all 10,000 complete and the JVM then exits. Returning from main does not cancel anything.
If one task blocks forever on a socket read, its worker never finishes, the pool never terminates, and the JVM never exits. shutdownNow() would not necessarily help, because classic blocking socket reads do not respond to interruption. The fix is a read timeout on the socket (or an interruptible channel), plus an awaitTermination deadline and monitoring for tasks that exceed it. The point the interviewer wants: lifecycle methods only work if tasks have bounded, interruptible blocking.
Continue learning
Keep going with the Java interview questions and the Java MCQs. Related notes: virtual threads and blocking workloads and CompletableFuture error composition. The method-level contract is in the ExecutorService API documentation.