5.3 线程池打满与参数复盘 本节摘要: 的无界队列把"快速拒绝"推迟成"全体超时",再推成 OOM。本节从一次堆内存事故讲起,拆解 ThreadPoolExecutor 的七参数与提交流程,给出按任务类型(CPU 密集/IO 密集)的参数推算法、拒绝策略的选择,以及那个著名的问题:到底该不该用 Executors 的工厂方法。 事故现场:队列长度 120 万,堆先爆 营销接口在大促前压测良好(500 QPS),大促当天流量爬到 1200 QPS 时先是 RT 缓慢上涨——注意是缓慢,没有报错、没有拒绝——20 分钟后 OOM 崩溃。堆 dump 里的大对象触目惊心:一个 ,里面躺着 120 万个待处理任务。 的实现是 ——队列无界(Integer.MAXVALUE)。
本节摘要:
Executors.newFixedThreadPool的无界队列把"快速拒绝"推迟成"全体超时",再推成 OOM。本节从一次堆内存事故讲起,拆解 ThreadPoolExecutor 的七参数与提交流程,给出按任务类型(CPU 密集/IO 密集)的参数推算法、拒绝策略的选择,以及那个著名的问题:到底该不该用 Executors 的工厂方法。
营销接口在大促前压测良好(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 纪律在这里闭环)。
CPU 密集答案固定(核数±1),IO 密集用利特尔法则思路:线程数 ≈ 核数 × (1 + W/C),W 是单任务等待时间(RPC/DB 的 IO 等待),C 是计算时间。单次调用等 50ms 算 5ms,8 核机器理论值约 88。但公式给起点不给终点:连接数、下游容量、内存都是约束,最终以压测曲线(线程数 × RT × QPS)拐点为准。虚拟线程把这个维度整个抹掉——IO 密集任务直接每个任务一个虚拟线程,线程数不再是容量参数,池化变成历史问题(代价模型转为调度与内存,需要 JDK 21+ 的生态成熟度)。
⚠️ 常见坑:任务里 catch 住所有 Throwable 却不上报。任务"消失"没有任何痕迹,池看起来正常,业务悄悄丢数——未捕获异常处理器 + 任务级监控是双保险。
💡 关键直觉:无界队列的本质是把背压问题交给堆内存去裁决。容量规划的第一课:所有队列必须有界,拒绝策略是系统对外宣誓的容量边界。
下一节是异步编排的暗面:CompletableFuture 的异常吞没。