Spring @Async 默认线程池陷阱:SimpleAsyncTaskExecutor 不复用线程致 OOM(附复现代码)
Spring @Async 默认线程池陷阱:SimpleAsyncTaskExecutor 不复用线程致 OOM(附复现代码)
TL;DR:@Async 默认使用 SimpleAsyncTaskExecutor,每次调用创建新线程不复用,高并发下线程数失控 OOM。文末附完整复现代码(JDK 8+ 验证通过)。
事故背景
上篇线程池 OOM 的事刚过去三天,又炸了。这次不是我自己配的线程池出问题,@Async 的默认行为把我坑了,@Async 帮我"默认"了一个,然后 OOM 了。
场景很简单:订单完成后异步发送通知。代码长这样:
@Service
public class OrderService {
@Async
public void sendNotify(Long orderId) {
// 调通知接口,耗时约 200ms
notifyApi.send(orderId);
}
public void completeOrder(Long orderId) {
orderMapper.updateStatus(orderId, "COMPLETED");
sendNotify(orderId); // 异步发送通知
}
}
上线后跑了一周没问题。直到有一天搞促销,10 分钟内完成了 2 万笔订单,告警又来了:
[告警] order-service 线程数 > 20000
[告警] order-service 内存使用率 > 85%
[告警] order-service 频繁 Full GC
dump 下来一看:堆里全是 Thread 对象,线程数 2 万多。每个线程栈 1MB,2 万个线程 = 20GB 栈空间,物理内存只有 4G 的机器直接被撑爆。
我没有手动创建线程池,代码里只有一个 @Async 注解。线程是哪来的?
一、@Async 的默认线程池是什么
很多人以为 @Async 底层用的是某个线程池,会复用线程。到底是不是,取决于你的 Spring Boot 版本和上下文里有没有其他 Executor bean。
1.1 Spring Boot 2.0+:默认是 ThreadPoolTaskExecutor
Spring Boot 2.0 起,TaskExecutionAutoConfiguration 会自动配置一个 ThreadPoolTaskExecutor(bean 名 applicationTaskExecutor),@Async 默认就用它。默认参数:
| 参数 | 默认值 |
|---|---|
| corePoolSize | 8 |
| maxPoolSize | Integer.MAX_VALUE |
| queueCapacity | Integer.MAX_VALUE(无界) |
会复用线程,不会"每次 new Thread"。但注意 queueCapacity 是无界,和上一篇讲的一样,高并发下任务对象会无限堆积,照样 OOM(只是 OOM 在堆上,不是线程栈)。
1.2 什么情况会降级到 SimpleAsyncTaskExecutor
@Async 的 executor 选择逻辑(AsyncExecutionInterceptor.getDefaultExecutor)是这样的:
- 先找上下文里
TaskExecutor类型的 bean,找到就用 - 找不到?降级到
new SimpleAsyncTaskExecutor()
Spring Boot 2.0+ 的自动配置有个前提:@ConditionalOnMissingBean(Executor.class)。如果你在项目里自定义了任何 Executor 类型的 bean(比如上一篇文章里配的 ThreadPoolExecutor),Spring Boot 的默认 ThreadPoolTaskExecutor 就不会自动配置了。而 JDK 的 ThreadPoolExecutor 实现了 Executor 但没实现 Spring 的 TaskExecutor,@Async 找不到 TaskExecutor bean,直接降级到 SimpleAsyncTaskExecutor。
这正是我们项目的情况:上一篇文章里配了一个 @Bean ThreadPoolExecutor,导致 Spring Boot 默认的 ThreadPoolTaskExecutor 没自动配置,@Async 降级到了 SimpleAsyncTaskExecutor。
纯 Spring Framework(非 Boot)、Spring Boot 1.x 也会走这个降级路径。
1.3 SimpleAsyncTaskExecutor:不复用线程
SimpleAsyncTaskExecutor 的行为在文档里写得很清楚:
SimpleAsyncTaskExecutor does not reuse threads. Each call creates a new thread.
看看它的源码就明白了:
// SimpleAsyncTaskExecutor.java
@Override
protected void doExecute(Runnable task) {
Thread thread = new Thread(getThreadGroup(), task, nextThreadName());
thread.setPriority(getThreadPriority());
thread.setDaemon(isDaemon());
thread.start(); // 直接 new Thread().start(),没有池化
}
每次执行任务就是 new Thread().start()。没有队列,没有核心线程,没有最大线程,没有拒绝策略。来一个任务建一个线程,用完就丢。
所以 2 万笔订单 = 2 万次 @Async 调用 = 2 万个新线程。机器直接被线程栈撑爆。
二、5 秒复现:@Async 怎么把线程数撑到爆
2.1 复现代码
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.stereotype.Service;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
@SpringBootApplication
@EnableAsync
@Service
public class AsyncOOMApp {
public static void main(String[] args) throws InterruptedException {
ConfigurableApplicationContext ctx = SpringApplication.run(AsyncOOMApp.class, args);
AsyncOOMApp app = ctx.getBean(AsyncOOMApp.class);
System.out.println("开始批量调用 @Async 方法...");
for (int i = 0; i < 50000; i++) {
app.asyncTask(i);
}
// 等一下,看线程数
Thread.sleep(3000);
int threadCount = Thread.activeCount();
System.out.println("当前活跃线程数: " + threadCount);
ctx.close();
}
@Async
public void asyncTask(int i) {
try {
Thread.sleep(5000); // 模拟耗时操作
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
运行结果(JVM 参数:-Xmx256m):
开始批量调用 @Async 方法...
当前活跃线程数: 8765
Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread
50000 个任务提交后,3 秒内就创建了 8765 个线程,然后 OOM。
2.2 为什么不复用线程会 OOM
对比一下线程池和 SimpleAsyncTaskExecutor 的行为:
| 线程池(ThreadPoolExecutor) | SimpleAsyncTaskExecutor | |
|---|---|---|
| 线程创建 | 核心线程数之内复用,超出排队 | 每次都 new Thread |
| 队列 | 有界或无界队列缓冲 | 没有 |
| 最大线程数 | 有上限 | 没有上限 |
| 拒绝策略 | 满了走策略 | 不存在"满了"这个概念 |
| 内存影响 | 可控 | 不可控 |
线程池的设计目的是控制线程数量,来再多任务,线程数也有上限,多余的排队或拒绝。SimpleAsyncTaskExecutor 完全没有这些机制,来多少任务建多少线程。
每个线程默认占用约 1MB 栈空间(Java 默认 -Xss1m)。1 万个线程 = 10GB 栈空间。你的 JVM 堆可能只配了 512MB,但线程栈是堆外内存,不占堆占物理内存。物理内存一满,OOM Killer 直接杀进程。
三、@Async 还有几个坑
除了默认线程池不复用线程,@Async 还有几个常见翻车场景。
3.1 自调用失效
@Service
public class OrderService {
public void completeOrder(Long orderId) {
orderMapper.updateStatus(orderId, "COMPLETED");
sendNotify(orderId); // 这里是自调用,@Async 失效!
}
@Async
public void sendNotify(Long orderId) {
notifyApi.send(orderId);
}
}
completeOrder 直接调用 this.sendNotify(),走的是原始对象,不是 Spring 代理对象。@Async 是基于 AOP 代理实现的,自调用绕过了代理,注解直接失效。
结果:sendNotify 变成同步执行,completeOrder 会被阻塞 200ms。如果你以为它是异步的,在 completeOrder 后面做了耗时不敏感的操作,就会出现意想不到的延迟。
解决方法:把 @Async 方法拆到另一个类里,或者注入自己的代理对象:
// 方案 1:拆到另一个 Service
@Service
public class NotifyService {
@Async("notifyThreadPool")
public void sendNotify(Long orderId) {
notifyApi.send(orderId);
}
}
@Service
public class OrderService {
@Autowired
private NotifyService notifyService;
public void completeOrder(Long orderId) {
orderMapper.updateStatus(orderId, "COMPLETED");
notifyService.sendNotify(orderId); // 走代理,@Async 生效
}
}
// 方案 2:注入自己的代理(不推荐,不够优雅)
@Autowired
@Lazy
private OrderService self;
public void completeOrder(Long orderId) {
orderMapper.updateStatus(orderId, "COMPLETED");
self.sendNotify(orderId); // 走代理
}
3.2 private 方法失效
@Async
private void sendNotify(Long orderId) { // private!@Async 失效
notifyApi.send(orderId);
}
Spring AOP 代理无法拦截 private 方法(动态代理只能代理接口方法或 public 方法)。@Async 加在 private 方法上等于没加。
3.3 没配 @EnableAsync
@SpringBootApplication
// 忘了加 @EnableAsync
public class App {
public static void main(String[] args) {
SpringApplication.run(App.class, args);
}
}
忘了 @EnableAsync,所有 @Async 注解都不生效。这个错误最隐蔽,代码不报错,日志不提示,@Async 默默变成同步执行。你以为异步了,实际全在主线程跑。
四、怎么正确使用 @Async
方案 1:自定义线程池(必须做)
不要用默认的 SimpleAsyncTaskExecutor。定义自己的线程池,通过 @Async(“beanName”) 指定:
@Configuration
public class AsyncConfig {
@Bean("notifyThreadPool")
public ThreadPoolTaskExecutor notifyThreadPool() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
int cpu = Runtime.getRuntime().availableProcessors();
executor.setCorePoolSize(cpu * 2); // IO 密集型
executor.setMaxPoolSize(cpu * 4);
executor.setQueueCapacity(500);
executor.setKeepAliveSeconds(60);
executor.setThreadNamePrefix("notify-async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
@Async("notifyThreadPool") // 指定自定义线程池
public void sendNotify(Long orderId) {
notifyApi.send(orderId);
}
这样 @Async 就会用你的线程池,线程数有上限,不会失控。
方案 2:实现 AsyncConfigurer 全局指定
如果你不想每个 @Async 都写 beanName,可以全局指定默认线程池:
@Configuration
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
int cpu = Runtime.getRuntime().availableProcessors();
executor.setCorePoolSize(cpu * 2);
executor.setMaxPoolSize(cpu * 4);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("async-default-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) -> {
log.error("异步任务异常: method={}, error={}", method.getName(), ex.getMessage());
};
}
}
这样所有 @Async 不指定 beanName 的方法都会用这个线程池。
注意:实现了 AsyncConfigurer 后,@Async 默认用的就不再是 SimpleAsyncTaskExecutor 了,而是你返回的线程池。这是最推荐的做法,一次性解决默认线程池不复用的问题。
方案 3:用 CompletableFuture 替代 @Async
如果你觉得 @Async 的代理机制太容易踩坑,直接用 CompletableFuture:
// ❌ @Async 容易踩坑
@Async("notifyThreadPool")
public void sendNotify(Long orderId) {
notifyApi.send(orderId);
}
// ✅ CompletableFuture 更可控
@Autowired
@Qualifier("notifyThreadPool")
private Executor notifyThreadPool;
public void sendNotify(Long orderId) {
CompletableFuture.runAsync(() -> notifyApi.send(orderId), notifyThreadPool);
}
CompletableFuture 的好处:不依赖 AOP 代理,没有自调用失效问题,线程池显式传入,行为完全可控。而且可以链式调用、组合异步、异常处理,比 @Async 灵活得多。
五、CheckList:@Async 上线前排查
| # | 检查项 | 风险点 | 正确做法 |
|---|---|---|---|
| 1 | 没指定线程池 | 默认 SimpleAsyncTaskExecutor 不复用线程 | 自定义线程池或实现 AsyncConfigurer |
| 2 | 自调用 | 绕过 AOP 代理,@Async 失效 | 拆到另一个类或注入代理对象 |
| 3 | private 方法 | 代理无法拦截,@Async 失效 | 改为 public |
| 4 | 忘了 @EnableAsync | 所有 @Async 静默失效 | 确认启动类加了 @EnableAsync |
| 5 | 没有异常处理 | 异步任务异常被吞 | 实现 AsyncUncaughtExceptionHandler |
| 6 | 高并发场景用默认线程池 | 线程数失控 → OOM | 必须用有界线程池 |
六、总结
回到这次 OOM:项目里有上一篇文章配的自定义 Executor bean,导致 Spring Boot 默认的 ThreadPoolTaskExecutor 没自动配置,@Async 降级到 SimpleAsyncTaskExecutor,每次调用创建新线程不复用,2 万笔订单 = 2 万个线程,物理内存被线程栈撑爆。
记住这三点:
- Spring Boot 2.0+ @Async 默认是 ThreadPoolTaskExecutor(corePoolSize=8, 无界队列),不是 SimpleAsyncTaskExecutor
- 但如果上下文有自定义 Executor bean,默认配置会失效,@Async 降级到 SimpleAsyncTaskExecutor,不复用线程,每次 new Thread
- 不管是哪种默认,都不安全:ThreadPoolTaskExecutor 无界队列会任务堆积,SimpleAsyncTaskExecutor 会线程失控
正确用法:自定义线程池 + 指定 beanName,或者实现 AsyncConfigurer 全局替换默认线程池。如果嫌 @Async 坑多,直接用 CompletableFuture,更可控。
附录:本地复现完整代码
不需要 Spring Boot 环境,用纯 Java 就能复现 SimpleAsyncTaskExecutor 的问题:
import java.util.concurrent.*;
import org.springframework.core.task.SimpleAsyncTaskExecutor;
public class AsyncDefaultOOM {
public static void main(String[] args) throws InterruptedException {
// 模拟 @Async 默认使用的 SimpleAsyncTaskExecutor
SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor("notify-async-");
System.out.println("提交 50000 个任务...");
for (int i = 0; i < 50000; i++) {
executor.execute(() -> {
try {
Thread.sleep(5000); // 每个任务 5 秒
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
Thread.sleep(2000);
System.out.println("当前线程数: " + Thread.activeCount());
// 对比:用 ThreadPoolExecutor 不会有这个问题
// ThreadPoolExecutor pool = new ThreadPoolExecutor(
// 16, 32, 60, TimeUnit.SECONDS,
// new LinkedBlockingQueue<>(500),
// new ThreadPoolExecutor.CallerRunsPolicy()
// );
// for (int i = 0; i < 50000; i++) {
// pool.execute(() -> { try { Thread.sleep(5000); } catch (Exception e) {} });
// }
// System.out.println("线程数: " + pool.getPoolSize()); // 最多 32
}
}
运行命令:
# 需要引入 spring-core 依赖
javac -cp "spring-core.jar" AsyncDefaultOOM.java
java -Xmx256m -cp ".:spring-core.jar" AsyncDefaultOOM
# 或者直接在 Spring Boot 项目里运行附录的完整示例
建议本地跑一遍。对比 SimpleAsyncTaskExecutor 和 ThreadPoolExecutor 的线程数差异,亲眼看到"不复用线程"是什么效果。
复现要点:SimpleAsyncTaskExecutor 没有 maxPoolSize 概念,来多少任务建多少线程。把循环数改大一点(比如 10 万),OOM 更快。如果你想验证 Spring Boot 2.0+ 的真实默认行为,在 Spring Boot 项目里不配任何 Executor bean,调用 @Async 方法,线程名会是
task-前缀(ThreadPoolTaskExecutor),而不是notify-async-前缀(SimpleAsyncTaskExecutor)。
你的 @Async 是怎么配线程池的?评论区聊聊你的配置方案。
如果觉得有帮助,点赞 + 收藏,下次用 @Async 直接翻出来查。
系列导航
本文是「Java 生产环境踩坑实录」系列第 8 篇,往期回顾:
-
第 4 篇:MySQL 间隙锁是怎么"悄悄"制造死锁的
-
第 5 篇:唯一索引并发插入为什么也会死锁
-
第 6 篇:MySQL RR 隔离级幻读真相
-
第 7 篇:线程池参数配错致 OOM
-
第 8 篇:@Async 默认线程池陷阱(本文)
下篇预告:Redis 缓存击穿/穿透/雪崩一次讲清,面试背了 100 遍,生产里到底怎么防
系列持续更新中。更多生产环境踩坑实录,关注公众号。
更多推荐




所有评论(0)