SpringBoot中使用线程池
SpringBoot中使用线程池
最近在做一个批量导出报表的功能,用户一点按钮,后台要处理几千条数据、还要调第三方接口生成图表。如果都在主线程里串行跑,接口要等十几秒才返回,前端早就超时了。于是想到了用线程池异步处理。本文记录一下我在 SpringBoot 项目里是怎么用线程池的,以及踩过的一些坑。
一、为什么要用线程池
先看一个反面例子,很多人一开始是这么写的:
public void handle() {
for (int i = 0; i < 100; i++) {
new Thread(() -> {
// 业务逻辑
}).start();
}
}
这样每来一个任务就 new 一个线程,看起来能跑,但问题很大:
- 创建销毁开销大:线程的创建和销毁是重量级操作,频繁创建会浪费大量系统资源。
- 线程数不可控:并发高的时候可能瞬间创建成百上千个线程,直接把系统拖垮。
- 难以统一管理:线程散落在各处,不好监控,也不好统一调优。
线程池正好解决这三个问题:复用线程、控制数量、统一管理。说白了,线程池就是"一个装着固定数量工人的工厂",任务来了排队等工人干,干完工人不消失,接着干下一个。
二、线程池的核心参数
Java 线程池的核心类是 ThreadPoolExecutor,构造方法有 7 个参数:
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
| 参数 | 含义 |
|---|---|
| corePoolSize | 核心线程数,线程池中常驻的线程数量 |
| maximumPoolSize | 最大线程数,线程池最多能容纳多少个线程 |
| keepAliveTime | 非核心线程空闲多久后被回收 |
| unit | keepAliveTime 的时间单位 |
| workQueue | 任务队列,存放等待执行的任务 |
| threadFactory | 线程工厂,用来创建线程,可以自定义线程名 |
| RejectedExecutionHandler | 拒绝策略,队列满且线程数达到上限时的处理方式 |
这些参数理解起来有点抽象,配合下面的执行流程就清楚了。
三、线程池的执行流程
当一个任务被提交到线程池时,大致经历这样几个判断:
- 当前线程数 < corePoolSize,直接创建核心线程执行;
- 当前线程数 >= corePoolSize,且任务队列没满,任务进入队列排队;
- 队列也满了,但线程数 < maximumPoolSize,创建非核心线程执行;
- 队列满了,线程数也达到 maximumPoolSize 了,触发拒绝策略。
这里有个反直觉的点:线程池是先占满核心线程、再往队列塞,队列塞满了才会扩到最大线程数,而不是一上来就创建最大线程数的线程。所以队列容量的大小非常关键,下面第六节会详细说。
四、SpringBoot 中使用线程池
SpringBoot 提供了 ThreadPoolTaskExecutor(内部封装了 ThreadPoolExecutor),配合 @Async 注解用起来非常方便。
1. 定义线程池 Bean
@Configuration
@EnableAsync
public class ThreadPoolConfig {
@Bean("taskExecutor")
public ThreadPoolTaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 核心线程数
executor.setCorePoolSize(10);
// 最大线程数
executor.setMaxPoolSize(20);
// 队列容量
executor.setQueueCapacity(200);
// 线程空闲存活时间(秒)
executor.setKeepAliveSeconds(60);
// 线程名前缀,方便排查问题
executor.setThreadNamePrefix("task-async-");
// 拒绝策略:由调用线程执行任务,起到削峰作用
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// 初始化
executor.initialize();
return executor;
}
}
注意配置类上要加 @EnableAsync 注解,开启异步支持。
2. 使用 @Async 注解
@Service
public class AsyncService {
@Async("taskExecutor")
public void sendMessage(String message) {
// 这里就是异步执行的任务
System.out.println("当前线程: " + Thread.currentThread().getName() + ",消息: " + message);
}
}
调用方直接调用 sendMessage 方法,方法就会在 taskExecutor 这个线程池里异步执行,主线程不用等它。
3. 有返回值的异步任务
@Async("taskExecutor")
public Future<String> doTask(int i) {
String result = "任务" + i + "执行完成";
return new AsyncResult<>(result);
}
返回 Future 或 CompletableFuture 都可以拿到异步任务的结果,需要时再 get()。
4. CompletableFuture 的用法
CompletableFuture 是 Java 8 引入的异步编程利器,相比 Future,它支持回调、链式调用、任务组合和异常处理,写起来更像是"声明式"的流水线。实际项目里,线程池 + CompletableFuture 是处理多任务并发的标配。
4.1 创建异步任务
// 无返回值
CompletableFuture<Void> f1 = CompletableFuture.runAsync(() -> {
// 异步任务
}, taskExecutor);
// 有返回值
CompletableFuture<String> f2 = CompletableFuture.supplyAsync(() -> {
return "执行结果";
}, taskExecutor);
注意:不传第二个参数(线程池)时,默认使用 ForkJoinPool.commonPool()。生产环境务必显式传入自己的线程池,否则异步任务都挤在公共池里,互相影响。
4.2 链式回调
CompletableFuture.supplyAsync(() -> 查询订单(), taskExecutor)
.thenApply(order -> 计算订单金额(order)) // 有入参有返回值
.thenAccept(amount -> 保存结果(amount)) // 有入参无返回值
.thenRun(() -> 打日志()); // 无入参无返回值
thenApply:拿到上一步结果,处理后返回新结果thenAccept:拿到上一步结果,做点事但不返回thenRun:上一步完成后执行,不关心结果
4.3 任务组合
// 两个任务串行:第二个依赖第一个的结果
CompletableFuture<String> f = CompletableFuture
.supplyAsync(() -> 查用户(userId), taskExecutor)
.thenCompose(user -> CompletableFuture.supplyAsync(() -> 查订单(user.getId()), taskExecutor));
// 两个任务并行:都完成后合并结果
CompletableFuture<String> f2 = CompletableFuture
.supplyAsync(() -> 查用户(userId), taskExecutor)
.thenCombine(
CompletableFuture.supplyAsync(() -> 查积分(userId), taskExecutor),
(user, score) -> user.getName() + ",积分:" + score
);
thenCompose 用于有依赖关系的任务(前一个结果作为后一个入参),thenCombine 用于两个独立任务并行执行后合并结果。
4.4 异常处理
CompletableFuture<String> f = CompletableFuture
.supplyAsync(() -> 可能抛异常的任务(), taskExecutor)
.exceptionally(e -> {
// 只有发生异常时才走这里,返回兜底值
return "兜底结果";
});
exceptionally:异常时返回兜底值,把异常"吃掉"handle:无论正常还是异常都会执行,能同时拿到结果和异常
CompletableFuture<String> f2 = CompletableFuture
.supplyAsync(() -> 可能抛异常的任务(), taskExecutor)
.handle((result, ex) -> {
if (ex != null) {
return "异常了: " + ex.getMessage();
}
return result;
});
4.5 等待多个任务完成
// 等所有任务都完成
CompletableFuture.allOf(f1, f2, f3).join();
// 等任意一个完成即可
CompletableFuture.anyOf(f1, f2, f3).join();
allOf 适合"需要所有子任务结果再往下走"的场景,anyOf 适合"只要有一个成功就行"的场景。
一个典型的批量处理例子:
List<CompletableFuture<Void>> futures = list.stream()
.map(item -> CompletableFuture.runAsync(() -> 处理单个(item), taskExecutor))
.collect(Collectors.toList());
// 等所有任务跑完
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
这样就能把一个大集合拆成多个任务丢给线程池并行处理,最后统一等待结果,非常适合批量导出、批量调接口这类场景。
5. @Async 的几个坑
- 不能被同类内部方法调用:
@Async依赖 Spring AOP 代理,同类里直接this.xxx()调用是走不到代理的,异步会失效,必须通过注入的 Bean 调用。 - 异步方法所在类必须被 Spring 管理:自己
new AsyncService()出来的对象是不生效的。 - 方法必须是 public,否则代理拦截不到。
五、拒绝策略
当线程池和队列都满了之后,新提交的任务需要一种处理方式。JDK 内置了四种拒绝策略:
| 策略 | 说明 |
|---|---|
| AbortPolicy | 默认策略,直接抛出 RejectedExecutionException |
| CallerRunsPolicy | 由调用线程自己执行这个任务,起到一定削峰作用 |
| DiscardPolicy | 直接丢弃任务,不抛异常 |
| DiscardOldestPolicy | 丢弃队列中最老的任务,再尝试重新提交 |
生产环境一般推荐 CallerRunsPolicy,它不会丢弃任务,只是让提交任务的那个线程自己跑,相当于变相减缓了生产速度,给线程池一个缓冲。
六、几个常见的坑
1. 别用 Executors 工具类创建线程池
// 反例,阿里开发手册明确禁止
ExecutorService pool = Executors.newFixedThreadPool(10);
ExecutorService cachedPool = Executors.newCachedThreadPool();
newFixedThreadPool 和 newSingleThreadExecutor 内部用的是无界队列 LinkedBlockingQueue,任务堆积起来会把内存撑爆;newCachedThreadPool 则允许创建无限数量的线程,同样有 OOM 风险。正确做法是用 ThreadPoolExecutor(或 SpringBoot 的 ThreadPoolTaskExecutor)手动指定参数。
2. 队列容量要设上限
队列容量不设上限,就等同于无界队列,核心线程忙不过来时任务会无限堆积,最终还是 OOM。所以 queueCapacity 一定要给一个合理的有限值。
3. 合理设置线程数
线程数不是越大越好,要根据任务类型来定:
int cpuCount = Runtime.getRuntime().availableProcessors();
// CPU 密集型:主要在做计算,线程数 = CPU 核数 + 1
int coreSize = cpuCount + 1;
// IO 密集型:大量时间在等待 IO,线程数可以适当调大
int ioCoreSize = cpuCount * 2;
实际项目里还需要根据压测结果不断调整,上面只是经验起点。
4. 记得优雅关闭线程池
应用停止时要关闭线程池,让已提交的任务跑完再退出,避免任务丢了一半:
executor.shutdown(); // 不再接收新任务,等待已提交任务执行完
七、总结
线程池在 SpringBoot 里其实就三步:定义 Bean 配好参数 → 加 @EnableAsync → 方法上标 @Async。真正需要注意的是参数怎么配、拒绝策略怎么选,以及别踩 Executors 和同类方法调用这些坑。
核心记住三句话:
- 线程池的参数没有银弹,核心线程数、队列容量、拒绝策略要根据业务和压测来定;
- 手动创建、设上限、优雅关闭,就能避开绝大多数 OOM 和任务丢失的问题;
- 遇到多任务并发/批量处理,优先用
CompletableFuture做链式编排和统一等待,代码清晰又好维护。
以上是我的一点实践总结,欢迎交流指正。

浙公网安备 33010602011771号