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
4.5 KiB
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.