找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4306

积分

0

好友

604

主题
发表于 3 天前 | 查看: 16| 回复: 0

昨天下午,运营反馈了一个问题:用户标签更新任务没有执行,导致后台数据全是旧的。这让我有点意外,因为这个任务我前两天刚刚优化过,逻辑很清晰,就是从数据库查询用户数据、计算新标签再回写。为了提高执行速度,我还特意使用了线程池来做并发处理。

我立刻登录服务器,准备查看 error.log 日志文件来定位问题。然而,看到的结果让我心头一紧:日志文件里干干净净,别说 Exception 了,连一个 WARN 级别的记录都没有。

这就很奇怪了。进程在正常运行,线程池监控显示任务也已触发。既然任务跑起来了,如果逻辑出错,肯定会抛出异常;一旦抛出异常,理应会在日志中留下痕迹。

但在线程池的世界里,这个我们习以为常的认知,却是错误的。

案发现场

为了精确复现这个现象,我写了一段最简化的代码。你可以将这段代码直接复制到 IDE 中运行,亲眼看看什么是因沉默而导致的“崩溃”。

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class 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() 提交任务时,线程池并不会直接执行你的 RunnableCallable,而是会将其包装成一个 FutureTask 对象。

关键在于 FutureTaskrun() 方法,我们来看看里面究竟做了什么(基于 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 {
        // ...
    }
}

真相大白了!

  1. 你的业务代码抛出的任何异常(甚至是 Throwable),都在 catch (Throwable ex) 块中被捕获了。
  2. 并没有被打印到标准错误输出(控制台),也没有被传递给线程的 UncaughtExceptionHandler
  3. 它被传递给了 setException(ex) 方法。

这个 setException 方法会将异常对象赋值给 FutureTask 内部的一个名为 outcome 的变量。异常并没有消失,它只是被“封存”在了这个 Future 对象内部。

线程池的设计逻辑是这样的:我帮你把任务执行的结果(无论是正常返回值还是异常)都保存好了。如果你想知道任务到底成功了还是失败了,必须主动调用 future.get() 来获取。你不问,我就不说。

如何让异常显形?

既然知道了问题的根源,解决方案就很明确了。这里推荐三种最常用且有效的方案。

方法一:改用 execute()(简单场景推荐)

如果你提交的任务不需要返回值,比如像许多后台定时任务一样,只是为了执行一段逻辑,那么强烈建议使用 execute() 方法来替代 submit()

// 改用 execute
executor.execute(() -> {
    System.out.println("任务开始执行...");
    int result = 10 / 0;
});

因为 execute() 方法是直接将任务交给工作线程去执行的,没有经过 FutureTask 的包装。一旦任务执行过程中抛出异常,工作线程无法处理,会直接抛给 JVM 默认的 UncaughtExceptionHandler,这时控制台就会立刻打印出红色的异常堆栈信息。

方法二:任务内部自行 Try-Catch(最稳妥)

无论你使用 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<?> future = executor.submit(() -> {
    return 10 / 0;
});

try {
    // 这一步会把“封存”在Future里的异常重新抛出来
    future.get();
} catch (ExecutionException e) {
    // 真正的业务异常包裹在 e.getCause() 里面
    log.error(“任务报错了”, e.getCause());
}

需要注意的是,future.get() 方法是阻塞的。如果你在主线程中立即调用它,就会失去异步并发执行的意义。通常,我们会在所有任务提交后,遍历 Future 列表时再去统一调用 get() 方法。

总结

线程池“吞”异常是一个隐蔽性很高的陷阱,在开发测试阶段不易察觉,往往到了线上环境才会暴露出问题。细节决定成败,理解其原理并选择合适的异常处理方式至关重要。希望本文的剖析能帮助你避免踩坑。如果你在多线程或并发编程中遇到其他有趣的问题,欢迎到云栈社区分享和探讨。




上一篇:Spring事务@Transactional注解的12种常见失效原因全面解析与避坑指南
下一篇:Nacos服务注册的默认设计:为何在云原生时代首选临时实例?
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-3-10 11:06 , Processed in 0.599523 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表