线程池配置看起来很充足核心线程 8、最大线程 64、队列容量 10 万。可高峰期任务越积越多接口延迟从 200ms 涨到 20 秒线程数却始终只有 8 个。最后才发现不是maxPoolSize没生效而是超大队列让它根本没有机会生效。一、事故现场系统里有一个订单完成后的异步任务需要做三件事生成结算记录 同步会员积分 发送业务通知为了不阻塞订单主流程代码使用了独立线程池Bean(orderExecutor)publicThreadPoolTaskExecutororderExecutor(){ThreadPoolTaskExecutorexecutornewThreadPoolTaskExecutor();executor.setCorePoolSize(8);executor.setMaxPoolSize(64);executor.setQueueCapacity(100000);executor.setKeepAliveSeconds(60);executor.setThreadNamePrefix(order-async-);executor.initialize();returnexecutor;}开发同学的想法很简单平时用 8 个线程节省资源 流量上来后自动扩容到 64 个 队列配大一点避免任务被拒绝但高峰期监控却出现了奇怪的数据活跃线程数8 线程池大小8 最大线程数64 队列任务数0 - 18000 单个任务平均耗时约 500ms 任务等待时间最高超过 20 秒任务已经排了 1.8 万个线程池却始终没有扩容。看起来就像maxPoolSize64完全失效了。实际上线程池的行为正好符合它的设计。二、ThreadPoolExecutor 不是先扩容再排队很多人以为线程池接收任务的顺序是核心线程忙了 - 创建非核心线程 - 达到最大线程数 - 再把任务放进队列但ThreadPoolExecutor实际上的顺序是1. 当前线程数小于 corePoolSize创建线程 2. 达到 corePoolSize优先把任务放进队列 3. 队列放不下继续创建线程直到 maximumPoolSize 4. 线程达到 maximumPoolSize队列也满了执行拒绝策略也就是说maximumPoolSize生效有一个前提队列必须先满。这次配置的队列容量是 10 万。在任务积累到 10 万之前线程池都会优先排队根本不会创建第 9 个线程。所以这个配置在绝大多数时间里等价于8 个线程处理任务 后面跟着一个容量 10 万的等待区maxPoolSize64虽然写在配置里却很难真正参与处理。三、为什么超大队列比直接拒绝更危险任务不丢看起来是一件好事。但队列只是保存任务并没有增加处理能力。假设核心线程数8 单个任务耗时500ms 每个线程每秒处理2 个任务 线程池每秒总处理能力8 × 2 16 个任务 高峰期每秒提交60 个任务每秒都会多出60 - 16 44 个任务一分钟后队列大约积累44 × 60 2640 个任务排在最后的任务需要等待2640 ÷ 16 165 秒任务虽然没有被拒绝但两分多钟后才开始执行。对于通知任务这可能只是延迟。如果队列里放的是库存释放、订单关闭、支付补偿等有时效性的任务晚两分钟执行可能已经产生业务错误。更麻烦的是大量排队任务还会占用堆内存。如果每个任务对象连同引用的数据平均占用 20KB100000 × 20KB ≈ 1.9GB队列看似保护了任务实际上可能把“处理不过来”变成任务延迟 堆内存上涨 频繁 GC 服务整体卡顿 最终 OOM拒绝是一种明确的失败。无限等待则可能是一种更隐蔽、更难恢复的失败。四、用 30 行代码复现下面这段代码可以直接观察线程池的扩容顺序importjava.util.concurrent.*;publicclassThreadPoolQueueDemo{publicstaticvoidmain(String[]args)throwsException{ThreadPoolExecutorexecutornewThreadPoolExecutor(2,10,60,TimeUnit.SECONDS,newArrayBlockingQueue(100),newThreadPoolExecutor.AbortPolicy());for(inti1;i20;i){inttaskIdi;executor.execute(()-{try{TimeUnit.SECONDS.sleep(10);}catch(InterruptedExceptione){Thread.currentThread().interrupt();}System.out.println(finished task taskId);});System.out.printf(task%d, poolSize%d, active%d, queue%d%n,taskId,executor.getPoolSize(),executor.getActiveCount(),executor.getQueue().size());}executor.shutdown();}}虽然最大线程数是 10提交 20 个任务后输出仍然类似task2, poolSize2, active2, queue0 task3, poolSize2, active2, queue1 task10, poolSize2, active2, queue8 task20, poolSize2, active2, queue18因为队列容量是 100还远远没有塞满。把队列容量改成 2newArrayBlockingQueue(2)再次运行会看到前 2 个任务创建核心线程 接下来 2 个任务进入队列 后续任务继续创建线程直到 10 个 再继续提交触发拒绝策略maximumPoolSize这时才真正生效。五、第一版修复让队列和最大线程数真正配合根据任务耗时、下游容量和可接受等待时间重新调整线程池Bean(orderExecutor)publicThreadPoolTaskExecutororderExecutor(){ThreadPoolTaskExecutorexecutornewThreadPoolTaskExecutor();executor.setCorePoolSize(16);executor.setMaxPoolSize(32);executor.setQueueCapacity(500);executor.setKeepAliveSeconds(60);executor.setThreadNamePrefix(order-async-);executor.setRejectedExecutionHandler(newThreadPoolExecutor.CallerRunsPolicy());executor.setWaitForTasksToCompleteOnShutdown(true);executor.setAwaitTerminationSeconds(30);executor.initialize();returnexecutor;}这组数字不是通用答案。它表达的是一种配置思路核心线程承担稳定流量 有限队列吸收短时尖峰 最大线程处理更高压力 拒绝策略负责最后保护队列从 10 万缩小到 500 后如果流量持续超过处理能力系统会更早暴露压力而不是把问题藏在队列里几十秒甚至几分钟。六、线程数不能只按 CPU 核数抄公式常见经验公式是CPU 密集型线程数接近 CPU 核数 IO 密集型线程数可以大于 CPU 核数这个方向没有错但不能直接得出生产参数。线程数至少还受到这些因素限制数据库连接池大小 HTTP 连接池大小 下游接口允许的并发量 单个任务的平均和 P99 耗时 机器 CPU 与内存 任务是否会持有大对象 业务允许等待多久例如数据库连接池只有 30 个连接却把 5 个业务线程池都配置成最大 100线程增加了 数据库连接没有增加 大量线程只是从队列等待变成等待数据库连接接口不会因此更快线程切换和内存开销反而会上升。更可靠的估算方式是从吞吐量开始。假设目标吞吐量40 个任务/秒 单个任务平均耗时0.5 秒根据 Little’s Law可以先估算并发量并发数 ≈ 吞吐量 × 平均耗时 并发数 ≈ 40 × 0.5 20这意味着想稳定处理目标流量至少需要约 20 个同时工作的执行单元。然后再结合 P99 耗时、突发流量和下游上限做压测而不是看到机器 8 核就机械地配置 16 个线程。七、队列容量也可以估算队列大小应该由“允许等待多久”决定而不是由“内存还能放多少”决定。假设线程池稳定处理能力是40 个任务/秒业务最多允许任务等待 5 秒那么队列可以从这个量级开始评估40 × 5 200还要继续通过压测验证任务对象实际占用多少内存 队列满时系统如何降级 调用方能否承受 CallerRunsPolicy 拒绝后任务能否进入 MQ 或补偿表对于不能丢失的任务不应该只靠 JVM 内存队列保证可靠性。进程重启、机器宕机或容器迁移时内存队列里的任务仍然会丢失。更合适的方案通常是提交到消息队列 记录到数据库任务表 消费失败后重试 超过次数进入人工处理线程池解决的是进程内并发调度不是可靠消息问题。八、拒绝策略不能随便选线程和队列都满后需要执行拒绝策略。AbortPolicy直接抛出RejectedExecutionException。优点是问题暴露明确调用方必须处理异常否则可能造成请求失败。CallerRunsPolicy由提交任务的线程执行。它可以形成一定的反压线程池处理不过来提交速度也会被迫降低。但如果提交线程是 Tomcat 请求线程任务又需要执行几秒接口延迟会直接升高。DiscardPolicy静默丢弃任务。除非任务天然允许丢失并且有独立监控否则通常不建议使用。DiscardOldestPolicy丢弃队列中最早的任务再尝试提交新任务。对于订单、支付、通知等业务旧任务往往并不比新任务更不重要使用前必须明确业务语义。拒绝策略不是一个纯技术参数。它实际回答的是系统超出处理能力时业务允许牺牲什么九、没有监控的线程池出事前几乎是黑盒至少应该监控corePoolSize maximumPoolSize poolSize activeCount queueSize queueRemainingCapacity completedTaskCount rejectedTaskCount 任务执行耗时 任务排队耗时只监控活跃线程数还不够。这次事故中activeCount一直是 8看起来非常稳定。真正暴露问题的是queueSize 持续上涨 任务排队时间持续上涨 completedTaskCount 增长速度低于提交速度尤其要区分两个指标执行耗时线程真正运行任务花了多久 排队耗时任务从提交到开始执行等了多久如果执行只需要 500ms接口却等待 20 秒问题通常不在任务本身而在它前面排了太多任务。十、修复后的结果经过压测和下游容量评估最终配置为corePoolSize16 maximumPoolSize32 queueCapacity500 拒绝策略CallerRunsPolicy 超出持续处理能力的任务写入补偿任务表上线后的监控变化高峰期线程数16 到 28 队列峰值183 任务最大排队时间20s - 1.4s Full GC0 拒绝任务可以监控并进入补偿真正的改进不是把 8 改成 16也不是把队列 10 万改成 500。而是让线程数、队列、下游容量和业务降级形成了一套完整策略。十一、线程池上线前检查清单任务是 CPU 密集还是 IO 密集 目标吞吐量和任务 P99 耗时是多少 下游最多允许多少并发 队列满之前最大等待时间是多少 队列中的任务大约占多少内存 任务是否允许丢失 拒绝后是失败、降级、重试还是补偿 是否监控线程数、队列长度和排队时间 应用停止时队列任务如何处理 是否通过真实场景压测验证如果这些问题没有答案core8、max64、queue100000就只是三个看起来很安全的数字。总结线程池最反直觉的地方是达到核心线程数后它会优先排队只有队列放不下才继续扩容到最大线程数。因此corePoolSize 决定稳定处理能力 queueCapacity 决定允许积压多少 maximumPoolSize 决定队列满后的扩容上限 RejectedExecutionHandler 决定系统过载时如何自保队列不是越大越安全。它可能只是把失败推迟并把一个清晰的拒绝变成长时间延迟、内存上涨和系统雪崩。线程池参数也没有可以直接复制的标准答案。应该先知道任务来得多快、处理得多快、最多能等多久以及下游到底能承受多少并发。如果你遇到线程池任务堆积、接口超时、CPU 或内存异常或者需要协助安装部署 Spring Boot、MySQL、Redis、Nginx可以关注我后台发送“排查”联系我。简单问题和初步判断我可以免费帮忙看一下。代码、日志和配置请先脱敏不要发送密码、Token、私钥与用户隐私数据。