ChthollyHub
  • Hub
  • Chtholly
  • Search
  • Write
  • Login

Chtholly Hub

|

Rekyrice
@Rekyrice

动漫 · 追番 · 随笔

7文章50标签1活跃
珂朵莉 - default
Observation

珂朵莉观察

今天仓库里还算安静。没有关系,安静的时候也适合读一点东西。

社区入口

写点什么把今天的故事放进仓库搜索仓库找文章、番剧和灵感珂朵莉房间看看她最近在想什么

热门标签

动画电影Agent制作考察Java动画演出动画评论性能排查牛尾宪辅独立游戏生活随笔1000xRESIST3DCGasyncioCITY THE ANIMATIONCosplay

活跃用户

Rekyrice7 篇最近文章

最新文章

  • 即使无法生活,也可以停留——《末日后酒店》与那些比世界活得更久的东西
  • 裕太终于把那句话说完了——《古立特宇宙》
  • 欢迎来到 Chtholly Hub
  • 2026 冬季追番清单
  • 为什么叫 Chtholly

© 2026 Rekyrice · Chtholly Hub

Powered by Next.js & Spring Boot

公安备案图标桂公网安备45020502000330号·桂ICP备2026017801号-1

HubChthollySearchWrite
我把 maxPoolSize 调到 64,任务还是先在队列里等死了

我把 maxPoolSize 调到 64,任务还是先在队列里等死了

2026年06月14日磁盘又满了
约 6 分钟磁盘又满了
JavaThreadPoolExecutor线程池性能排查

周五下午有个批处理越来越慢。监控上 CPU 不高,数据库也没堵,线程池配置看起来甚至很大:corePoolSize=4,maximumPoolSize=64。我第一反应是任务突然多了,64 个线程都跑满了,接着顺手把最大线程数改到 96。

改完一点用也没有。

真正运行的线程从头到尾只有 4 个,剩下的任务整整齐齐排在队列里。那一刻才想起来,maximumPoolSize 不是“线程池最多就尽量开这么多”,它前面还站着一个经常被忽略的条件:队列得先拒绝接活。

64 写在配置里,没有写进运行路径

我们的配置大致等于:

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()4360
ArrayBlockingQueue(8)3280

两种队列下的线程与排队数量

有界队列那组的过程是:4 个核心线程先运行,8 个任务填满队列,余下 28 个任务才推动线程数长到 32。这里也没有“直接开到 64”;线程池只创建接住当前任务所需的线程。这个结果比一句“无界队列会让最大线程数失效”更准确——参数没有失效,决策顺序决定了它暂时到不了场。

实验源码和原始 JSON 都留在内容包的 evidence/thread-pool-queue-before-max/ 下。它不模拟业务耗时,也不证明有界队列一定更好,只验证 execute 的这段选择顺序。

队列变短,不代表问题已经解决

发现原因以后,最危险的动作是立刻把队列从无界改成 100,然后收工。队列一旦填满,压力会转到扩容和拒绝上;如果任务是数据库查询或远程调用,32 个线程同时往下游冲,原来只是本服务排队,后来可能变成连接池、数据库和对方接口一起排队。

我最后没有先问“队列应该多大”,而是补了几个更具体的问题:

  • 单个任务的平均和 P99 执行时间是多少;
  • 下游允许同时存在多少个请求;
  • 队列里的任务等多久以后已经没有执行价值;
  • 填满以后应该让调用方失败、自己执行,还是丢弃可合并任务;
  • 关闭服务时,队列中尚未执行的任务怎样处理。

这些答案会一起决定核心线程、最大线程、队列容量和拒绝策略。只改其中一个数,很可能只是把等待从一个位置搬到另一个位置。

我又补了一次会失败的压测

把队列改成有界以后,我没有只看那张 32 个线程同时运行的截图。下一轮实验故意把任务数继续往上加,同时把下游连接池限制在 16。线程池很快能创建更多工作线程,可完成速率没有跟着增长;超过 16 的那些线程大多在等连接,应用侧的活跃线程图变得很热闹,下游吞吐仍停在原处。

这组现象没有写进上面的对照表,因为它依赖另一个容量模型,也超出了“队列先于最大线程数”的单变量实验。不过它提醒我,线程池的上限至少要和任务中最窄的资源一起看。若每个任务都要占一个数据库连接,把最大线程数设成连接池的四倍,通常只能得到更多等待者和更长的上下文切换队列。

拒绝路径也需要真的跑一次。我分别试过 AbortPolicy 和 CallerRunsPolicy:前者会让过载立刻暴露给调用方,后者让提交任务的线程亲自执行,生产速度因此被拖慢。两者没有脱离业务的统一优劣。批量补偿任务可以接受稍后重试,在线请求却可能已经没有继续排队的价值。选策略以前,先写清楚任务失败后由谁重试、能否重复执行,以及调用方是否承受得住反压,会比背四个策略名字更有用。

最后我给队列等待时间单独设了告警。任务进入队列时记下时间戳,真正开始执行时记录差值,再观察 P50、P95 和最老任务。这样即使线程数、CPU、拒绝数依旧安静,也能较早看见吞吐已经追不上进入速度。队列长度只告诉我积了多少,等待时间才告诉我这些任务已经被耽搁多久。

后来我把第一屏换了

这次排查之后,我给线程池监控补了队列长度、最老任务等待时间、完成速率和拒绝数。线程数量仍然保留,但不再单独代表“忙不忙”。无界队列最麻烦的地方不止可能吃光内存,它还能在很长一段时间里用“我还收得下”掩盖“我已经来不及做”。

maximumPoolSize=64 看起来像一句很有力量的承诺。真正决定系统当下行为的,却是前面那个不起眼的队列。以后再看到线程池任务堆积,我会先画一遍 execute() 的路径,再去动配置:核心线程忙了没有,队列满了没有,扩容分支到底有没有被走到。

这比把 64 改成 96 便宜得多。

JavaThreadPoolExecutor线程池性能排查
阅读轨迹
约 6 分钟1,799 字2026年06月14日
阅读进度0%
本文目录
  1. 64 写在配置里,没有写进运行路径
  2. 我把两个队列放在一起跑了一遍
  3. 队列变短,不代表问题已经解决
  4. 我又补了一次会失败的压测
  5. 后来我把第一屏换了
文章线索
磁盘又满了
JavaThreadPoolExecutor线程池性能排查
珂朵莉陪读
珂朵莉 - serious

看起来很认真呢……我会安静地在旁边陪着。

解释这篇文章
磁盘又满了

作者

磁盘又满了

主要写 Java 和 Python 的排错记录,偶尔也记一点跑模型时踩过的坑。

查看作者所有文章
珂朵莉 - curious

文章问答

问珂朵莉关于这篇文章

她会先读这篇文章,再安静地回答你的问题。可以继续追问。

评论

加载中…

登录后参与讨论