线程池不是越大越好
线程池的参数看起来只有七个,但真正难的是它们之间的配合:核心线程数、最大线程数、队列容量、拒绝策略,任何一个拍脑袋,最后都会变成线上问题。典型现象是:接口平时好好的,流量一上来就大量超时,日志里既没有异常也没有报错,只有线程池的队列在悄悄变长。
先把执行顺序记住:先占满核心线程,再进队列,队列满了才扩容到最大线程数,再满才拒绝。很多人以为会先扩到最大线程数,于是配了一个无界队列,结果最大线程数永远用不上,任务全堆在队列里等到超时。
七个参数怎么配合
| 参数 | 作用 | 常见取值思路 |
|---|---|---|
| corePoolSize | 常驻线程数 | CPU 密集≈核数,IO 密集可放大 |
| maximumPoolSize | 峰值线程数 | 有界队列下才有意义 |
| keepAliveTime | 非核心线程空闲存活 | 30~60 秒 |
| workQueue | 排队策略 | 必须用有界队列 |
| threadFactory | 线程命名 | 带业务前缀,方便 jstack |
| handler | 拒绝策略 | 不能静默丢弃 |
| allowCoreThreadTimeOut | 核心线程是否回收 | 低频任务可开 |
队列容量要和上游超时一起算。队列 1000、单任务 200ms,最坏排队就是 200 秒,而网关超时可能只有 3 秒——请求早就被调用方放弃了,线程还在傻傻地算。比较合理的做法是:队列长度 × 平均耗时 ≤ 可接受的排队时间,通常队列控制在几百以内。
@Bean("orderExecutor")
public ThreadPoolExecutor orderExecutor() {
ThreadFactory factory = Thread.ofPlatform()
.name("order-worker-", 0)
.daemon(false)
.factory();
return new ThreadPoolExecutor(
8, 32, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200),
factory,
(task, executor) -> {
log.error("订单线程池已满, active={}, queue={}",
executor.getActiveCount(), executor.getQueue().size());
throw new BizException("SYSTEM_BUSY", "系统繁忙,请稍后重试");
});
}
这里用 Thread.ofPlatform() 显式命名线程,出问题时 jstack 里能直接按 order-worker- 过滤。new ArrayBlockingQueue<>(200) 是有界队列,配合 32 的最大线程数,池子的容量上限是可计算的。
拒绝策略怎么选
JDK 自带四种拒绝策略:AbortPolicy 抛异常、CallerRunsPolicy 让提交任务的线程自己执行、DiscardPolicy 静默丢弃、DiscardOldestPolicy 丢掉最老的任务。默认的 AbortPolicy 最容易暴露问题,但也最容易被上层吞掉;CallerRunsPolicy 能形成背压,但在 Web 场景会阻塞 Tomcat 线程,把压力传回入口;两个 Discard 策略则几乎不该在生产使用,丢任务还没有任何日志。
更稳妥的是自定义:记录线程池状态、打点、抛出明确的业务异常。如果任务不能丢,就先落库或发 MQ 再异步补偿,而不是指望线程池兜底。
Spring 里的线程池
Spring Boot 3 里更常用 ThreadPoolTaskExecutor,它封装了同样的参数,还能配合 @Async:
@Bean("asyncExecutor")
public ThreadPoolTaskExecutor asyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(32);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setTaskDecorator(new MdcTaskDecorator());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}
TaskDecorator 用来把 MDC(traceId)、租户信息、登录态复制到子线程,并在任务结束后清理。waitForTasksToCompleteOnShutdown 保证停机时把在途任务跑完,不然发版会丢任务。
Java 21 的虚拟线程适合阻塞型 IO,但不要把它塞进传统线程池池化——虚拟线程本身很轻,直接 Thread.ofVirtual().start(task) 即可,池化反而失去意义。
常见坑
坑一:用无界队列。 LinkedBlockingQueue 不传容量就是 Integer.MAX_VALUE,任务无限堆积,最后 OOM。所有业务线程池都应有界。
坑二:线程池嵌套。 A 池的任务同步调用 B 池,两个池都满时互相等待,形成死锁。跨服务调用要设超时,不要让线程无限期阻塞。
坑三:ThreadLocal 不清理。 线程复用导致上一个请求的租户 ID、用户信息串到下一个请求。用 try-finally 清理,或者用 TaskDecorator 统一处理。
坑四:@Async 方法返回 void,异常被吞。 异步方法抛出的异常不会传播到调用方,必须配置 AsyncUncaughtExceptionHandler 记录日志。
坑五:忽略容器 CPU 限制。 Runtime.availableProcessors() 在容器里会按 CPU limit 返回,但如果你在启动脚本里写死线程数,扩缩容后就可能过小或过大。
排查步骤
- 看线程池指标:活跃线程数、队列长度、拒绝次数、任务耗时。没有指标就先补上。
jstack <pid>按线程名前缀统计数量和状态,大量WAITING在队列上说明消费不过来。- 看拒绝日志和上游超时日志,判断是容量不足还是下游变慢。
- 检查队列容量和任务耗时,算一下最坏排队时间是否超过上游超时。
- 最后再决定扩线程、扩实例还是优化任务本身,不要一上来就调大
maximumPoolSize。
评论