做后端开发久了,你会发现Spring Boot项目里最容易被写砸的不是业务逻辑,而是多线程。带@Async注解的异步任务、批量处理、并行查询聚合,这些场景几乎每个项目都会碰到。我见过不少团队,代码能跑,但线程池乱配、任务莫名丢失、进程关闭时卡死几分钟,出了问题全靠重启。这篇文章就把Spring Boot多线程的优雅使用案例代码完整串一遍,从线程池配置、异步任务、结果聚合到优雅关闭一步不落,适合刚接触异步编程的新手,也适合想系统性梳理线程池最佳实践的开发者。
1. 多线程场景拆解:先搞清楚什么时候该上多线程
1.1 典型场景:异步任务、并行查询、任务编排
后端项目里多线程主要解决三类问题,先分清楚再动手写代码,不容易跑偏。
第一类是异步化。比如用户注册后要发邮件、发短信、生成周报,这些逻辑如果同步写在注册接口里,响应时间直接被拖到几秒。异步化之后,主线程拿到请求立刻把任务交到线程池,接口几十毫秒就返回。相比引入消息队列,如果只是内部小任务、不需要跨服务消费,直接用线程池反而更轻,少维护一个中间件。
第二类是并行加速。一个详情接口往往要查用户信息、订单列表、优惠券、库存,单次查询可能都只要20ms,但串行加起来就是100多ms。把这几路查询同时发出去,用CompletableFuture等全部结果回来再聚合,接口耗时能降到原来的一半甚至三分之一。这类优化立竿见影,也是多线程在CRUD系统里最值钱的应用。
第三类是任务编排。有些流程带依赖关系,比如先查库存,再扣减,扣减成功后异步推送通知;或者几个独立校验都通过才走下一步。这种场景用CompletableFuture的thenApply、thenCombine、allOf写编排,比手写线程加CountDownLatch清爽太多,代码可读性完全不在一个量级。
1.2 单线程为什么不行:阻塞模型的问题
很多人纠结“我这个场景到底要不要上多线程”,我的判断标准很朴素:任务会不会阻塞调用方、任务之间是否有依赖、单个任务是否达到几十毫秒以上。
同步阻塞的本质是容器线程被白白占着。以常见的Servlet容器为例,默认只有200个左右的请求线程,每个请求占一个线程。如果线程里在等IO、查数据库、调远程接口,它就一直在原地等。这时候把耗时子任务扔进独立线程池,请求线程能快速释放回容器,整体吞吐量自然上去。这就是为什么说多线程在IO密集型场景里收益最大。
但多线程不是万能药。如果服务本身就是CPU密集型,CPU核数已经吃满,再开一堆线程只会增加上下文切换开销,反而变慢。之前排查一个批量处理接口时,调用方把线程数开到32,结果机器负载拉满,单接口耗时从2秒涨到4秒。线程池参数不是拍脑袋定的,核心逻辑下面详细讲。
2. 方案选型与核心原理:@Async、CompletableFuture、自定义线程池怎么选
2.1 三套实现方式的优缺点对比
Spring Boot里实现多线程,最常见的有三条路:直接new Thread、用原生ExecutorService、用Spring封装的@Async + ThreadPoolTaskExecutor。
直接new Thread是最典型的反面教材。每来一个任务就新建线程,高并发下线程数量没有上限,内存和句柄整体被耗光,线程也不能复用,基本属于不可维护的写法。
原生ExecutorService比直接new Thread好一些,但任务提交、结果获取、异常处理全要手写,代码量不小,生命周期还得自己管理,稍不注意就泄漏。Spring Boot项目里我更推荐用@Async + 自定义线程池,理由很直接:配置是声明式的,线程池生命周期交给Spring容器管理,Bean销毁时自动回收;业务代码零侵入,一个注解搞定异步;方法返回CompletableFuture后,还能和异步编排无缝结合。
三种方式的取舍,简单说就是:
- @Async适合“投递后不管”的异步任务,比如日志、通知、报表生成;
- CompletableFuture适合“需要拿结果再合并”的并行查询、编排任务;
- 两者可以配合,让@Async方法直接返回CompletableFuture,把异步执行和结果编排合并成一条代码路径。
2.2 自定义线程池参数设计:核心线程数、队列、拒绝策略
这是多线程配置里最考功力的地方。ThreadPoolTaskExecutor底层包装了JDK的ThreadPoolExecutor,参数含义一致,但Spring帮你做了Bean管理和生命周期处理。
线程池参数有四个关键项:corePoolSize、maxPoolSize、queueCapacity、rejectedExecutionHandler。它们之间的关系我习惯用一个餐厅比喻:核心线程是固定编制的大厨,任务多了大厨忙不过来,新来的点单先排到等待区,也就是队列;等待区满了,餐厅才开始招临时工,对应最大线程数;高峰期过了临时工被辞退,对应keepAliveTime。如果等待区满了、临时工也满编,再来客人就只能拒单。
所以参数设计的核心问题是:任务到底应该排队等,还是直接开新线程处理。对IO密集型任务,线程数可以设到CPU核数的2倍左右;对CPU密集型任务,建议设成CPU核数加1。队列容量决定了系统的缓冲能力,我强烈建议用有界队列,配合一个稳妥的拒绝策略,不要图省事设置成无界队列。无界队列意味着任务可以无限排队,内存迟早被撑爆,而且最大线程数形同虚设,线程池永远不会触发扩容。
实际操作中,我习惯用有界队列加CallerRunsPolicy拒绝策略。CallerRunsPolicy的含义是线程池满了之后,任务不丢弃,而是回到调用方线程同步执行。好处是不丢任务,坏处是调用方线程会被阻塞。但对大多数业务接口来说,“宁可慢一点,不要静默丢失”比“快速失败”更合理。我踩过快速失败的亏:线程池满后抛RejectedExecutionException,前端直接报错,用户刷新页面重试,结果又把线程池塞满了,恶性循环。
2.3 为什么Spring容器管理线程池更优雅
线程池自己new、自己关闭,很容易出现资源泄漏和关闭不及时的问题。Spring管理的ThreadPoolTaskExecutor可以声明waitForTasksToCompleteOnShutdown和awaitTerminationSeconds,在容器关闭时等待所有任务完成后释放资源。
这一点很关键。很多同学没配这两个参数,生产环境每次发布版本,正在跑的任务直接被中断,数据处于中间态。配了之后,Spring在销毁Bean时会先停止接收新任务,然后等待存量任务执行完毕,超过等待时间再强制清理。
另外,给线程池起名字是很多人忽略的习惯。ThreadPoolTaskExecutor支持setThreadNamePrefix,我建议每个业务线程池独立命名,比如report-pool、push-pool。排查线上问题时,导出线程栈一眼就能定位是哪个线程池卡住。之前接手过一个未命名的线程池,生产日志里全是pool-XX-thread-,对着代码找了好半天才确定是哪个模块,后来补上命名,排查效率提升非常明显。
3. 完整案例实战:从线程池配置到异步结果聚合
3.1 线程池配置类:一套可以直接抄作业的写法
直接给配置代码,这套写法在多个生产项目里验证过,改改参数就能用。
@Configuration @EnableAsync public class AsyncThreadPoolConfig { @Bean("bizTaskExecutor") public ThreadPoolTaskExecutor bizTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); // 核心线程数:IO密集型按 CPU 核数 * 2 起步 executor.setCorePoolSize(Runtime.getRuntime().availableProcessors() * 2); // 最大线程数:高峰期允许的临时扩容量 executor.setMaxPoolSize(Runtime.getRuntime().availableProcessors() * 4); // 有界队列容量 executor.setQueueCapacity(200); // 非核心线程空闲存活时间 executor.setKeepAliveSeconds(60); // 线程名前缀:排查问题必备 executor.setThreadNamePrefix("biz-task-"); // 拒绝策略:忙不过来的任务由调用线程兜底执行 executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); // 优雅关闭:等待所有任务执行完毕 executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(30); executor.initialize(); return executor; } }几个细节说明一下。
@EnableAsync必须放在配置类上,它是启动@Async注解功能的开关,很多异步失效问题就是因为漏了它。
Runtime.getRuntime().availableProcessors()获取的是JVM可见的CPU核数。注意如果服务运行在容器环境里,要用容器配额实际限制的核数,否则拿到宿主机核数,配置直接离谱。
setAwaitTerminationSeconds(30)表示容器关闭时最多等30秒,超时后未完成的任务会被中断。这个值不建议设太长,否则发布流程会被一个慢任务拖到天荒地老。
3.2 异步服务:注解加返回值,把异步和编排结合起来
配置好线程池,业务服务就好写了。以一个“订单报表聚合”场景为例:根据订单ID并行查询用户信息、订单明细、物流状态、风控记录,最后合并返回。
@Service public class OrderReportService { private final UserClient userClient; private final OrderClient orderClient; private final LogisticsClient logisticsClient; private final RiskClient riskClient; public OrderReportService(UserClient userClient, OrderClient orderClient, LogisticsClient logisticsClient, RiskClient riskClient) { this.userClient = userClient; this.orderClient = orderClient; this.logisticsClient = logisticsClient; this.riskClient = riskClient; } @Async("bizTaskExecutor") public CompletableFuture<UserInfo> getUserInfo(Long orderId) { return CompletableFuture.completedFuture(userClient.query(orderId)); } @Async("bizTaskExecutor") public CompletableFuture<OrderDetail> getOrderDetail(Long orderId) { return CompletableFuture.completedFuture(orderClient.query(orderId)); } @Async("bizTaskExecutor") public CompletableFuture<LogisticsInfo> getLogisticsInfo(Long orderId) { return CompletableFuture.completedFuture(logisticsClient.query(orderId)); } @Async("bizTaskExecutor") public CompletableFuture<RiskInfo> getRiskInfo(Long orderId) { return CompletableFuture.completedFuture(riskClient.query(orderId)); } }方法返回CompletableFuture是关键。如果方法返回void,Spring会让异步方法在独立线程执行,但调用方完全感知不到执行结果;返回CompletableFuture后,调用方才能拿到Future做编排和聚合。这种写法的好处是,异步逻辑只写一份,既可以被“投递后不管”的场景调用,也可以被“需要聚合结果”的场景调用。
还有个细节容易踩坑:不要在@Async方法内部再用CompletableFuture.supplyAsync提交同一个线程池,那会造成双重线程调度,完全没必要。让方法体本身在异步线程里执行,返回completedFuture即可。
3.3 控制器聚合:allOf加join,四路并发变一路返回
接下来写聚合逻辑,假设ReportDTO使用了Lombok的@Builder注解。
@Service public class ReportAggregateService { private final OrderReportService orderReportService; public ReportAggregateService(OrderReportService orderReportService) { this.orderReportService = orderReportService; } public ReportDTO getReport(Long orderId) { CompletableFuture<UserInfo> userFuture = orderReportService.getUserInfo(orderId); CompletableFuture<OrderDetail> orderFuture = orderReportService.getOrderDetail(orderId); CompletableFuture<LogisticsInfo> logisticsFuture = orderReportService.getLogisticsInfo(orderId); CompletableFuture<RiskInfo> riskFuture = orderReportService.getRiskInfo(orderId); // 等待所有异步任务完成 CompletableFuture.allOf(userFuture, orderFuture, logisticsFuture, riskFuture).join(); // 从各个future中取出结果 UserInfo userInfo = userFuture.join(); OrderDetail orderDetail = orderFuture.join(); LogisticsInfo logisticsInfo = logisticsFuture.join(); RiskInfo riskInfo = riskFuture.join(); return ReportDTO.builder() .userInfo(userInfo) .orderDetail(orderDetail) .logisticsInfo(logisticsInfo) .riskInfo(riskInfo) .build(); } }allOf的机制是,等到所有给定的Future都完成后再返回,内部不关心每个Future的执行顺序。join方法会阻塞直到对应Future有结果,但既然前面已经调用了allOf等待全部完成,这里的join基本是立即返回,不会再有额外等待。
这段代码的时间复杂度从四路串行的a+b+c+d,变成了max(a,b,c,d),在数据源响应时间相近时提速最明显。实测一组数据,串行耗时150ms左右,并行改造后稳定在45ms左右,接口整体响应时间直接砍掉三分之二。这种收益不需要额外引入中间件,纯代码层就能拿到。
3.4 事务与异步结合的正确姿势
专门提一下@Async和@Transactional同时出现时的坑。
Spring的事务是绑定在线程上的,事务管理器默认用ThreadLocal保存连接信息。@Async方法会把任务提交给线程池里的另一条线程执行,这时候事务注解实际上是不生效的。更准确地说,开启事务和提交事务发生在不同线程,Spring根本没办法保证一致性。
正确做法有两种。第一种,在调用方方法上开启事务,异步方法内部只做数据准备或者查询,不涉及跨线程的写事务。第二种,把需要事务的逻辑封装在另一个同步方法里,异步方法内部调用这个同步方法,事务在同步方法上生效。
我踩过一次实打实的坑:给一个异步导入Excel的方法加了@Transactional,结果数据写到一半报错,回滚没生效,留下了半成品数据。排查后才发现事务在线程切换后根本没开启。这个点新手极其容易忽略,写代码时一定要先把事务边界想清楚。
4. 优雅关闭线程池:防止发布时任务丢失和进程卡死
4.1 为什么需要优雅关闭
生产环境的服务每隔一两周就要发一次版本。如果线程池没有优雅关闭,发布时Spring容器销毁线程池Bean,默认行为是直接中断线程。正在执行的任务被打断,队列里排队的任务全部丢弃。对用户来说,就是导出报表没生成、推送通知悄悄丢了。
反过来,如果只等任务不做限制,任务队列里排着上千个任务,每个任务几十秒,关闭过程可能持续几十分钟。发布卡在重启阶段,容器环境的存活探针都过不了。优雅关闭的核心就是:先停止接收新任务,再等待存量任务完成,同时设置一个最大等待时长,超时后强制结束。
4.2 shutdown、awaitTermination的正确用法
很多人记得ExecutorService有shutdown()方法,但shutdown只是“拒绝新任务提交”,存量任务依然会继续跑,它不会阻塞调用线程,立即返回。要真正等待任务完成,还得配awaitTermination。
public void shutdownThreadPool(ExecutorService executor) { executor.shutdown(); try { // 等待所有任务完成,最多等30秒 if (!executor.awaitTermination(30, TimeUnit.SECONDS)) { // 超时后强制关闭,并给正在响应中断的任务收尾时间 executor.shutdownNow(); if (!executor.awaitTermination(10, TimeUnit.SECONDS)) { System.err.println("线程池未能完全停止"); } } } catch (InterruptedException e) { executor.shutdownNow(); Thread.currentThread().interrupt(); } }如果在Spring Boot项目里用的是ThreadPoolTaskExecutor,这套逻辑不用手写,初始化时设置setWaitForTasksToCompleteOnShutdown(true)和setAwaitTerminationSeconds(30),Spring容器关闭时会自动按这个策略执行。只有当你手动创建了ExecutorService而不是Spring管理的Bean时,才需要手写shutdown逻辑。
4.3 Spring Boot优雅停机配置
线程池自己关闭还不够,整个服务还要支持优雅停机。Spring Boot在较新版本里内置了这个能力,在application.yml里配置:
server: shutdown: graceful spring: lifecycle: timeout-per-shutdown-phase: 30s开启优雅停机后,应用收到关闭信号,会先停止接收新请求,然后等待在途请求处理完成,再进入容器销毁阶段。和线程池的awaitTermination配合起来,才是一条完整的关闭链路。我在测试环境模拟过:向一个慢接口发请求,同时触发关闭流程。没有优雅停机时连接直接断开、任务中断;开启优雅停机后,接口能正常返回,日志里还能看到完整的任务收尾记录。多花一次配置的时间,少写一个投诉工单。
5. 常见问题与排查实录:多线程翻车现场汇总
5.1 @Async不生效,究竟哪里出了问题
这是被问得最多的问题。现象是方法上加了@Async,但调用后日志显示任务还在请求线程里串行执行。常见原因和排查顺序如下。
第一,没有加@EnableAsync。缺了这个注解,所有@Async都是摆设。这必须是第一个排查点。
第二,同类内部调用。在同一个类里写方法A调用方法B,B加了@Async,但这里的this调用不会走Spring代理,异步自然失效。解决办法是把被调方法放到另一个Bean里,或者注入自身的代理对象。
第三,方法不是public。Spring的代理机制默认只对public方法生效,private方法上的@Async不会走代理逻辑。
第四,线程池Bean名字没对上。如果@Async注解指定了线程池名称,比如@Async("bizTaskExecutor"),但Spring容器里没有这个名字的Bean,任务就会落到默认执行器,通常又会变成串行执行。
5.2 线程池耗尽:任务堆积、接口超时怎么办
线程池耗尽的表现很典型:任务堆积、队列满、拒绝策略开始生效、接口变慢甚至报错。排查思路建议分三步走。
第一步看监控。线程池至少要暴露这几个指标:核心线程数、活跃线程数、队列长度、拒绝次数。ThreadPoolTaskExecutor自带getActiveCount、getQueue()等方法,配合一个定时任务采集到监控系统即可。
第二步看业务。是突发流量导致,还是某个上游在无限提交任务?如果是后者,优先在入口加限流和并发信号量控制,别让线程池独自扛压力。
第三步调参数。不要一上来就把线程数调大,先看CPU和内存水位。线程池从来不是越大越好,超过合理范围后,上下文切换开销会吃掉性能收益。
5.3 线程上下文丢失:ThreadLocal、TraceId传不过去
多线程并行后,最常见的问题就是ThreadLocal里存的用户信息、TraceId在异步线程里拿不到。ThreadLocal本身是线程私有的,子线程不会自动继承父线程的值。
解决办法有几种。如果项目里大量用@Async,可以在任务提交入口做一次包装,用TaskDecorator把主线程的上下文复制到异步线程:
@Bean("bizTaskExecutor") public ThreadPoolTaskExecutor bizTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setTaskDecorator(task -> { Map<String, String> context = TraceContext.get(); return () -> { TraceContext.set(context); try { task.run(); } finally { TraceContext.clear(); } }; }); // 其他参数配置省略 return executor; }如果项目里的扩展接口支持线程池上下文传递,也可以直接引入第三方并发工具包,用包装过的线程池替代原生线程池,成本更低。这个坑在链路追踪系统接入后尤其明显,异步线程里没有TraceId,排查问题根本串不起调用链。
5.4 异常处理:异步任务抛错不能悄无声息
同步方法抛异常,调用方立刻感知。但@Async的void方法抛异常,默认只在异步线程里打印日志,调用方感知不到。任务失败不可怕,可怕的是失败了没人知道。
解决办法是自定义异步异常处理器:
@Configuration public class AsyncExceptionConfig implements AsyncConfigurer { @Override public Executor getAsyncExecutor() { return null; } @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return (throwable, method, params) -> { log.error("异步任务执行异常,方法: {}", method.getName(), throwable); // 在这里接告警,比如企业IM、邮件、监控平台 }; } }如果是返回CompletableFuture的异步方法,异常不会触发这个Handler,而是封装在Future里,调用join时会抛出CompletionException。所以用CompletableFuture的场景,聚合处一定要做exceptionally或者catch处理。我通常还会在业务代码里加一个超时兜底,防止某个下游服务挂掉导致整个聚合卡死。
5.5 监控与巡检:给线程池装上摄像头
线程池不是配好就完事的,它需要持续观察。我把几个核心监控指标整理成一张速查表:
| 指标 | 获取方式 | 关注点 |
|---|---|---|
| 活跃线程数 | getActiveCount() | 是否长期接近核心线程数 |
| 队列长度 | getQueue().size() | 是否持续增长 |
| 当前池大小 | getPoolSize() | 是否频繁触发扩容 |
| 已完成任务数 | getCompletedTaskCount() | 单位时间吞吐量变化 |
| 拒绝次数 | RejectedExecutionHandler自定义计数 | 拒绝策略是否频繁触发 |
配合告警规则:队列长度超过阈值、拒绝次数大于0、活跃线程数持续满载,都值得立刻查看。我因为漏配监控,让一个被上游频繁调用的转换任务默默积累了十几万个队列任务,等到发现时接口已经雪崩。多花半小时把指标接好,后期能省下大量排查时间。
做多线程这几年,我最大的感受是:Spring Boot把并发编程的上手门槛降得很低,但把“用对”的难度留给了每个人。注解一行就能启动异步,可是线程池参数、事务边界、上下文传递、优雅关闭,每一个环节都藏着细节。如果让我给一个最简单实用的起步建议,那就是:先用一个统一命名的线程池Bean,配上文中那套有界队列加CallerRunsPolicy配置,异步方法一律返回CompletableFuture,关闭参数一次性配齐。这套组合在绝大多数业务场景下不会出大问题。后面再根据监控数据慢慢调整参数,先把跑通的链路稳定下来,多线程这件事就算入门了。希望这份案例代码对你的项目有实际帮助,也欢迎分享你踩过的多线程坑。