Skip to content

线程池的优雅关闭与异常处理:shutdown、awaitTermination 与被吞掉的异常

线程池的「生老病死」

之前聊线程池,焦点都在参数怎么配、线程数怎么算、监控怎么打。但线程池有自己的生命周期——从创建、运行到关闭,过手多少任务、中间有没有出异常,线上不会因为你配得好就永远不出事。

面试里「线程池怎么优雅关闭」是高频追问。很多人知道 shutdown() 和 shutdownNow(),但真让他写容器关闭时的流程,至少一半会漏掉 awaitTermination。生产环境里,漏掉这个调用意味着任务还没跑完进程就被杀了,数据丢得不明不白。

异常处理更隐蔽。submit() 返回 Future 时,异常被塞进 FutureTask 里存着,你不 get 它永远不抛,线上日志也看不到。等排查时发现数据不对,回溯半天才知道是线程池吞了异常。

这篇把这两个问题捆在一起:池怎么停得干净、任务怎么报得出错。

关闭流程:shutdown、shutdownNow 与 awaitTermination

shutdown vs shutdownNow

ThreadPoolExecutor 提供了两套关闭入口:

java
// 温柔关闭:不再接受新任务,已提交任务继续执行直到完成
executor.shutdown();

// 强制关闭:尝试停止正在执行的任务(发送 Interrupt),队列里排队的任务返回
List<Runnable> unfinished = executor.shutdownNow();

shutdown() 把线程池状态从 RUNNING 切到 SHUTDOWN,然后中断空闲线程。已经在执行的任务不受影响,队列里等着的任务也会继续被取走执行。

shutdownNow() 把状态切到 STOP,中断所有线程(包括正在执行的),然后返回队列里还没被执行的任务列表。调用方拿到这个列表后自己决定怎么处理——比如记录到数据库下次重试,或者落本地文件等补偿。

选哪个看业务容忍度。能接受任务跑完再停的用 shutdown(),不能等的用 shutdownNow() 自己兜底。容器上下线时通常混用:先 shutdown 等一小段,超时了再 shutdownNow 强杀。

最容易被漏掉的那一步

看一段常见的「写了个假的优雅关闭」:

java
@PreDestroy
public void destroy() {
    executor.shutdown();
}

shutdown() 是非阻塞的——它只是发信号,不等任务跑完就返回了。Spring 容器紧接着就继续往下走,说不定下一步就是销毁 Bean 甚至关闭 JVM。正在执行的任务被中断,队列里还没跑的任务也丢了。

正确做法是加 awaitTermination:

java
@PreDestroy
public void destroy() {
    executor.shutdown();
    try {
        if (!executor.awaitTermination(30, TimeUnit.SECONDS)) {
            // 30 秒还没结束,强制关闭
            executor.shutdownNow();
            // 再等 5 秒等任务取消
            if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
                log.error("Thread pool did not terminate");
            }
        }
    } catch (InterruptedException e) {
        // 当前线程被中断,也强制关闭
        executor.shutdownNow();
        Thread.currentThread().interrupt();
    }
}

这段代码是生产环境的标准模板。shutdown() 先发信号,awaitTermination 阻塞等待(30 秒通常够大多数任务收尾),超时还没结束就 shutdownNow 强杀,再等 5 秒等 Interrupt 生效。如果等的时候线程自己被中断了,说明外部的守护逻辑也在催你,那也强杀并恢复中断状态。

Spring 的 @PreDestroy 里用这个模板,基本能覆盖容器上下线的所有场景。如果容器里多处用线程池,建议统一封装成工具类,别每个地方各写一遍。

任务队列里的残留任务

shutdown() 之后队列里排队的任务还会被执行——前提是线程还没被回收。如果核心线程数设得少,队列里堆了几百个任务,shutdown 后能不能在 awaitTermination 的超时时间内跑完,取决于你的超时够不够。如果发现经常超时,要么调大超时时间,要么在上线前确保队列里没有积压任务。

shutdownNow() 返回的 List<Runnable> 里那些没跑的任务,需要你自己决定怎么存。最简单的做法是序列化到数据库,起一个补偿线程轮询重试。如果业务允许丢,那就直接丢了。

异常处理:submit() 为什么吞异常

故障复现

先看一个典型场景:

java
ExecutorService executor = Executors.newFixedThreadPool(4);
executor.submit(() -> {
    throw new RuntimeException("数据库连接失败");
});
// 代码继续执行,日志里什么都没打印

跑一下,控制台没有异常堆栈,一切正常。但业务结果就是不对。排查半天才发现是某个线程抛了异常但没人知道。

原因是 submit() 的入参是 Callable 或 Runnable,内部封成 FutureTask。FutureTask.run() 的代码逻辑是:如果任务抛异常,不往外抛,而是把异常存入 outcome 字段。只有调用 Future.get() 才会重新抛出:

java
// FutureTask.run() 简化版
public void run() {
    try {
        result = callable.call();
    } catch (Throwable ex) {
        outcome = ex;  // 异常被存起来了,不抛
    }
}

execute() 不一样,它直接在当前线程里跑,异常会传播到线程的 UncaughtExceptionHandler,或者被线程池的 afterExecute 钩子捕获。所以用 execute() 提交任务,至少线程池的日志框架能收到异常。

三方处理方案

方案一:用 execute() 代替 submit()

如果不需要返回结果,直接用 execute()。异常会走 UncaughtExceptionHandler 或 afterExecute,在日志里可见。

方案二:包装 Runnable,自己 try-catch

java
executor.submit(() -> {
    try {
        // 业务逻辑
        doSomething();
    } catch (Exception e) {
        log.error("任务执行异常", e);
        // 按需决定是否抛出去
        throw e;
    }
});

简单直接,但每处 submit 都要写 try-catch 很啰嗦。可以做个包装类:

java
public class SafeRunnable implements Runnable {
    private final Runnable delegate;
    
    public SafeRunnable(Runnable delegate) {
        this.delegate = delegate;
    }
    
    @Override
    public void run() {
        try {
            delegate.run();
        } catch (Exception e) {
            log.error("Task failed", e);
        }
    }
}

方案三:重写 afterExecute 钩子

ThreadPoolExecutor 提供了 protected 方法 afterExecute 作为钩子,在任务执行完毕后调用,不管任务是否异常:

java
ThreadPoolExecutor executor = new ThreadPoolExecutor(4, 8, 60, TimeUnit.SECONDS,
        new ArrayBlockingQueue<>(200)) {
    @Override
    protected void afterExecute(Runnable r, Throwable t) {
        super.afterExecute(r, t);
        // submit() 提交的任务,异常被存起来了,需要从 Future 里取
        if (t == null && r instanceof Future<?>) {
            try {
                ((Future<?>) r).get();
            } catch (CancellationException e) {
                // 任务被取消,不算异常
            } catch (ExecutionException e) {
                t = e.getCause();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
        if (t != null) {
            log.error("Task execution failed", t);
        }
    }
};

这段逻辑是阿里巴巴 Java 规范里推荐的。它的思路是:先看 t 有没有值(execute 提交的异常会传到 t),没值但 r 是 Future(说明是 submit 提交的),手动调用 get() 把塞在 outcome 里的异常挖出来。

方案四:用 CompletableFuture 的异常链

java
CompletableFuture.supplyAsync(() -> doSomething(), executor)
    .exceptionally(ex -> {
        log.error("Task failed", ex);
        return fallbackValue;
    });

CompletableFuture 的异常链是显式的,exceptionally 或 handle 里能拿到异常,不会无声丢失。而且它不依赖线程池的异常处理机制,即使你用默认的 ForkJoinPool.commonPool 也吃得住。

线程工厂做最后一层防护

不管上面哪种方案,线程工厂设 UncaughtExceptionHandler 是最后一道防线。execute() 提交的任务如果 afterExecute 也没捕获,异常会传到线程的 UncaughtExceptionHandler,默认的版本只是打印到 System.err(线上往往被重定向到 /dev/null)。改一下:

java
ThreadFactory factory = new ThreadFactory() {
    private final AtomicInteger counter = new AtomicInteger(1);
    
    @Override
    public Thread newThread(Runnable r) {
        Thread t = new Thread(r, "biz-pool-" + counter.getAndIncrement());
        t.setUncaughtExceptionHandler((thread, throwable) -> {
            log.error("Thread [{}] caught unhandled exception", thread.getName(), throwable);
        });
        return t;
    }
};

线程命名也是附带好处——排查问题时 jstack | grep biz-pool 一眼定位到自己的线程池,不用在几百个 pool-1-thread-1 里盲猜。

总结

线程池的生产级用法,参数之外还有三道关:

  • 关闭:shutdown + awaitTermination 双保险,超时兜底 shutdownNow,容器关闭时用 @PreDestroy 模板。不要只调 shutdown 就以为完了。
  • 异常:submit() 会吞异常,三种处理姿势选一种——包装 Runnable、重写 afterExecute 挖 Future、或者用 CompletableFuture 的异常链。
  • 兜底:线程工厂设 UncaughtExceptionHandler 和命名,是排查问题的最后一层保障,写线程池时就顺手做了。

这三件事做好,线程池就不只是「能跑」,而是「能停得干净、错得清楚」。

参考

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1