
周五下午有个批处理越来越慢。监控上 CPU 不高,数据库也没堵,线程池配置看起来甚至很大:corePoolSize=4,maximumPoolSize=64。我第一反应是任务突然多了,64 个线程都跑满了,接着顺手把最大线程数改到 96。
改完一点用也没有。
真正运行的线程从头到尾只有 4 个,剩下的任务整整齐齐排在队列里。那一刻才想起来,maximumPoolSize 不是“线程池最多就尽量开这么多”,它前面还站着一个经常被忽略的条件:队列得先拒绝接活。
我们的配置大致等于:
new ThreadPoolExecutor(
4,
64,
30,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>()
);
问题就在最后一行。没有传容量的 LinkedBlockingQueue 在这里近似一条不会填满的长队。ThreadPoolExecutor.execute() 接到任务时,先让核心线程干活;核心线程都忙了以后,它会尝试把任务放进队列。只有队列放不进去,它才会继续创建核心线程以外的线程,直到 maximumPoolSize。
于是这组参数的实际路径是:前 4 个任务启动 4 个核心线程,第 5 个开始全部入队。队列愿意一直收,扩容分支就永远轮不到。64 没写错,也没有失效,它只是没有机会被读到。
这件事最会骗人之处,是指标看上去并不“爆”。线程数很低,CPU 也很从容,拒绝计数还是 0。若只盯这三项,很容易得出系统还有余量的结论。真正不断变坏的是队列等待时间,而它往往没有像接口耗时那样出现在第一屏。
为了不再靠脑内推演,我写了一个很小的 Java 21 实验。两组线程池都用 corePoolSize=4、maximumPoolSize=64,一次提交 40 个任务。每个任务启动后都在同一个 CountDownLatch 上等着,确保采样时不会提前结束。
第一组是无界 LinkedBlockingQueue;第二组只把队列换成容量为 8 的 ArrayBlockingQueue。200 毫秒后读取 poolSize、activeCount 和队列长度。
结果很干脆:
| 队列 | 运行线程 | 队列中 | 拒绝 |
|---|---|---|---|
LinkedBlockingQueue() | 4 | 36 | 0 |
ArrayBlockingQueue(8) | 32 | 8 | 0 |

有界队列那组的过程是:4 个核心线程先运行,8 个任务填满队列,余下 28 个任务才推动线程数长到 32。这里也没有“直接开到 64”;线程池只创建接住当前任务所需的线程。这个结果比一句“无界队列会让最大线程数失效”更准确——参数没有失效,决策顺序决定了它暂时到不了场。
实验源码和原始 JSON 都留在内容包的 evidence/thread-pool-queue-before-max/ 下。它不模拟业务耗时,也不证明有界队列一定更好,只验证 execute 的这段选择顺序。
发现原因以后,最危险的动作是立刻把队列从无界改成 100,然后收工。队列一旦填满,压力会转到扩容和拒绝上;如果任务是数据库查询或远程调用,32 个线程同时往下游冲,原来只是本服务排队,后来可能变成连接池、数据库和对方接口一起排队。
我最后没有先问“队列应该多大”,而是补了几个更具体的问题:
这些答案会一起决定核心线程、最大线程、队列容量和拒绝策略。只改其中一个数,很可能只是把等待从一个位置搬到另一个位置。
把队列改成有界以后,我没有只看那张 32 个线程同时运行的截图。下一轮实验故意把任务数继续往上加,同时把下游连接池限制在 16。线程池很快能创建更多工作线程,可完成速率没有跟着增长;超过 16 的那些线程大多在等连接,应用侧的活跃线程图变得很热闹,下游吞吐仍停在原处。
这组现象没有写进上面的对照表,因为它依赖另一个容量模型,也超出了“队列先于最大线程数”的单变量实验。不过它提醒我,线程池的上限至少要和任务中最窄的资源一起看。若每个任务都要占一个数据库连接,把最大线程数设成连接池的四倍,通常只能得到更多等待者和更长的上下文切换队列。
拒绝路径也需要真的跑一次。我分别试过 AbortPolicy 和 CallerRunsPolicy:前者会让过载立刻暴露给调用方,后者让提交任务的线程亲自执行,生产速度因此被拖慢。两者没有脱离业务的统一优劣。批量补偿任务可以接受稍后重试,在线请求却可能已经没有继续排队的价值。选策略以前,先写清楚任务失败后由谁重试、能否重复执行,以及调用方是否承受得住反压,会比背四个策略名字更有用。
最后我给队列等待时间单独设了告警。任务进入队列时记下时间戳,真正开始执行时记录差值,再观察 P50、P95 和最老任务。这样即使线程数、CPU、拒绝数依旧安静,也能较早看见吞吐已经追不上进入速度。队列长度只告诉我积了多少,等待时间才告诉我这些任务已经被耽搁多久。
这次排查之后,我给线程池监控补了队列长度、最老任务等待时间、完成速率和拒绝数。线程数量仍然保留,但不再单独代表“忙不忙”。无界队列最麻烦的地方不止可能吃光内存,它还能在很长一段时间里用“我还收得下”掩盖“我已经来不及做”。
maximumPoolSize=64 看起来像一句很有力量的承诺。真正决定系统当下行为的,却是前面那个不起眼的队列。以后再看到线程池任务堆积,我会先画一遍 execute() 的路径,再去动配置:核心线程忙了没有,队列满了没有,扩容分支到底有没有被走到。
这比把 64 改成 96 便宜得多。

文章问答
她会先读这篇文章,再安静地回答你的问题。可以继续追问。
加载中…
登录后参与讨论