ElIauk

Java 线程池参数与拒绝策略

线程池不是越大越好 线程池的参数看起来只有七个,但真正难的是它们之间的配合:核心线程数、最大线程数、队列容量、拒绝策略,任何一个拍脑袋,最后都会变成线上问题。典型现象是:接口平时好好的,流量一上来就大量超时,日志里既没有异常也没有报错,只有线程池的队列在悄悄变长。 先把执行顺序记住:先占满核心线程,

评论
Java 线程池参数与拒绝策略

线程池不是越大越好

线程池的参数看起来只有七个,但真正难的是它们之间的配合:核心线程数、最大线程数、队列容量、拒绝策略,任何一个拍脑袋,最后都会变成线上问题。典型现象是:接口平时好好的,流量一上来就大量超时,日志里既没有异常也没有报错,只有线程池的队列在悄悄变长。

先把执行顺序记住:先占满核心线程,再进队列,队列满了才扩容到最大线程数,再满才拒绝。很多人以为会先扩到最大线程数,于是配了一个无界队列,结果最大线程数永远用不上,任务全堆在队列里等到超时。

七个参数怎么配合

参数 作用 常见取值思路
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 返回,但如果你在启动脚本里写死线程数,扩缩容后就可能过小或过大。

排查步骤

  1. 看线程池指标:活跃线程数、队列长度、拒绝次数、任务耗时。没有指标就先补上。
  2. jstack <pid> 按线程名前缀统计数量和状态,大量 WAITING 在队列上说明消费不过来。
  3. 看拒绝日志和上游超时日志,判断是容量不足还是下游变慢。
  4. 检查队列容量和任务耗时,算一下最坏排队时间是否超过上游超时。
  5. 最后再决定扩线程、扩实例还是优化任务本身,不要一上来就调大 maximumPoolSize。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者