Skip to main content

ExecutorService Done Right: Shutdown, try-with-resources and Virtual Thread Executors

shutdown() vs shutdownNow(), the graceful two-phase pattern, and the JDK 19 AutoCloseable shortcut that runs it for you – plus real numbers for newVirtualThreadPerTaskExecutor() and the exception submit() silently swallows if nothing ever calls Future.get().

A background task threw an exception. Nothing crashed, nothing showed up in the logs, no alert fired – the task just stopped happening, silently, and the only symptom was a downstream job that had been quietly not running for two weeks. The task had been submitted with executor.submit(...), which hands back a Future<?>, and nobody was calling get() on it. The exception was sitting inside that Future the entire time, never thrown, never printed, never seen – because that’s exactly what submit() does by design. Below is that trap, reproduced directly, alongside the rest of what actually determines whether an ExecutorService shuts down cleanly, hangs, or drops work.
Versions. JDK 25.0.4.1+1 (Temurin, LTS) for every demo in this article – no cross-version comparison needed here. JUnit 5.11.0 for the correctness tests. On a 2-vCPU x86-64 virtual machine – the same sandbox as the rest of this series.

shutdown() vs shutdownNow(): finish the queue, or abandon it

shutdown() stops the executor from accepting new tasks but lets everything already submitted – running or still queued – run to completion. shutdownNow() is more aggressive: it interrupts every actively running task and hands back the tasks that were still waiting, un-started, as a List<Runnable>. Interruption is a request, not a guarantee – a task that ignores Thread.interrupted() and never calls an interruptible blocking method keeps running regardless. Both demos below submit 6 tasks to a 2-thread pool, so exactly 2 are running and 4 are still queued at the moment shutdown is called:
ExecutorService pool = Executors.newFixedThreadPool(2);
for (int i = 0; i < 6; i++) {
    int id = i;
    pool.submit(() -> runTask(id)); // each task sleeps 500ms
}

pool.shutdown();       // lets all 6 finish
// vs.
List<Runnable> abandoned = pool.shutdownNow(); // interrupts the 2 running, abandons the 4 queued
$ java ShutdownVsShutdownNowDemo

=== shutdown(): lets running AND already-queued tasks finish ===
task-0: started
task-1: started
main: called shutdown() - isShutdown=true, isTerminated=false
task-0: finished normally
task-2: started
...
main: awaitTermination returned true - all 6 tasks ran to completion, none were skipped

=== shutdownNow(): interrupts running tasks, abandons queued ones ===
task-0: started
task-1: started
main: called shutdownNow() - 4 queued tasks abandoned, never started
task-1: interrupted, exiting early
task-0: interrupted, exiting early
main: awaitTermination returned true - the 2 running tasks were interrupted mid-sleep, not allowed to finish
Output: 01-shutdown-vs-shutdownnow.txt, source: ShutdownVsShutdownNowDemo.java. The 4 abandoned tasks in the second run never print a single line – they’re returned to the caller as data, not executed at all.

Going deeper on this section

The graceful shutdown pattern: bounded patience, then force

The pattern from the ExecutorService javadoc combines both calls: shutdown() first, then a bounded awaitTermination(timeout), and only escalate to shutdownNow() if that grace period actually runs out. Run against two different workloads so both branches of the pattern execute for real, not just the happy path:
pool.shutdown();
if (!pool.awaitTermination(1, TimeUnit.SECONDS)) {
    pool.shutdownNow();                       // grace period expired - force it
    pool.awaitTermination(1, TimeUnit.SECONDS);
}
$ java GracefulShutdownPatternDemo

=== Case 1: tasks finish comfortably inside the grace period ===
quickPool: shutdown() called, waiting up to 1s for a clean finish
quick-task-1: done (200ms)
quick-task-0: done (200ms)
quickPool: terminated cleanly within the grace period

=== Case 2: a task outlives the grace period, forcing shutdownNow() ===
slowPool: shutdown() called, waiting up to 1s for a clean finish
slow-task: started, will try to sleep 3s
slowPool: grace period expired, escalating to shutdownNow()
slow-task: interrupted during shutdownNow(), stopping early
slowPool: terminated after shutdownNow()
Output: 02-graceful-shutdown-pattern.txt, source: GracefulShutdownPatternDemo.java. Case 2’s slow-task never reaches its 3-second sleep’s end – the “this line should never print” line in the source genuinely never prints, because shutdownNow() interrupts it first.

Going deeper on this section

AutoCloseable since JDK 19: try-with-resources, done correctly by default

Since JDK 19, ExecutorService extends AutoCloseable, with a default close() that runs essentially the same two-phase pattern above for you: shutdown(), then block awaiting termination, escalating to shutdownNow() if interrupted while waiting. That makes try-with-resources a correct one-line replacement for the hand-rolled block, not a shortcut that skips the graceful phase:
try (ExecutorService pool = Executors.newFixedThreadPool(3)) {
    for (int i = 0; i < 3; i++) {
        int id = i;
        pool.submit(() -> { Thread.sleep(300); System.out.println("task-" + id + ": finished"); });
    }
    System.out.println("main: leaving the try-with-resources block now");
} // close() runs HERE and blocks until every task above has finished
$ java AutoCloseableExecutorDemo

=== try-with-resources: close() runs the graceful pattern automatically ===
main: leaving the try-with-resources block now
task-2: finished
task-0: finished
task-1: finished
main: back from the try-with-resources block - the pool is terminated, every task already finished, no separate awaitTermination() call was written
Output: 03-autocloseable-executor.txt, source: AutoCloseableExecutorDemo.java. Note the order: “leaving the try-with-resources block now” prints before any task-finished line, and “back from the try-with-resources block” only prints after all three – proving close() genuinely blocked on the closing brace, not just called shutdown() and moved on.

Going deeper on this section

newVirtualThreadPerTaskExecutor(): what it actually costs against a bounded pool

Executors.newVirtualThreadPerTaskExecutor() isn’t a pool with a fixed worker count – there’s nothing to exhaust, so nothing ever queues. Every submitted task gets its own virtual thread immediately. 5,000 tasks that each sleep 50ms, run against this executor and against a 20-thread fixed pool doing identical work on the same box, show what that difference is worth in real wall-clock time rather than asserting it:
try (ExecutorService vtPool = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 5000; i++) {
        vtPool.submit(() -> { Thread.sleep(50); completed.incrementAndGet(); });
    }
} // close() waits for all 5000
$ java VirtualThreadPerTaskExecutorDemo

Running 5000 tasks that each sleep 50ms.

sample task thread: VirtualThread[#22]/runnable@ForkJoinPool-1-worker-1
newVirtualThreadPerTaskExecutor(): 5000/5000 tasks completed in 121ms
(121ms is close to the 50ms a single task takes - every task ran concurrently, nothing waited for a free worker)

sample task thread: Thread[#5027,pool-1-thread-1,5,main]
newFixedThreadPool(20): 5000/5000 tasks completed in 12556ms
(12556ms is roughly 250 rounds of 50ms each - only 20 tasks are ever running at once)
Output: 04-virtual-thread-per-task-executor.txt, source: VirtualThreadPerTaskExecutorDemo.java. 121ms against 12,556ms for identical work – not because virtual threads execute code faster, but because 5,000 blocking tasks stop competing for a scarce thread count once the thread itself stops being scarce. A CPU-bound workload wouldn’t show anything like this gap.

Going deeper on this section

The lost-exception trap: submit() without get() is silence

execute(Runnable) lets an uncaught exception reach the worker thread’s default UncaughtExceptionHandler, which prints a stack trace – same as any other unhandled exception on any other thread. submit(...) instead captures the exception inside the returned Future, and nothing rethrows it unless something calls get(). Skip that call and the exact same exception produces zero output, anywhere:
pool.execute(() -> { throw new RuntimeException("boom from execute()"); });
// prints a full stack trace to stderr, unprompted

Future<?> lostFuture = pool.submit(() -> { throw new RuntimeException("boom, never retrieved"); });
// prints NOTHING - the exception sits inside lostFuture until something calls get()

Future<?> checkedFuture = pool.submit(() -> { throw new RuntimeException("boom, retrieved via get()"); });
try {
    checkedFuture.get();
} catch (ExecutionException e) {
    System.out.println("caught: " + e.getCause()); // the SAME exception, now surfaced
}
$ java LostExceptionTrapDemo

=== execute(): uncaught exception reaches the default handler, gets printed ===
Exception in thread "pool-1-thread-1" java.lang.RuntimeException: boom from execute()
	at com.ankurm.executors.LostExceptionTrapDemo.lambda$main$0(LostExceptionTrapDemo.java:22)
	...
	at java.base/java.lang.Thread.run(Thread.java:1474)

=== submit(): same exception, but nobody calls get() - dead silence ===
main: task's Future exists (isDone=true), but its exception was never printed anywhere - it's just sitting inside the Future

=== submit() + get(): the SAME exception, now surfaced by calling get() ===
main: caught ExecutionException, cause = java.lang.RuntimeException: boom from submit(), retrieved via get()
Output: 05-lost-exception-trap.txt, source: LostExceptionTrapDemo.java. All three blocks run on the same pool in the same program – the only variable is execute() vs submit(), and whether anything ever calls get() on what submit() returns.
The gap that matters in practice. A task submitted with submit() that silently stops “happening” in production isn’t necessarily hanging or deadlocked – it may have thrown on its very first line and nobody was ever going to find out, because nothing was holding onto its Future or calling get() on it. isDone() alone doesn’t help either – output/05 shows it reports true for a task that failed just as readily as one that succeeded. If a scheduled or fire-and-forget task needs its failures to be visible, either call get() somewhere, wrap the task body in its own try/catch that logs, or use execute() and let the default handler do it – but know which one is deliberately chosen.

Going deeper on this section

Decision table

Questionshutdown()shutdownNow()try-with-resources (close())newVirtualThreadPerTaskExecutor()
Accepts new tasks after calling it?NoNoNo, once the block starts exitingN/A – about task placement, not shutdown
Queued-but-not-started tasksStill runAbandoned, returned as a listStill run (calls shutdown(), not shutdownNow())N/A – never queues in the first place
Running tasksAllowed to finishInterrupted (best-effort)Allowed to finishN/A
Blocks the calling thread?No – pair with awaitTermination()No – pair with awaitTermination()Yes, until termination or escalationN/A
Reach for it whenYou want every submitted task to finishYou need tasks stopped now and can tolerate abandoning the queueYou want the graceful pattern without writing it by hand (JDK 19+)Many concurrent blocking tasks, not CPU-bound work

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.