5.3 线程池打满与参数复盘


文档摘要

5.3 线程池打满与参数复盘 本节摘要: 的无界队列把"快速拒绝"推迟成"全体超时",再推成 OOM。本节从一次堆内存事故讲起,拆解 ThreadPoolExecutor 的七参数与提交流程,给出按任务类型(CPU 密集/IO 密集)的参数推算法、拒绝策略的选择,以及那个著名的问题:到底该不该用 Executors 的工厂方法。 事故现场:队列长度 120 万,堆先爆 营销接口在大促前压测良好(500 QPS),大促当天流量爬到 1200 QPS 时先是 RT 缓慢上涨——注意是缓慢,没有报错、没有拒绝——20 分钟后 OOM 崩溃。堆 dump 里的大对象触目惊心:一个 ,里面躺着 120 万个待处理任务。 的实现是 ——队列无界(Integer.MAXVALUE)。

5.3 线程池打满与参数复盘

本节摘要Executors.newFixedThreadPool 的无界队列把"快速拒绝"推迟成"全体超时",再推成 OOM。本节从一次堆内存事故讲起,拆解 ThreadPoolExecutor 的七参数与提交流程,给出按任务类型(CPU 密集/IO 密集)的参数推算法、拒绝策略的选择,以及那个著名的问题:到底该不该用 Executors 的工厂方法。

事故现场:队列长度 120 万,堆先爆

营销接口在大促前压测良好(500 QPS),大促当天流量爬到 1200 QPS 时先是 RT 缓慢上涨——注意是缓慢,没有报错、没有拒绝——20 分钟后 OOM 崩溃。堆 dump 里的大对象触目惊心:一个 LinkedBlockingQueue,里面躺着 120 万个待处理任务。

// 初始化代码 看起来无比标准 ExecutorService pool = Executors.newFixedThreadPool(32);

newFixedThreadPool 的实现是 new ThreadPoolExecutor(32, 32, 0, LinkedBlockingQueue())——队列无界(Integer.MAX_VALUE)。提交速度超过消费速度时,任务在队列里无限堆积,每个任务对象连同它捕获的请求上下文(几十 KB)都被拖住:120 万 × 40KB ≈ 48GB 的引用链,堆毫无悬念地爆。更阴的是崩溃前的表现:RT 是慢慢涨的(排队长度线性增长),没有突刺没有拒绝,监控如果只看错误率会一路绿灯看到 OOM。

七参数与提交流程

理解事故钥匙在 ThreadPoolExecutor 的提交逻辑:

public ThreadPoolExecutor( int corePoolSize, // 核心线程数 常驻 int maximumPoolSize, // 最大线程数 队列满后才扩 long keepAliveTime, TimeUnit unit, // 非核心线程空闲回收时间 BlockingQueue<Runnable> workQueue, // 任务队列 决定堆积行为 ThreadFactory threadFactory, // 命名与异常处理 全局唯一id RejectedExecutionHandler handler) // 拒绝策略

提交一个任务的决策序:线程数 < core?创建核心线程执行;否则入队;队列满且线程数 < max?创建非核心线程;都满了?触发拒绝策略。这个顺序有两个反直觉点:队列排在"扩线程"前面(先排队后扩员),以及 maximumPoolSize 只有在有界队列填满后才生效——配了 max=64 配了无界队列,那 64 永远不会达到,等于没配。

四种种拒绝策略的语义要分清:AbortPolicy(抛异常,默认,调用方立刻感知)适合可以失败重试的上游;CallerRunsPolicy(提交者自己执行,天然反压)适合削峰但会拖慢提交线程;DiscardPolicy(静默丢弃,事故之源,几乎不该用);DiscardOldestPolicy(丢队头老任务,适合只关心最新状态的场景如行情推送)。

线程池提交流程与事故位

线程池提交流程与事故位

修复:显式构造 + 有界 + 命名

ThreadPoolExecutor pool = new ThreadPoolExecutor( 16, 32, // core max 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(2000), // 有界 触发扩员与拒绝 new ThreadFactory() { // 命名便于 jstack 定位 private final AtomicInteger seq = new AtomicInteger(); public Thread newThread(Runnable r) { Thread t = new Thread(r, "marketing-worker-" + seq.incrementAndGet()); t.setUncaughtExceptionHandler((x, e) -> log.error("worker died", e)); return t; } }, new ThreadPoolExecutor.AbortPolicy()); // 显式选择 快速失败

配套三件事:拒绝时的降级动作(AbortPolicy 抛出的异常要有地方接——转异步补偿或返回友好失败,而不是让 500 裸奔);监控(活跃线程、队列长度、拒绝计数三个指标接告警,队列长度是容量预警的头号信号);优雅停机shutdown + awaitTermination,第 5.1 节的 InterruptedException 纪律在这里闭环)。

IO 密集的线程数到底怎么算

CPU 密集答案固定(核数±1),IO 密集用利特尔法则思路:线程数 ≈ 核数 × (1 + W/C),W 是单任务等待时间(RPC/DB 的 IO 等待),C 是计算时间。单次调用等 50ms 算 5ms,8 核机器理论值约 88。但公式给起点不给终点:连接数、下游容量、内存都是约束,最终以压测曲线(线程数 × RT × QPS)拐点为准。虚拟线程把这个维度整个抹掉——IO 密集任务直接每个任务一个虚拟线程,线程数不再是容量参数,池化变成历史问题(代价模型转为调度与内存,需要 JDK 21+ 的生态成熟度)。

⚠️ 常见坑:任务里 catch 住所有 Throwable 却不上报。任务"消失"没有任何痕迹,池看起来正常,业务悄悄丢数——未捕获异常处理器 + 任务级监控是双保险。

💡 关键直觉:无界队列的本质是把背压问题交给堆内存去裁决。容量规划的第一课:所有队列必须有界,拒绝策略是系统对外宣誓的容量边界。

防坑清单

  • 禁用 Executors 工厂方法(newFixedThreadPool/newSingleThreadExecutor 无界队列;newCachedThreadPool 线程无上限),显式 new ThreadPoolExecutor
  • 队列必有界;max 线程数配了无界队列等于没配
  • 线程工厂命名 + 未捕获异常处理器
  • 拒绝策略按业务语义选,被拒任务有明确去向
  • 队列长度/活跃线程/拒绝数进监控,队列线性上涨即告警

本节要点回顾

  • 事故机理:无界队列无限堆积,RT 缓涨掩盖故障,最终 OOM
  • 决策序:core → 队列 → max → 拒绝,排队先于扩员
  • 参数法:CPU 密集核数±1,IO 密集按等待/计算比推,压测定终值
  • 四策略:Abort 快失败、CallerRuns 反压、两个 Discard 慎用
  • 工厂方法: Executors 便捷方法全部有坑,显式构造是唯一正解

下一节是异步编排的暗面:CompletableFuture 的异常吞没。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U