SpringBoot中使用线程池

SpringBoot中使用线程池

最近在做一个批量导出报表的功能,用户一点按钮,后台要处理几千条数据、还要调第三方接口生成图表。如果都在主线程里串行跑,接口要等十几秒才返回,前端早就超时了。于是想到了用线程池异步处理。本文记录一下我在 SpringBoot 项目里是怎么用线程池的,以及踩过的一些坑。

一、为什么要用线程池

先看一个反面例子,很多人一开始是这么写的:

public void handle() {
    for (int i = 0; i < 100; i++) {
        new Thread(() -> {
            // 业务逻辑
        }).start();
    }
}

这样每来一个任务就 new 一个线程,看起来能跑,但问题很大:

  1. 创建销毁开销大:线程的创建和销毁是重量级操作,频繁创建会浪费大量系统资源。
  2. 线程数不可控:并发高的时候可能瞬间创建成百上千个线程,直接把系统拖垮。
  3. 难以统一管理:线程散落在各处,不好监控,也不好统一调优。

线程池正好解决这三个问题:复用线程、控制数量、统一管理。说白了,线程池就是"一个装着固定数量工人的工厂",任务来了排队等工人干,干完工人不消失,接着干下一个。

二、线程池的核心参数

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 拒绝策略,队列满且线程数达到上限时的处理方式

这些参数理解起来有点抽象,配合下面的执行流程就清楚了。

三、线程池的执行流程

当一个任务被提交到线程池时,大致经历这样几个判断:

  1. 当前线程数 < corePoolSize,直接创建核心线程执行;
  2. 当前线程数 >= corePoolSize,且任务队列没满,任务进入队列排队
  3. 队列也满了,但线程数 < maximumPoolSize,创建非核心线程执行;
  4. 队列满了,线程数也达到 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);
}

返回 FutureCompletableFuture 都可以拿到异步任务的结果,需要时再 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();

newFixedThreadPoolnewSingleThreadExecutor 内部用的是无界队列 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 做链式编排和统一等待,代码清晰又好维护。

以上是我的一点实践总结,欢迎交流指正。

posted @ 2026-09-03 17:38  康帝  阅读(7)  评论(0)    收藏  举报