线程池抛了异常怎么处理?

作者:old wang 发布时间: 2025-05-24 阅读量:2 评论数:0

线程池抛了异常怎么处理?只写 try-catch 还不够

在 Java 项目中,线程池是很常见的异步执行工具。

但线程池里的任务一旦抛出异常,处理方式并没有很多人想得那么简单。

尤其是 execute()submit() 两种提交方式,对异常的处理行为完全不同:

  • execute() 提交的任务,异常会直接抛出;

  • submit() 提交的任务,异常会被封装进 Future,如果不调用 get(),异常可能不会暴露出来。

所以,“线程池抛异常怎么处理”这个问题,不能只简单的try-catch 包一下。

这只是最基础的处理方式。

应该包括:

  • execute()submit() 的异常行为差异;

  • FutureTask 为什么会吞掉异常;

  • afterExecute() 如何统一兜底;

  • UncaughtExceptionHandler 的适用边界;

  • CompletableFuture 中如何处理异步异常。

一、先看 execute 和 submit 的区别

线程池提交任务常见有两种方式:

executor.execute(runnable);

以及:

Future<?> future = executor.submit(runnable);

这两种方式看起来都能提交任务,但异常处理行为并不一样。

提交方式

默认异常行为

execute(Runnable)

异常会抛出,并打印到标准错误输出

submit(Runnable) / submit(Callable)

异常会被封装进 Future,必须调用 future.get() 才能拿到

看一个最小示例:

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

public class Demo {

    public static void main(String[] args) {
        ExecutorService pool = Executors.newFixedThreadPool(1);

        pool.submit(new Task());   // 异常不会直接打印
        pool.execute(new Task());  // 异常会打印到 stderr
    }

    static class Task implements Runnable {
        @Override
        public void run() {
            System.out.println("进入 task");
            int i = 1 / 0;
        }
    }
}

如果使用 submit(),异常并不是没有发生,而是被保存到了返回的 Future 里。

要拿到异常,需要显式调用:

Future<?> future = pool.submit(new Task());

try {
    future.get();
} catch (Exception e) {
    e.printStackTrace();
}

此时异常会以 ExecutionException 的形式抛出,原始异常可以通过:

e.getCause()

拿到。

二、最基础的处理方式:任务内部 try-catch

最直接的方式,是在任务内部自己捕获异常。

class Task implements Runnable {

    @Override
    public void run() {
        try {
            int i = 1 / 0;
        } catch (Exception e) {
            log.error("任务执行异常", e);
        }
    }
}

这种方式简单直接,也适合一些局部任务。

但它有明显缺点:

  1. 每个任务都要重复写 try-catch

  2. 容易漏写;

  3. 异常处理逻辑分散;

  4. 不方便统一告警、统一打点、统一上报。

所以在生产项目中,一般不会只依赖这种方式。

任务内部 try-catch 可以作为局部兜底,但不是线程池异常治理的完整方案。

三、为什么 submit 不会直接打印异常?

submit() 的异常行为,核心原因在于它会把任务包装成 FutureTask

ThreadPoolExecutor.submit() 的大致逻辑如下:

public <T> Future<T> submit(Callable<T> task) {
    if (task == null) {
        throw new NullPointerException();
    }

    RunnableFuture<T> ftask = newTaskFor(task);
    execute(ftask);
    return ftask;
}

可以看到,submit() 底层其实还是调用了 execute()

但它不是直接执行原始任务,而是先把任务包装成了 FutureTask

FutureTask.run() 中会捕获异常,并把异常保存起来:

public void run() {
    try {
        Callable<V> c = callable;
        V result;

        try {
            result = c.call();
            ran = true;
        } catch (Throwable ex) {
            result = null;
            ran = false;
            setException(ex);
        }

        if (ran) {
            set(result);
        }
    } finally {
        // ...
    }
}

异常会进入 setException()

protected void setException(Throwable t) {
    if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)) {
        outcome = t;
        UNSAFE.putOrderedInt(this, stateOffset, EXCEPTIONAL);
        finishCompletion();
    }
}

也就是说,异常并没有丢失,而是被保存到了 FutureTaskoutcome 字段中。

只有调用 Future.get() 时,异常才会重新抛出来:

private V report(int s) throws ExecutionException {
    Object x = outcome;
    if (s == NORMAL) {
        return (V) x;
    }
    if (s >= CANCELLED) {
        throw new CancellationException();
    }
    throw new ExecutionException((Throwable) x);
}

所以,submit() 不是没有异常,而是:

异常被 FutureTask 保存起来了,只有调用 future.get() 才能取出来。

如果提交任务后不调用 get(),异常就很容易变成“黑洞”。

四、execute 的异常流转方式

execute() 的行为和 submit() 不一样。

使用 execute() 提交任务时,任务抛出的异常会继续向外抛。

在线程池内部,任务最终会进入 runWorker() 执行。

简化后的逻辑如下:

final void runWorker(Worker w) {
    try {
        beforeExecute(wt, task);

        Throwable thrown = null;

        try {
            task.run();
        } catch (RuntimeException x) {
            thrown = x;
            throw x;
        } catch (Error x) {
            thrown = x;
            throw x;
        } catch (Throwable x) {
            thrown = x;
            throw new Error(x);
        } finally {
            afterExecute(task, thrown);
        }
    } finally {
        // ...
    }
}

这里有两个重点:

第一,execute() 提交的任务,如果抛出异常,会被 runWorker() 捕获后继续抛出。

第二,线程池会调用:

afterExecute(task, thrown);

这给了我们一个统一处理任务异常的扩展点。

submit() 提交的任务,由于异常已经在 FutureTask.run() 里被捕获并保存,所以不会继续向外抛。

这也解释了一个常见现象:

  • execute() 的异常可能会触发 UncaughtExceptionHandler

  • submit() 的异常通常不会触发 UncaughtExceptionHandler

五、方案一:ThreadFactory + UncaughtExceptionHandler

一种常见做法是自定义 ThreadFactory,给线程设置 UncaughtExceptionHandler

ThreadFactory factory = runnable -> {
    Thread thread = new Thread(runnable);

    thread.setUncaughtExceptionHandler((t, e) -> {
        log.error("线程 {} 执行异常", t.getName(), e);
    });

    return thread;
};

然后创建线程池:

ExecutorService pool = new ThreadPoolExecutor(
        1,
        1,
        0,
        TimeUnit.MILLISECONDS,
        new LinkedBlockingQueue<>(10),
        factory
);

这种方式对 execute() 提交的任务有效。

因为 execute() 的异常会继续向外抛,最终可以被线程的 UncaughtExceptionHandler 捕获。

但它有一个明显限制:

对 submit() 提交的任务无效。

原因前面已经说过,submit() 会把任务包装成 FutureTask,异常在 FutureTask.run() 里已经被捕获并保存到了 outcome 中,不会继续向外抛。

因此,UncaughtExceptionHandler 不能作为线程池异常处理的完整兜底方案。

它只能覆盖部分场景。

六、方案二:重写 afterExecute 统一处理异常

更通用的方式,是重写 ThreadPoolExecutor.afterExecute()

afterExecute()ThreadPoolExecutor 提供的扩展方法,每个任务执行完成后都会调用。

可以利用它统一处理:

  • execute() 提交任务抛出的异常;

  • submit() 提交任务中被 FutureTask 保存的异常。

示例:

ExecutorService pool = new ThreadPoolExecutor(
        2,
        3,
        0,
        TimeUnit.MILLISECONDS,
        new LinkedBlockingQueue<>(10)
) {
    @Override
    protected void afterExecute(Runnable r, Throwable t) {
        super.afterExecute(r, t);

        if (t != null) {
            log.error("任务执行异常", t);
            return;
        }

        if (r instanceof Future<?>) {
            Future<?> future = (Future<?>) r;

            try {
                if (future.isDone()) {
                    future.get();
                }
            } catch (CancellationException e) {
                log.warn("任务被取消", e);
            } catch (ExecutionException e) {
                log.error("任务执行异常", e.getCause());
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                log.error("获取任务结果时被中断", e);
            }
        }
    }
};

这里的处理逻辑分两部分。

第一部分处理 execute()

if (t != null) {
    log.error("任务执行异常", t);
    return;
}

如果任务是通过 execute() 提交的,异常会通过参数 t 传进来。

第二部分处理 submit()

if (r instanceof Future<?>) {
    Future<?> future = (Future<?>) r;
    future.get();
}

如果任务是通过 submit() 提交的,它实际是一个 FutureTask,异常需要通过 future.get() 取出来。

这里有一个常见疑问:

afterExecute 里调用 future.get() 会不会阻塞?

通常不会。

因为 afterExecute() 是任务执行完成后才调用的,此时 FutureTask 已经进入完成状态,get() 会立即返回结果或者抛出异常。

为了代码更稳妥,可以先判断:

if (future.isDone()) {
    future.get();
}

这种方式是比较适合统一兜底的方案。

七、方案三:CompletableFuture 显式处理异常

如果是新代码,也可以使用 CompletableFuture 处理异步任务。

例如:

CompletableFuture
        .runAsync(() -> {
            int i = 1 / 0;
        }, pool)
        .exceptionally(ex -> {
            log.error("异步任务执行异常", ex);
            return null;
        });

或者有返回值的场景:

CompletableFuture
        .supplyAsync(() -> {
            int i = 1 / 0;
            return "success";
        }, pool)
        .exceptionally(ex -> {
            log.error("异步任务执行异常", ex);
            return "fallback";
        });

CompletableFuture 的好处是异常处理链路比较清晰。

相比 submit() 返回 Future 后还要记得调用 get()CompletableFuture 可以直接通过:

exceptionally()

或者:

whenComplete()

处理异常。

例如:

CompletableFuture
        .runAsync(() -> {
            doSomething();
        }, pool)
        .whenComplete((result, ex) -> {
            if (ex != null) {
                log.error("异步任务异常", ex);
            }
        });

这类写法更适合业务代码中显式表达异步任务的异常处理逻辑。

八、Spring @Async 抛异常怎么处理?

如果项目里使用了 Spring 的 @Async,也需要注意异常处理。

对于返回 void 的异步方法:

@Async
public void asyncTask() {
    int i = 1 / 0;
}

这类异常不能通过调用方拿到。

可以通过配置 AsyncUncaughtExceptionHandler 统一处理:

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, method, params) -> {
            log.error("Async 方法执行异常,method={}", method.getName(), ex);
        };
    }
}

如果异步方法有返回值,建议返回 CompletableFuture

@Async
public CompletableFuture<String> asyncTask() {
    int i = 1 / 0;
    return CompletableFuture.completedFuture("success");
}

调用方可以处理异常:

asyncService.asyncTask()
        .exceptionally(ex -> {
            log.error("异步任务异常", ex);
            return "fallback";
        });

需要注意的是,AsyncUncaughtExceptionHandler 主要用于处理 void 返回值的 @Async 方法。

如果方法返回 FutureCompletableFuture,异常应该通过返回对象处理。

九、线程池异常会导致 worker 线程退出吗?

这个问题要分情况看。

如果任务通过 execute() 提交,异常会一路向外抛出,当前 worker 线程会退出。

但线程池通常会根据状态和工作线程数量补充新的 worker,所以后续任务一般不会因为这个 worker 退出而全部停止。

如果任务通过 submit() 提交,异常被 FutureTask 捕获,不会继续向外抛,所以 worker 线程不会因为这个异常退出,会继续执行后续任务。

总结一下:

提交方式

异常是否向外抛

worker 是否可能退出

execute()

可能

submit()

通常不会


十、几种方案对比

方案

能否处理 execute 异常

能否处理 submit 异常

适用场景

任务内部 try-catch

可以

可以

局部任务兜底

Future.get()

不适用

可以

关心返回值或结果的任务

UncaughtExceptionHandler

可以

不可以

兜底 execute 异常

重写 afterExecute()

可以

可以

统一线程池异常处理

CompletableFuture.exceptionally()

可以

可以

新代码中的异步链式处理

Spring AsyncUncaughtExceptionHandler

部分可以

不适用

void 返回值的 @Async 方法


十一、常见错误理解

1. 只写 try-catch 就够了

try-catch 可以解决局部问题,但不适合作为线程池异常治理的唯一方案。

如果每个任务都手动写,容易遗漏,也不利于统一告警。

2. submit 也会自动打印异常

不会。

submit() 的异常会被封装进 FutureTask,如果不调用 future.get(),异常不会主动暴露出来。

3. UncaughtExceptionHandler 可以兜底所有异常

不可以。

它对 execute() 提交的任务有效,但对 submit() 提交的任务通常无效。

因为 submit() 的异常已经被 FutureTask 捕获了,不会继续抛到线程层面。

4. 线程池中某个任务异常后,整个线程池就不能用了

不一定。

execute() 任务异常可能导致当前 worker 线程退出,但线程池通常会补充新的 worker。

submit() 任务异常一般不会导致 worker 线程退出。

5. submit 一定比 execute 好

不能这么说。

两者适用场景不同:

  • 不需要返回值,只是提交任务执行,可以使用 execute()

  • 需要返回值,或者需要通过 Future 获取结果和异常,可以使用 submit()

关键不是谁更好,而是要清楚它们的异常处理方式不同。

十二、生产实践建议

在实际项目中,建议按下面几条原则处理线程池异常。

1. 不要让异常静默丢失

尤其是使用 submit() 时,如果不调用 Future.get(),异常很容易被忽略。

如果不关心返回值,只是想执行任务,可以考虑使用 execute(),再配合统一异常处理。

2. 统一封装线程池

可以基于 ThreadPoolExecutor 做统一封装,重写 afterExecute(),把任务异常统一接入日志、监控和告警。

3. 重要异步任务显式处理异常

对于重要任务,不要只依赖兜底逻辑。

可以在业务代码中显式使用:

exceptionally()

或者:

whenComplete()

处理异常。

4. 结合 traceId 排查问题

异步任务异常日志里最好带上 traceId、业务 ID、任务类型等信息。

否则任务一旦异步执行,排查链路会比较困难。

5. 区分兜底异常和业务异常

线程池统一异常处理适合做兜底。

但业务上可预期的异常,仍然应该在业务层处理。

例如:

  • 参数校验失败;

  • 业务状态不允许;

  • 第三方接口返回明确错误码。

这些不应该全部依赖线程池兜底处理。

结论

线程池异常处理的关键,不是简单写一个 try-catch

更重要的是理解不同任务提交方式的异常行为。

execute() 提交任务时,异常会继续向外抛,可以被 afterExecute()UncaughtExceptionHandler 捕获。

submit() 提交任务时,任务会被包装成 FutureTask。异常会被保存到 FutureTaskoutcome 中,只有调用 future.get() 时才会以 ExecutionException 的形式抛出。

所以,生产中更稳妥的做法是:

  • 局部任务可以使用 try-catch

  • 需要结果的任务要处理 Future.get()

  • 统一线程池建议重写 afterExecute()

  • 新代码可以优先考虑 CompletableFuture 的异常链式处理;

  • Spring @Async 要区分 void 返回值和 Future / CompletableFuture 返回值;

  • 异常日志要接入统一告警,并带上必要的上下文信息。

评论