大家好,我是苏三~
昨天下午,运营说有个用户标签更新任务没跑,后台数据全是旧的!
这个任务我前两天才优化过,逻辑很简单,就是从数据库查一批人,算一下标签,再写回去。为了快点,我还特意用了线程池做并发。
我第一时间去翻服务器的日志 error.log。结果让我头皮发麻:日志里干干净净,没有一行 Exception,甚至连个 WARN 都没有。
这就见鬼了。进程活着,线程池没满,监控显示任务也正常触发了。既然任务跑了,如果逻辑出错,肯定会抛异常;如果抛异常,日志肯定会打出来。
但在线程池的世界里,这个常识是错的。
为了复现这个问题,我写了一段最简化的代码。大家可以把这段代码复制到 IDEA 里跑一下,亲眼看看什么叫由于沉默而导致的崩溃。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
publicclass ThreadPoolSwallowException {
public static void main(String[] args) {
// 1. 创建一个单线程的线程池
ExecutorService executor = Executors.newSingleThreadExecutor();
// 2. 提交一个必然报错的任务
executor.submit(() -> {
System.out.println("任务开始执行...");
// 这里有个大坑:除以 0,必然抛出 ArithmeticException
int result = 10 / 0;
System.out.println("计算结果:" + result);
});
// 3. 关闭线程池
executor.shutdown();
System.out.println("主线程结束,准备看戏...");
}
}
运行结果:
主线程结束,准备看戏...
任务开始执行...
看到问题了吗?
控制台打印了任务开始执行...,然后就没了。
那个致命的 / by zero 异常,没有堆栈,没有报错,程序就这样优雅地结束了。
如果这是在生产环境,你的业务逻辑就像这段代码一样,断在了中间,而你却一无所知,只能等着用户投诉或者数据错乱。
为了搞清楚异常到底被谁吞了,我们需要扒开 JDK 的源码看一看。
当你调用 executor.submit() 时,线程池并不是直接跑你的任务,而是把它包装成了一个 FutureTask 对象。
我们来看看 FutureTask 的 run() 方法里到底干了什么(JDK 8 源码片段):
public void run() {
// ... 省略状态检查 ...
try {
Callable<V> c = callable;
if (c != null && state == NEW) {
V result;
boolean ran;
try {
// 1. 这里真正执行你的业务逻辑
result = c.call();
ran = true;
} catch (Throwable ex) {
// 2. 【重点!】异常被捕获了!
result = null;
ran = false;
// 3. 异常被塞到了这里
setException(ex);
}
if (ran)
set(result);
}
} finally {
// ...
}
}
真相大白!
catch (Throwable ex) 里被捕获了。Thread 的 UncaughtExceptionHandler。setException(ex) 方法。这个 setException 它把异常对象赋值给了 FutureTask 内部的一个变量 outcome。异常并没有消失,它只是被封存在了这个 Future 对象里。
线程池的逻辑是:我帮你把任务存起来了。想知道结果,想知道有没有报错,那得调用 future.get()来问,你不问我就不说。
既然知道了原因,解决办法就有很多种。这里推荐 3 种最实用的方案。
execute()(最推荐)如果你提交的任务不需要返回结果,比如定时任务一样,请直接用 execute() 替代 submit()。
// 改用 execute
executor.execute(() -> {
System.out.println("任务开始执行...");
int result = 10 / 0;
});
因为 execute() 方法是直接把任务扔给线程跑的,没有 FutureTask 的包装。
一旦抛出异常,线程会直接把异常抛给 JVM 的 UncaughtExceptionHandler,控制台立马就会打印出红色的堆栈信息。
不管你用 submit 还是 execute,在最外层包一个 try-catch 永远是保命的最佳实践。
executor.submit(() -> {
try {
System.out.println("任务开始执行...");
int result = 10 / 0;
} catch (Exception e) {
// 自己记录日志,想怎么打就怎么打
log.error("任务执行发生异常", e);
}
});
这虽然写起来麻烦点,但能保证异常一定会被你捕获并记录到日志文件中,而不是依赖控制台的标准输出。
Future.get()如果你非要用 submit 且需要返回值,那你必须在主线程里去获取结果。
Future<?> future = executor.submit(() -> {
return 10 / 0;
});
try {
// 这一步会把“封存”的异常重新抛出来
future.get();
} catch (ExecutionException e) {
// 真正的异常在 e.getCause() 里
log.error("任务报错了", e.getCause());
}
但要注意,future.get() 是阻塞的。如果你在主线程直接调用,就失去了异步并发的意义。通常我们会在遍历 Future 列表时才去调用。
线程池吞异常是一个非常不易发现的坑,一般都只有在线上才容易看到,细节决定绩效,记住就行了!
看完等于学会,点个赞吧!!!