主题
线程池的优雅关闭与异常处理: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 和命名,是排查问题的最后一层保障,写线程池时就顺手做了。
这三件事做好,线程池就不只是「能跑」,而是「能停得干净、错得清楚」。