01
Confirm that task completion and process exit differ
Check whether the process is still alive after its final application output. An external runner timeout can expose this distinction, but first inspect the task result and the threads or resources still owned by the process.
Our leaking fixture waited for a finite task’s result and printed that main had returned. The JVM still required external termination. That isolated the unclosed executor from a task that itself never completed.
02
Reproduce an application-owned pool leak
Save this intentionally incomplete program as LeakingExecutor.java. It creates its own fixed pool and does not shut it down. Run the failing reproduction only under a test harness that can terminate the disposable process.
Do not apply this diagnosis to every hanging JVM without evidence. A shutdown hook, another worker, native operation, or blocked application thread can also delay termination.
import java.util.concurrent.Executors;
public class LeakingExecutor {
public static void main(String[] args) throws Exception {
var pool = Executors.newFixedThreadPool(1);
System.out.println(pool.submit(() -> "task done").get());
System.out.println("main returned; executor still owned by application");
}
}03
Close the pool at its owning scope
On the tested modern JDK, an ExecutorService can be used as an AutoCloseable resource. Save the corrected finite-task example as OwnedExecutor.java. The scope that creates the executor also closes it.
Do not close an executor merely borrowed from a framework or another long-lived component. For older JDKs without this resource contract, implement the documented shutdown and termination handling appropriate to the owner.
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class OwnedExecutor {
public static void main(String[] args) throws Exception {
// ExecutorService is AutoCloseable on Java 19 and later.
try (var pool = Executors.newFixedThreadPool(1)) {
String result = pool.submit(() -> "task done").get(5, TimeUnit.SECONDS);
if (!result.equals("task done")) throw new AssertionError(result);
System.out.println(result);
}
System.out.println("owned executor closed");
}
}04
Verify the process returns without external termination
Compile both examples. Our harness let the leaking program run for three seconds, confirmed its task output, and then terminated only that test JVM. The corrected program printed both lines and returned normally without the harness timeout.
The command below runs the corrected finite-task example. In a service, test its real shutdown entry point as well; wrapping a pool created at application startup in an unrelated request scope changes its lifecycle.
javac -d out LeakingExecutor.java OwnedExecutor.java
java -cp out OwnedExecutor05
Design blocked-task shutdown separately
Closing an ExecutorService waits for its tasks and termination. A timeout on Future.get does not automatically impose a maximum duration on the surrounding close operation. An indefinitely blocked task therefore needs its own cancellation and shutdown design.
Similarly, shutdownNow attempts interruption rather than guaranteeing every task stops. Verify the application’s task behavior and operational deadline deliberately. Changing worker threads to daemon threads can let the JVM exit while abandoning work; it does not establish orderly completion.
Know the limits
Reproduced with Eclipse Temurin OpenJDK 25.0.4.1+1 in a disposable Linux container with no external network. A finite task finished but an unclosed fixed pool kept its JVM alive until an external three-second timeout. A locally owned pool closed through try-with-resources exited normally. ExecutorService is AutoCloseable from Java 19; blocked-task shutdown deadlines were not tested.
Sources & further reading
A technical publication by CoreTrailKit.
Browse all articles