使用自定义ExecutorService创建CompletableFuture后服务无法终止的原因及解决方案
Let's break down what's happening here and how to fix it.
The Root Cause
Your issue has nothing to do with exceptions in CompletableFuture—it's all about how FixedThreadPool works by default:
When you create a thread pool with
Executors.newFixedThreadPool(10), you're creating a pool where:- The number of core threads equals the maximum number of threads (10)
- Core threads never time out by default (the
allowCoreThreadTimeOutflag is set tofalse) - These threads are non-daemon threads, meaning they'll keep the JVM running even if the main thread exits.
CompletableFuturedoesn't manage the lifecycle of your custom executor—it just submits tasks to it. Once all yourCompletableFuturetasks complete (even with exceptions handled), the executor's core threads are still alive, waiting for new tasks to process. That's why:executor.awaitTermination(5, TimeUnit.SECONDS)returnsfalse—threads are still active- You can still submit a new task and have it execute—the pool is still operational
- The JVM doesn't exit because those non-daemon threads are running.
Compare this to the default CompletableFuture executor (ForkJoinPool.commonPool()): its threads are daemon threads, so when the main thread finishes, those daemon threads are automatically terminated, letting the JVM exit.
How to Fix It
You have two reliable ways to ensure your executor terminates correctly:
1. Explicitly Shut Down the Executor After Tasks Complete
The simplest and most controllable approach is to call executor.shutdown() once all your CompletableFuture tasks are done. This tells the executor to stop accepting new tasks and terminate all threads once existing tasks are finished.
Modify your code like this:
class Scratch { public static void main(String[] args) throws InterruptedException { ExecutorService executor = Executors.newFixedThreadPool(10); AtomicInteger counter = new AtomicInteger(); Supplier<?> throwingException = () -> { throw new RuntimeException("throw " + counter.incrementAndGet()); }; Function<String, CompletableFuture<?>> process = url -> CompletableFuture.supplyAsync(throwingException, executor) .exceptionally(Scratch::log); var collect = IntStream.range(1, 10).mapToObj(i -> "url" + i) .map(process) .toArray(CompletableFuture[]::new); final CompletableFuture<Void> together = CompletableFuture.allOf(collect); System.out.println("joining"); together.exceptionally(Scratch::log).join(); System.out.println("finished"); // Explicitly shut down the executor executor.shutdown(); if (executor.awaitTermination(5, TimeUnit.SECONDS)) { System.out.println("exiting cleanly"); } else { // Force shutdown if threads are still stuck executor.shutdownNow(); System.out.println("forced shutdown"); } // This will throw RejectedExecutionException now, since the pool is shut down // executor.submit(() -> System.out.println("still executing")); } static <T> T log(Throwable t) { System.out.println(t.getMessage()); return null; } }
2. Allow Core Threads to Time Out
If you want the executor to automatically clean up idle core threads without explicitly shutting it down, you can enable core thread timeouts. Note this is less predictable than explicit shutdown, but useful in some scenarios:
// Cast to ThreadPoolExecutor to access configuration ThreadPoolExecutor executor = (ThreadPoolExecutor) Executors.newFixedThreadPool(10); executor.allowCoreThreadTimeOut(true); // Core threads will terminate after idle timeout
With this setting, after your tasks complete, the core threads will wait for new tasks until the default idle timeout (60 seconds), then terminate. Once all threads are gone, the executor will be considered terminated.
Key Takeaway
Custom executors don't terminate on their own—you have to manage their lifecycle explicitly. Always remember to shut down your executor when you're done using it, especially if it uses non-daemon threads (like FixedThreadPool does by default).
内容的提问来源于stack exchange,提问作者daniu

