Files
asmhatreandClaude Sonnet 5 bc5153d2a5 executors: ExecutorService lifecycle companion code
Adds the executors module: shutdown() vs shutdownNow(), the graceful
two-phase shutdown pattern, AutoCloseable/try-with-resources (JDK 19+),
newVirtualThreadPerTaskExecutor() timing vs a bounded pool, and the
submit()-without-get() lost-exception trap. 5 runnable demos, 1 JUnit
correctness test class (23 tests), 6 captured output transcripts.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01FhzLY5p6okFva3qsnsRyvM
2026-09-30 06:54:17 +00:00
..

executors

Companion code for the ankurm.com post "ExecutorService Done Right: Shutdown, try-with-resources and Virtual Thread Executors." Sixth module in java-core-examples, the Java-core / concurrency series.

Five demos, each isolating one thing people get wrong about ExecutorService lifecycle and task submission: shutdown() vs shutdownNow(), the two-phase graceful-shutdown pattern, the AutoCloseable try-with-resources shortcut (JDK 19+), what newVirtualThreadPerTaskExecutor() actually costs against a bounded pool doing the same blocking work, and the exception that submit() quietly swallows if nothing ever calls Future.get().

Versions this was built and tested against

Component Version Notes
JDK 25.0.4.1+1 (Temurin, LTS) Every demo here runs on one JDK - no cross-version comparison needed.
JUnit Jupiter 5.11.0 Correctness tests, 10 repeats for the two timing-sensitive ones.
Maven 3.9.11
Hardware 2 vCPU x86-64 VM Same sandbox as the rest of this series.

Quickstart

export JAVA_HOME=/path/to/jdk-25
mvn package
java -cp target/classes com.ankurm.executors.ShutdownVsShutdownNowDemo
java -cp target/classes com.ankurm.executors.VirtualThreadPerTaskExecutorDemo

scripts/run-all.sh regenerates every file in output/ (needs JDK25_HOME).

What's in here

File What it shows
.../ShutdownVsShutdownNowDemo.java shutdown() finishes everything queued; shutdownNow() interrupts what's running and abandons what's still waiting.
.../GracefulShutdownPatternDemo.java The two-phase pattern from the ExecutorService javadoc: shutdown(), bounded awaitTermination(), escalate to shutdownNow() only if the grace period runs out.
.../AutoCloseableExecutorDemo.java Since JDK 19, ExecutorService extends AutoCloseable - close() runs that same two-phase pattern for you, so try-with-resources is a correct one-liner, not a shortcut that skips it.
.../VirtualThreadPerTaskExecutorDemo.java 5,000 sleeping tasks against newVirtualThreadPerTaskExecutor() vs a 20-thread fixed pool - real wall-clock numbers, not a claim.
.../LostExceptionTrapDemo.java execute() lets an exception reach the default uncaught-exception handler and print. submit() without a later get() swallows the identical exception completely.
src/test/.../ExecutorShutdownBehaviorTest.java Asserts shutdown() drops nothing, shutdownNow() abandons exactly the still-queued tasks, try-with-resources blocks until every task is done, the virtual-thread executor really runs on virtual threads, and a submit()'d exception only surfaces at get().
output/01-05 Each demo's real run.
output/06 JUnit correctness run.

Reading the results honestly (2-vCPU sandbox)

shutdownNow() doesn't guarantee interruption, it requests it. output/01 shows both running tasks stopping mid-sleep() because Thread.sleep() responds to interrupt() - a task that never checks Thread.interrupted() or calls an interruptible blocking method would keep running to completion regardless of shutdownNow(). The 4 queued-but-not-started tasks are handled differently: shutdownNow() returns them directly as a List<Runnable> and they never run at all.

The virtual-thread number here (output/04) is the whole argument, not a summary of it. 5,000 tasks sleeping 50ms each finished in 121ms on newVirtualThreadPerTaskExecutor() - close to the cost of one task, because nothing waited for a worker to free up. The identical workload on a 20-thread fixed pool took 12,556ms, in line with the ~250 sequential rounds of 20 that a bounded pool forces. This isn't "virtual threads are faster" in general - it's that a large number of blocking tasks stop competing for a small thread count once the thread stops being the scarce resource.

The submit() silence in output/05 is real, not a simplification. The failing task's Future.isDone() reports true immediately, and nothing about the program's output changes whether or not that Future is ever inspected. Compare that block to the execute() block right above it in the same transcript, on the same pool: identical exception, and one prints a full stack trace unprompted while the other prints nothing at all until something explicitly calls get(). That gap is exactly why a task silently "not running" in production logs is worth checking for an un-retrieved Future before anything else.

License

MIT - see the repo-wide LICENSE.