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)是这样的:

  1. 先找上下文里 TaskExecutor 类型的 bean,找到就用
  2. 找不到?降级到 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 万个线程,物理内存被线程栈撑爆。

记住这三点:

  1. Spring Boot 2.0+ @Async 默认是 ThreadPoolTaskExecutor(corePoolSize=8, 无界队列),不是 SimpleAsyncTaskExecutor
  2. 但如果上下文有自定义 Executor bean,默认配置会失效,@Async 降级到 SimpleAsyncTaskExecutor,不复用线程,每次 new Thread
  3. 不管是哪种默认,都不安全: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 篇,往期回顾:

下篇预告:Redis 缓存击穿/穿透/雪崩一次讲清,面试背了 100 遍,生产里到底怎么防


系列持续更新中。更多生产环境踩坑实录,关注公众号。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐