@Async 默认执行器又炸了:SimpleAsyncTaskExecutor 为什么不是线程池?

@Async 默认执行器又炸了:SimpleAsyncTaskExecutor 为什么不是线程池?

加个 @Async,接口响应是快了。

但高峰期一来,线程数一路飙升,CPU 抖动,内存上涨,最后 JVM 直接 OOM。

很多人以为自己用了线程池。

其实你可能只是开了一个"来一个任务就创建一个线程"的异步执行器。


一、事故现场

线上有个导入接口。

用户上传 Excel 后,接口不想一直等后台处理,于是开发同学加了一个 @Async

java 复制代码
@Service
public class ImportService {

    @Async
    public void importExcel(Long fileId) {
        List<RowData> rows = parseExcel(fileId);

        for (RowData row : rows) {
            saveRow(row);
        }
    }
}

Controller 里直接调用:

java 复制代码
@PostMapping("/import")
public String importFile(@RequestParam Long fileId) {
    importService.importExcel(fileId);
    return "任务已提交";
}

刚上线时效果很好。

接口从原来的几十秒返回,变成了几毫秒返回。

大家都很满意。

直到某天业务批量导入,用户连续上传了很多文件。

监控开始报警:

text 复制代码
JVM 线程数持续上涨
CPU 上下文切换升高
堆内存持续上涨
接口开始超时
最后出现 OutOfMemoryError

线程 dump 里一看,满屏都是异步任务线程。

第一反应是:

线程池怎么没扛住?

问题是:你可能根本没配置真正可控的线程池。


二、@Async 不等于你已经有了安全线程池

很多人对 @Async 的理解是:

加了 @Async,Spring 会帮我丢到线程池里执行。

这句话只说对了一半。

@Async 确实会异步执行。

但关键是:

它到底用哪个 Executor?

如果项目里没有显式配置异步执行器,Spring Framework 大致会按这些方式确定执行器:

text 复制代码
AsyncConfigurer 提供的 Executor
容器里唯一的 TaskExecutor Bean
名称为 taskExecutor 的 Executor Bean

如果都没有,就会退回到默认执行器。

在纯 Spring 项目里,这个默认执行器通常就是 SimpleAsyncTaskExecutor

但如果你用的是 Spring Boot,还要多看一眼版本和自动配置。

Spring Boot 通常会自动配置一个 AsyncTaskExecutor。在常见的非虚拟线程场景下,它默认是基于 ThreadPoolTaskExecutorapplicationTaskExecutor,并不一定会直接退回到 SimpleAsyncTaskExecutor

也就是说,这个坑更准确的说法是:

不要把 @Async 等同于"已经有了按业务配置好的安全线程池"。

有些项目确实会因为没有启用 Boot 自动配置、手动排除了任务执行自动配置、纯 Spring 用法,或者显式配置不当,最后落到 SimpleAsyncTaskExecutor

还有一些项目虽然用的是 ThreadPoolTaskExecutor,但线程数、队列和拒绝策略没有按业务压测过,照样可能把任务堆到不可控。

它名字里有 Executor,但它不是传统意义上的线程池。


三、SimpleAsyncTaskExecutor 最大的坑

SimpleAsyncTaskExecutor 的核心问题是:

默认不复用线程。

它更像是:

text 复制代码
每来一个任务,就启动一个新线程去执行。

而不是:

text 复制代码
维护一组固定工作线程,任务来了排队执行。

这两者差别非常大。

真正的线程池一般会有:

text 复制代码
核心线程数
最大线程数
任务队列
拒绝策略
线程复用

SimpleAsyncTaskExecutor 如果没有限制并发,任务一多,就可能不断创建新线程。

比如瞬间来了 5000 个异步任务:

text 复制代码
任务 1 -> 创建线程 1
任务 2 -> 创建线程 2
任务 3 -> 创建线程 3
...
任务 5000 -> 创建线程 5000

线程不是免费的。

每个线程都有栈内存。

线程多了以后,还会带来:

text 复制代码
内存占用增加
上下文切换增加
CPU 抖动
GC 压力上升
调度变慢
最终 OOM

所以这类事故经常表现为:

明明是想用异步削峰,结果异步把机器打爆了。


四、最小复现

先开启异步:

java 复制代码
@EnableAsync
@SpringBootApplication
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}

异步方法:

java 复制代码
@Service
public class AsyncTaskService {

    @Async
    public void runTask(int i) {
        try {
            Thread.sleep(60_000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

模拟提交大量任务:

java 复制代码
@RestController
public class TestController {

    @Autowired
    private AsyncTaskService asyncTaskService;

    @GetMapping("/submit")
    public String submit() {
        for (int i = 0; i < 5000; i++) {
            asyncTaskService.runTask(i);
        }
        return "ok";
    }
}

如果你是在纯 Spring 场景,或者项目没有让 Spring Boot 的任务执行自动配置生效,又没有自己配置受控线程池,就要小心了。

如果你用的是 Spring Boot 默认配置,这段复现不一定表现为"疯狂创建 5000 个平台线程",但仍然要关注默认任务执行器的队列、最大线程数和拒绝策略是否符合业务压力。

任务提交速度远远大于任务执行速度。

如果执行器不限制线程数量,线程数会快速上涨。

这时你看监控,会看到类似现象:

text 复制代码
Live Threads 持续增加
CPU 上下文切换增加
堆内存、非堆内存和本地内存压力增加
接口响应变慢

严重时就是:

text 复制代码
java.lang.OutOfMemoryError: unable to create native thread

或者:

text 复制代码
java.lang.OutOfMemoryError: Java heap space

具体报哪种,取决于线程栈、本地内存、堆内存、任务对象、队列堆积和系统限制等因素。

其中线程数量失控时,更典型的是:

text 复制代码
java.lang.OutOfMemoryError: unable to create native thread

如果你看到的是 Java heap space,通常还要一起排查任务对象、解析结果、缓存、队列堆积等是否把 Java 堆撑爆了。


五、为什么本地测不出来?

这个坑本地很难暴露。

1. 本地任务少

你本地可能只点几次接口。

线上可能是几千个用户、几十个定时任务、批量数据同时触发。

2. 本地任务快

本地处理一条数据可能 10ms。

线上可能涉及:

text 复制代码
数据库慢 SQL
远程接口
文件解析
对象存储
消息投递
锁等待

任务执行越慢,堆积越严重。

3. 没看线程数

很多人压测只看 QPS、RT、CPU、内存。

但异步任务一定要看:

text 复制代码
线程数
队列长度
活跃线程数
任务完成数
拒绝次数

否则你只看到接口很快返回,看不到后台已经在堆任务。


六、正确写法:显式配置 ThreadPoolTaskExecutor

生产环境不要依赖默认异步执行器。

建议显式配置:

java 复制代码
@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean("bizAsyncExecutor")
    public ThreadPoolTaskExecutor bizAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();

        executor.setCorePoolSize(8);
        executor.setMaxPoolSize(16);
        executor.setQueueCapacity(1000);
        executor.setThreadNamePrefix("biz-async-");

        executor.setRejectedExecutionHandler(
            new ThreadPoolExecutor.CallerRunsPolicy()
        );

        executor.initialize();
        return executor;
    }
}

然后在 @Async 里指定:

java 复制代码
@Async("bizAsyncExecutor")
public void importExcel(Long fileId) {
    // ...
}

这样至少有几个明确边界:

text 复制代码
核心线程:8
最大线程:16
队列容量:1000
队列和线程数都达到上限后触发拒绝策略
线程名可识别

出了问题,线程 dump 也能很快定位:

text 复制代码
biz-async-1
biz-async-2
biz-async-3

比一堆默认线程名好排查得多。


七、拒绝策略不能随便选

线程池最容易被忽略的是拒绝策略。

常见策略有几个:

策略 行为 风险
AbortPolicy 直接抛异常 调用方要处理异常
CallerRunsPolicy 调用线程自己执行 可以反压,但会拖慢入口
DiscardPolicy 直接丢任务 任务可能无感丢失
DiscardOldestPolicy 丢最老任务 容易丢关键任务

我一般不建议业务任务用静默丢弃。

比如导入、发通知、写日志、同步状态,如果任务被静默丢了,后面排查会很痛苦。

CallerRunsPolicy 的好处是:

线程池满了以后,让提交任务的线程自己执行,从而拖慢入口,形成反压。

但它也不是万能的。

如果入口线程是 Tomcat 请求线程,就可能导致接口变慢。

所以你要根据业务选择:

text 复制代码
能失败就明确失败
能排队就有限排队
不能丢就落库再异步

不要让任务悄悄丢。


八、线程池参数怎么估?

不要一上来就背公式。

先问 5 个问题:

text 复制代码
这个任务是 CPU 密集还是 IO 密集?
单个任务平均耗时多少?
高峰期每秒提交多少任务?
允许任务排队多久?
任务能不能丢,失败能不能重试?

比如导入任务:

text 复制代码
每秒最多提交 20 个任务
每个任务平均执行 5 秒
希望最多排队 1 分钟

粗略估算:

text 复制代码
运行中任务数 = 提交速率 * 执行耗时 = 20 * 5 = 100

如果你只配:

text 复制代码
corePoolSize = 8
maxPoolSize = 16
queueCapacity = 100

那高峰期很快就会排满。

这时你要么:

text 复制代码
降低提交速率
拆分任务
增加消费能力
引入 MQ
限制用户提交
把大任务改成后台任务表

而不是只把线程数从 16 改成 200。

线程数太大,数据库、Redis、远程接口也可能被打爆。

线程池不是越大越好。

它是用来控制并发的,不是用来无限接任务的。


九、不要所有 @Async 共用一个线程池

很多项目只配一个:

java 复制代码
@Bean("taskExecutor")
public ThreadPoolTaskExecutor taskExecutor() {
    // ...
}

然后所有异步任务都用它。

这会有一个问题:

一个慢任务把公共线程池占满,其他异步任务也跟着饿死。

比如这些任务混在一起:

text 复制代码
Excel 导入
短信发送
邮件通知
操作日志
报表生成
远程同步

某天 Excel 导入堆满队列,短信通知也发不出去了。

所以建议按业务隔离线程池:

java 复制代码
@Async("importExecutor")
public void importExcel(Long fileId) {}

@Async("notifyExecutor")
public void sendSms(Long userId) {}

@Async("logExecutor")
public void writeLog(Long bizId) {}

至少把这些分开:

text 复制代码
耗时批处理
用户可感知通知
低优先级日志
核心状态同步

线程池隔离,本质上是故障隔离。


十、异步任务失败也要能看见

还有一个常见坑:

java 复制代码
@Async
public void sendMessage(Long userId) {
    messageClient.send(userId);
    throw new RuntimeException("发送失败");
}

如果异步方法返回 void,异常不会像同步方法一样直接抛回调用方。

很多时候你只是在日志里看到一条异常。

调用方根本不知道任务失败了。

所以关键任务不要只靠 @Async void

可以考虑:

text 复制代码
任务落库
状态流转
失败重试
告警
人工补偿

比如:

java 复制代码
@Transactional(rollbackFor = Exception.class)
public void submitImport(Long fileId) {
    importTaskMapper.insert(fileId, "NEW");
}

后台任务扫描:

java 复制代码
@Scheduled(fixedDelay = 1000)
public void consumeImportTasks() {
    List<ImportTask> tasks = importTaskMapper.selectNewTasks();

    for (ImportTask task : tasks) {
        try {
            importTaskMapper.markRunning(task.getId());
            doImport(task.getFileId());
            importTaskMapper.markSuccess(task.getId());
        } catch (Exception e) {
            importTaskMapper.markFailed(task.getId(), e.getMessage());
        }
    }
}

这比直接一个 @Async void 稳得多。

因为任务有没有执行、失败了几次、卡在哪一步,都能查。


十一、上线前 checklist

看到 @Async,建议上线前过一遍这个表。

检查项 风险 建议
是否显式配置 Executor 默认或公共执行器未按业务配置 配置 ThreadPoolTaskExecutor
是否指定线程池名称 可能误用公共线程池 @Async("xxxExecutor")
是否有队列容量 无限堆积或无限建线程 设置有界队列
是否有拒绝策略 队列满后行为不明 明确失败、反压或落库
线程名是否可识别 排查困难 设置 ThreadNamePrefix
是否所有任务共用线程池 相互拖垮 按业务隔离
异步任务是否能丢 静默失败 关键任务落库
是否监控活跃线程和队列 堆积不可见 接入监控和告警
是否有重试和补偿 失败后无人知道 状态表/消息表

十二、总结

@Async 最大的误区是:

加了注解,就等于安全异步。

不是。

真正安全的异步,至少要回答这几个问题:

text 复制代码
用哪个线程池?
最多多少线程?
队列多大?
满了怎么办?
任务失败谁知道?
任务能不能重试?
不同业务会不会互相拖垮?

SimpleAsyncTaskExecutor 名字里有 Executor,但它默认不是一个会复用固定工作线程的传统线程池。

生产环境里,别把异步能力完全交给未经压测和隔离的默认行为。

最后记住一句话:

异步不是为了无限接活,而是为了可控地削峰。

下一篇准备写:一条 UPDATE 锁住半张表:MySQL 没走索引的真实事故

这个系列会持续更新 Spring 事务、线程池、JVM、MySQL、Redis、MQ 的真实踩坑复现。

相关推荐
程序员Agions1 小时前
Fypro 新手快速上手与实战指南
后端
舒心的云朵1 小时前
用三张图片详解Asp.Net 全生命周期
后端·asp.net
卷无止境1 小时前
C++ 类型转换变迁之路:从隐式转换到 static_cast
c++·后端
孝顺的书包1 小时前
用Asp.net写自己的服务框架
后端·asp.net
糊涂的世界1 小时前
IE10、IE11 User-Agent 导致的 ASP.Net 网站无法写入Cookie 问题
后端·asp.net
易协同低代码1 小时前
通达OA配置文件详解
后端
uhakadotcom2 小时前
什么是 “无界限的列表积累” 问题
后端·面试·github
KaifuZeng2 小时前
通信与接口协议面试八、CAN通信
单片机·嵌入式硬件·面试·通信与接口协议