@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。在常见的非虚拟线程场景下,它默认是基于 ThreadPoolTaskExecutor 的 applicationTaskExecutor,并不一定会直接退回到 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 的真实踩坑复现。