线程池可能是 SpringBoot 项目里最容易被低估的技术点。很多人写接口的时候,下意识地 new 一个 Thread 就把任务丢出去,结果高并发一来,系统直接卡死;也有一些人把 Executors.newFixedThreadPool() 一贴就完事,队列无上限,内存被拖垮才反应过来。我在实际项目里见过太多次这种事故,所以想把这些经验完整地写下来——从线程池的工作原理、核心参数怎么定,到 SpringBoot 里的三种落地方式,再到参数估算和线上排查,一次讲透。
这篇内容面向谁?如果你是刚接触 SpringBoot 的初学者,能帮你建立线程池的完整认知,学会在项目里正确使用;如果你已经写过一些线程池代码,这篇文章里的压测思路、监控手段、避坑清单,大概率也能帮你解决一些线上隐患。全文不涉及复杂的源码分析,把“为什么这么做”讲清楚,并提供可以直接参考的配置模板,看完就能在项目里落地。
1. 为什么 SpringBoot 项目里一定要用线程池
1.1 从一次接口超时事故说起
我先说一个真实经历。有个订单查询接口,逻辑本身不重,但调用方为了拿全量数据,会在一个请求里串行调用商品、库存、营销、会员四个服务,四个服务平均耗时 150ms、200ms、300ms、250ms,加起来差不多 900ms。接口的 TPS 一高,Tomcat 默认 200 个线程很快被打满,新请求全部排队,整体响应时间从 1 秒涨到 3 秒、5 秒,最后服务直接雪崩。
当时的第一反应是加机器,但加了机器之后成本翻倍,问题却没有根治。后来把四个远程调用改成并行,用一个线程池一次性提交四个任务,总耗时就变成 300ms 左右(取最慢的那个任务)。同样的需求,没增加一台服务器,QPS 容量直接翻了两三倍。这个案例很典型:线程池解决的不是“能不能创建线程”的问题,而是“怎么用有限的资源扛住更多并发”的问题。
顺着这个案例说,很多人第一反应是“我有 SpringBoot 自带的 Tomcat 线程池,为什么还要自己搞一个”?这个理解要纠正:Tomcat 的线程池是处理 HTTP 请求的,一个请求占用一个 Tomcat 线程,如果业务逻辑本身要等很久(比如同步调用下游服务),这个 Tomcat 线程就一直被占着。自己再建线程池,相当于把“请求线程”和“业务执行线程”解耦,请求线程可以快速返回或者继续做别的,真正耗时的部分丢到业务线程池里去跑,这样吞吐量才能上来。
有一次我把这个思路讲给团队里的新人听,他说“那不就是把线程一会儿放这里一会儿放那里吗?”,其实这里的关键不是“挪线程”,而是“让有限的线程尽可能多地处理任务”,让本来就稀缺的 Web 容器线程不再被长时间阻塞。
1.2 线程池的真实成本与收益
很多人喜欢无脑“手动创建新线程”,线程虽然轻量,但创建和销毁并不是零成本:每次 new Thread 都要经历系统调用、内核分配资源、JVM 启动栈等一系列开销。假设一个任务只跑 10ms,但线程创建就要花 1~2ms,在高频调用下这部分开销会被放大,更麻烦的是并发线程一多,线程上下文切换会直接把 CPU 拖垮。
再算一笔账。假设系统同时有 500 个线程在跑,每个线程都在等待 IO,线程上下文切换每秒可能发生几百次到上千次,CPU 有相当一部分时间花在保存和恢复现场上,真正干活的占比反而不高。线程池的核心思想就一句话:把线程创建和销毁的成本前置,用固定数量的一组线程循环处理任务。池子里有活儿就干活,没活儿就待命;任务多了就排队,排队还不行就触发拒绝策略。
把线程池理解成“银行柜台”就很形象:柜台(核心线程)固定开几个,等候区(队列)能容纳多少人,人满之后要么加开窗口(扩到最大线程),要么直接告诉客户“今天不接待了”(拒绝策略)。SpringBoot 作为一个 Web 项目,天然适合用这种模式来承载异步任务、批量数据处理和并发调用聚合,这也是为什么线程池几乎成了 SpringBoot 生产项目的标配。
2. 线程池核心参数拆解:七个开关一个都不能少
2.1 核心线程数、最大线程数与存活时间如何配合
ThreadPoolExecutor 的构造函数里,最重要的参数是这七个:corePoolSize、maximumPoolSize、keepAliveTime、unit、workQueue、threadFactory、handler。逐个说清楚,后面配置才能不发怵。
先看 corePoolSize,它是最低保证的线程数量。任务提交时,如果当前线程数小于 corePoolSize,线程池会直接创建新线程执行任务,哪怕有空闲线程也照样创建。这是很多人容易忽略的一点:核心线程不是为了“复用已有线程”而生的,而是为了“保持足够响应速度”而存在的。
再看 maximumPoolSize,它是线程数的上限。当任务来了,核心线程全忙,队列也塞满了,线程池才会把线程数往 maximumPoolSize 往上扩。注意这个触发顺序:先核心,再队列,再到最大线程数。所以“最大线程数”不是越大越好,扩出来的线程同样会竞争 CPU 和内存,扩得太多反而会让核心任务也一起变慢。
keepAliveTime 管理的是非核心线程的存活时间。线程数超过 corePoolSize 之后,多出来的线程如果空闲了 keepAliveTime 时间,就会被回收。这里有个实用经验:如果任务的到达率抖动很大,比如每隔几分钟就来一波峰值,keepAliveTime 可以适当调大(比如 120 秒),避免峰值过后线程反复创建销毁;如果任务到达率很均匀,存活时间反而不用太长,30~60 秒就够。
很多刚接触 SpringBoot 的同学会把 Executors 的几个快捷方法当作“标准答案”,我建议尽量远离。newFixedThreadPool 使用无界队列,newCachedThreadPool 使用 SynchronousQueue 且 max 线程数是 Integer.MAX_VALUE,newScheduledThreadPool 同样有队列积压风险。这些方法写起来爽,但真正线上运行时,问题往往不在任务本身,而在这些“看似方便”的默认值上。
2.2 阻塞队列怎么选:三种队列对比
队列是线程池的“缓冲带”,直接决定任务排队的方式。最常用的三种:
无界队列 LinkedBlockingQueue:默认容量是 Integer.MAX_VALUE,任务无限制往队列里堆。表面上永远不会拒绝任务,但实际上非常危险。队列里积压几万个任务时,内存占用飙升,任务延迟越来越大,而且因为队列永远不会满,线程池永远只使用 corePoolSize 个线程,maximumPoolSize 完全失效。我不建议在核心业务上用无界队列,宁可让请求快速失败,也别让系统无限制地“扛着”。
有界队列 ArrayBlockingQueue:需要指定容量,比如 1000。它让系统有了明确的积压上限,超过上限后才会触发扩容或者拒绝策略,是最容易把控的选择。我日常项目里用得最多的就是它,容量可以从几百到几千,根据接口的峰值速率去换算。
同步移交队列 SynchronousQueue:它不存储任何任务,每个任务都必须立刻交给一个线程执行,没人接就直接拒绝。这个队列配合较大的 maximumPoolSize 使用,适合“任务执行很迅速、不依赖排队缓冲”的场景,比如并行聚合查询时,几个子任务同时提交,核心线程不够就能立刻扩容,而不是让子任务在队列里排队等着。
用生活化的对比来说,LinkedBlockingQueue 像容量无限的候诊室,病人再怎么多也不会拒收,但医生永远只有固定几个;ArrayBlockingQueue 像有明确座位的候诊室,坐满了就只能往医生那儿加派或者劝退;SynchronousQueue 更像急诊台,来了病人必须马上有人接,没人接就拒绝。SpringBoot 项目里到底用哪个,核心看你对“延迟”和“失败”的容忍度。
2.3 拒绝策略选错,线上服务直接雪崩
当队列满了、线程数也到了 maximumPoolSize,再来新任务就得走拒绝策略。JDK 内置了四种:
- AbortPolicy:直接抛 RejectedExecutionException,默认策略。适合你能接受“任务失败”的场景,但要注意调用方必须捕获异常,否则请求会以 500 的形式返回,而且异常日志如果不打全,线上定位很难。
- CallerRunsPolicy:不在线程池里执行,而是由提交任务的线程自己执行。这个策略很巧妙,它相当于一种“自然限流”:如果业务线程比较忙,提交线程被占住之后,新请求的 TPS 会自动降下来。我比较推荐用于关键业务。
- DiscardPolicy:静默丢弃,不抛异常。最危险,因为业务上根本感知不到任务被丢,排查问题时往往一脸懵,数据还会出现“莫名缺失”。
- DiscardOldestPolicy:丢弃队列里最老的任务,然后重新提交当前任务。适合任务本身有优先级的场景,但同样要谨慎,因为被丢掉的可能是未完成的关键任务。
我在项目里的经验是:能用 CallerRunsPolicy 就尽量用,让调用方自己去承担压力,服务不会“猝死”;如果确实要快速失败,再考虑 AbortPolicy,但一定要把异常信息记录完整,方便 trace。拒绝策略一定要在配置类上写好注释,说清楚“为什么用这个策略、任务丢了能不能接受”,不然三个月后你自己都忘了当初为什么这么选。
3. SpringBoot 中线程池的落地方式与配置实战
3.1 方式一:手动创建 ThreadPoolExecutor(最可控)
直接在 Spring 容器里注册一个 Bean,是最直观的方式。示例配置类:
@Configuration public class ThreadPoolConfig { @Bean("bizThreadPool") public ThreadPoolExecutor bizThreadPool() { return new ThreadPoolExecutor( 8, 16, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1000), new ThreadFactoryBuilder().setNameFormat("biz-pool-%d").build(), new ThreadPoolExecutor.CallerRunsPolicy() ); } }这里有几个细节要展开。线程名必须用有意义的 prefix,不要用默认的 pool-1-thread-1,否则线上排查问题时,日志里全是 pool-1-thread-X,根本无法定位是哪个线程池在作怪。推荐用 Guava 的 ThreadFactoryBuilder,或者直接用自定义 ThreadFactory,把线程名、是否 daemon 都设置清楚。守护线程这块也要注意:如果线程池里的线程是 daemon,应用关闭时可能会直接被 kill,任务没跑完就没了。
使用的时候注入 Bean 执行任务就好。要注意 ExecutorService.submit() 和 execute() 的区别:submit 返回 Future,能拿到执行结果,也能捕获异常;execute 是 fire-and-forget,异常会被吞掉。如果只是发短信、推送这种不需要结果的任务,用 execute 没关系;如果任务失败要重试或者要感知状态,就用 submit。
在实际团队协作里,建议把线程池配置单独放到一个配置类,并且把每个 Bean 的名字写清楚,比如 bizThreadPool、logThreadPool、reportThreadPool。线上通过线程名的前缀就能瞬间判断任务来自哪个业务,这一步省下的排查时间非常可观。
3.2 方式二:使用 ThreadPoolTaskExecutor(最省心)
Spring 还提供了一个面向 Spring 生态的封装 ThreadPoolTaskExecutor,它内部就是包装了一个 ThreadPoolExecutor,但额外提供了设置线程名前缀、拒绝策略、优雅关闭等能力。在很多 SpringBoot 项目里,大家都用这个类来替代原生的 ThreadPoolExecutor。
示例配置:
@Bean("taskExecutor") public ThreadPoolTaskExecutor taskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(16); executor.setQueueCapacity(1000); executor.setKeepAliveSeconds(60); executor.setThreadNamePrefix("task-pool-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(30); executor.initialize(); return executor; }setWaitForTasksToCompleteOnShutdown(true) 和 setAwaitTerminationSeconds(30) 很关键。应用关闭时,线程池默认会直接中断任务,如果任务正在写库或者发消息,就会产生数据不一致。设置这两个参数后,Spring 会等任务执行完(最多等 30 秒)再关闭容器,这个细节在发布重启时特别容易踩坑,很多人上线后才发现任务丢了。
另一个容易被忽视的差异:ThreadPoolTaskExecutor 的 setQueueCapacity 是直接用有界队列实现的,容量可以设置,不像 Executors 的快捷方法动不动就给你一个无界队列。这也是我更喜欢它的原因之一。如果项目里用了 Spring 的 TaskExecutor 接口,ThreadPoolTaskExecutor 还方便统一管理,比如配合 @Async 使用时,Spring 能找到唯一的 TaskExecutor。
3.3 方式三:@Async + 自定义线程池(最方便)
SpringBoot 里最常见的异步姿势是在方法上标 @Async,让方法丢到线程池里执行。但这里有个普遍误解:不配置线程池,直接用默认的 @Async 是可以,但默认的 SimpleAsyncTaskExecutor 每次都会 new 一个 Thread,根本不算线程池。高并发下用它就是在自爆。
正确做法是两步。第一步,在启动类或配置类上加 @EnableAsync;第二步,定义一个名为 executor 的线程池 Bean(或者用 ThreadPoolTaskExecutor 配置类),Spring 默认会找类型为 TaskExecutor 的 Bean,如果只有一个,就会自动使用它。
建议在配置类里写:
@Bean("asyncExecutor") public ThreadPoolTaskExecutor asyncExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(16); executor.setQueueCapacity(500); executor.setThreadNamePrefix("async-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); executor.initialize(); return executor; }然后在 Service 方法上加 @Async("asyncExecutor"),明确指定线程池。不要不传名字,否则一旦容器里有多个 TaskExecutor,Spring 只能按名称匹配,定位起来很费劲。
用 @Async 有两点必须牢记:异步方法不能写在同类里调用,否则 @Async 不会生效,因为 Spring 的代理机制只在外部调用时生效;异步方法里抛出的异常默认不会直接抛给调用方,所以要自己 try/catch,或者用 AsyncUncaughtExceptionHandler 来统一收集异常日志。
关于 AsyncUncaughtExceptionHandler,我建议单独定义一个类实现它,把所有异步异常统一打到专门的日志文件里,这样线上异步任务失败时,不需要翻主日志就能快速找到问题,排查效率会提高很多。
4. 线程池参数估算与调优逻辑
4.1 从请求耗时数据反推参数
很多同学把线程池参数当玄学,拍脑袋写 8、16、32。实际上,参数可以从两个维度估算:任务的“类型”和任务的“耗时”。
先说任务类型。CPU 密集型任务(比如计算、加解密、图像处理),理论上核心线程数可以设为 CPU 核心数 + 1,因为线程多了也抢不到更多 CPU 时间片,反而增加上下文切换。IO 密集型任务(比如远程调用、数据库查询、文件读写),线程大部分时间在等待,可以设为核心线程数 2 倍甚至更高,具体要看“等待时间 / 计算时间”的比值。
公式可以参考:核心线程数 = CPU 核心数 × (1 + 等待时间 / 计算时间)。举个例子,一个任务计算耗时为 50ms,远程调用等待耗时 250ms,那么比值是 5,四核机器上估算的核心线程数就是 4 × (1 + 5) = 24。当然这个公式不是精确结论,它是起点,实际到底多大要结合压测。如果你所在团队的产品周期比较紧,先用这个公式算一个基准值,再根据压测结果微调,比盲写 8 靠谱得多。
再说队列容量。队列容量跟系统能接受的“最大积压量”有关。如果接口的 TPS 是 100,单个任务平均 200ms,那么每个线程每秒能处理 5 个任务,10 个线程每秒能处理 50 个,多余 50 个就得进队列。如果希望峰值持续 10 秒,队列至少得留 500 的空间。这样算下来,队列容量不要拍脑袋定 10000,而是先算积压时长,再乘速率的倒数。
这个估算有一个容易被忽略的细节:队列容量并不是越大越好,容量越大,任务在队列里等待的时间就越久,业务上如果对“完成时间”有要求,队列太长会让某些任务“超龄”执行,反而影响整体 SLA。所以队列容量要和超时时间挂钩,比如任务 2 秒内必须执行完,那就别让它在队列里排 5 秒。
4.2 压测与监控:让参数有依据
线上参数不能一次定死。我的建议是先用估算值上线,然后做压测,观察几个关键指标:线程池活动线程数、队列积压量、拒绝次数、任务平均耗时、任务超时率。
Spring 里可以用 Actuator 暴露线程池指标,也可以自己写个简单定时任务,每隔几秒打印一次:
log.info("poolSize={}, activeCount={}, queueSize={}, completedTaskCount={}, rejectedCount={}", bizThreadPool.getPoolSize(), bizThreadPool.getActiveCount(), bizThreadPool.getQueue().size(), bizThreadPool.getCompletedTaskCount(), bizThreadPool.getTaskCount() - bizThreadPool.getCompletedTaskCount());压测时如果发现“队列一直在增长,但线程数始终到不了 maximumPoolSize”,说明核心线程数偏小,任务积压严重,要判断是不是扩容触发条件的问题;如果发现“线程数频繁上下波动、任务完成率低”,说明 keepAliveTime 太短,线程在反复创建销毁。
还有一个上线前的小技巧:用 ThreadPoolExecutor 的 prestartAllCoreThreads() 把核心线程全部预热起来,避免第一个请求高峰来临时,线程还在一个个地创建,接口冷启动慢。注意这个方法只有在线程池刚创建时调用才有效,如果任务已经提交完再调用,核心线程早就起来了,效果不大。
监控不是只看一次就完事。我一般会在本地做一轮压测,记录线程池各项指标;线上观察一周,对比每日高峰期的数据和本地压测的差距。如果差距超过 30%,就要考虑是不是代码路径不同导致的耗时差异,而不是一味调线程池。
5. 线程池常见问题与避坑实录
5.1 线程池“爆了”时的排查思路
遇到“接口突然变慢、大量超时”的情况,先看是不是线程池出问题。排查步骤我整理过一套:
- 第一步,拿到线程池的活动线程数和队列积压量。如果 activeCount 长期等于 maximumPoolSize,队列 size 也持续上涨,说明线程池被打满。
- 第二步,看拒绝策略触发了没有。如果用的是 AbortPolicy,日志里会出现 RejectedExecutionException;如果用的是 CallerRunsPolicy,接口耗时会出现明显波动,因为部分任务由请求线程自己执行。
- 第三步,找到谁在往线程池里塞任务,评估并发量是不是真的超出了预期。有时候不是配置不对,而是上游发起了瞬间大流量,这时需要考虑限流而不是加大线程池。
- 第四步,用 jstack 抓线程栈,看线程池里的线程是不是卡在某个远程调用上。如果线程 wait 在某一个下游接口上,即使线程池容量再大也不顶用,真正要做的是给下游加缓存或者做熔断。
这套思路的核心是:线程池指标 + JVM 线程栈 + 调用量,三者交叉验证。线程池指标只能告诉你“满没满”,jstack 才能告诉你“卡在哪”。我见过一个项目,线程池满了之后,开发第一反应是疯狂调大 maximumPoolSize,结果下游数据库连接池先爆了,问题从一个故障变成两个故障。所以排查时一定要先定位瓶颈,再动手改参数。
5.2 线程池与事务、ThreadLocal 的兼容性问题
这是实际项目中踩过最深的一个坑。把有事务的方法放到线程池里执行,事务大概率不生效。因为 Spring 的事务传播是基于 ThreadLocal 绑定数据库连接的,子线程里拿不到主线程的事务上下文,所以要么把事务控制在主线程,要么把事务方法放到另一个被 Spring 管理的事务 Bean 里调用。
ThreadLocal 的坑类似。主线程往 ThreadLocal 里放用户信息,再丢到线程池执行,子线程读到的往往是上一个任务的残留数据,或者压根读不到。这里两个方向:
- 如果只是“传值”,不需要回写,可以继承 ThreadLocal 或者用参数显式传递。
- 如果任务执行完要把结果回写到主线程的 ThreadLocal,那基本不可靠,不如直接使用返回值或 Future。
另外还有线程池“线程复用”带来的隐藏 bug:任务 A 在 ThreadLocal 里留了脏数据,任务 B 复用同一个线程时直接读到 A 的值。所以每次任务结束,finally 块里必须显式 remove()。这个习惯一定要有,尤其是配合线程池使用的时候,比单线程场景更容易暴露问题。
为了避免这种问题,更推荐的做法是:在提交任务之前,把主线程需要用到的上下文(比如用户 ID、traceId、租户 ID)通过方法参数显式传给子线程,而不是依赖 ThreadLocal 的隐式传递。虽然代码会多几个参数,但可读性和可维护性都会好很多。
TraceId 这块还有一个强需求:全链路日志追踪。如果子线程里没有 traceId,日志串不起来,排查分布式问题时寸步难行。可以用 ThreadPoolTaskExecutor 的 TaskDecorator 来自动传递,这是一招很实用的技巧。TaskDecorator 会在任务执行前把主线程的 traceId 复制到子线程,执行完再清掉,比手动传参优雅很多。
5.3 可以直接抄的配置模板
最后给一个我项目里常用的配置思路,不一定适合所有场景,但可以当作起点:
- 场景一:异步通知 / 日志上报,对结果不敏感,用 ArrayBlockingQueue + CallerRunsPolicy,核心线程 4,最大 8,队列 500。
- 场景二:并行查询聚合(接口聚合类),对耗时敏感,用 SynchronousQueue + CallerRunsPolicy,核心线程 8,最大 16 或者更高,队列不设缓冲,宁可让一部分请求快速失败去走降级,也不让任务在队列里积压。
- 场景三:定时任务批量处理,任务量稳定但单任务耗时长,用 ArrayBlockingQueue + AbortPolicy,核心线程按 CPU 密集算,队列容量留足,但一定要有任务失败的补偿机制。
每个场景都要配一个独立的线程池,不要所有业务共用同一个池。不同业务的优先级、耗时特征、失败容忍度都不同,混在一起很容易出现“低优先级任务把高优先级任务挤垮”的问题。线程池隔离本质上就是故障隔离,这一点在微服务拆分之后尤其重要,不然一个报表导出任务就能把核心下单接口拖垮,你连原因都找不到。
我在实际使用中的体会是:线程池用好了,是系统的“稳压器”;用不好,就是事故的“放大镜”。不管是刚入门还是已经在写生产代码,都建议你花半小时去看看自己项目里现有线程池的指标,数一数创建了多少次、队列有没有积压过、拒绝策略是什么。这些数据比任何经验博客都要真实。
踩过几次坑之后,我现在的原则很简单:先估参数再压测,宁可拒绝也不要无限排队,每个线程池都设置明确的名字,关闭应用时务必等任务完成。把这些底线守住,SpringBoot 项目里的线程池基本不会给你捅出太大的篓子。写这篇内容不是让大家照搬配置,而是希望你们看完之后能有一套自己的判断逻辑,知道每个参数后面对应的是什么样的线上行为。