线程池的问题大多不出在 API 不会用,而出在参数拍脑袋、业务混着跑、出了事看不见。这篇给一份可直接落地的实践清单:怎么估算线程数、为什么要按业务隔离、命名和监控怎么做,最后复盘几个典型事故。

核心原则

三句话打底:显式声明参数,不用 Executors 的快捷方法(无界队列和无上限线程数都是 OOM 隐患);队列必须有界,让过载尽早暴露;池必须可观测,没有监控的线程池等于裸奔。后面的每条实践都围绕这三点展开。

线程数估算:公式给起点,压测定终点

区分任务类型选初始值:

  • CPU 密集型(加解密、压缩、规则计算):线程数 ≈ CPU 核数(可 +1 应对偶发缺页或暂停)。线程再多,只是在增加上下文切换。
  • IO 密集型(查库、调下游、读写文件):经典估算式为 N = Ncpu × Ucpu × (1 + W/C),其中 Ucpu 是目标 CPU 利用率,W/C 是任务等待时间与计算时间的比值。等待占比越高,可配的线程越多。
  • 另一个视角:用排队论的思路,所需并发 ≈ 每秒任务数 × 任务平均耗时(秒)。高峰每秒 400 个任务、平均耗时 80ms,并发需求约 32。

但公式只负责给起点,有两点它回答不了:一是平均耗时掩盖长尾,P99 可能远超均值;二是下游容量是硬约束——100 个线程同时查库,连接池只有 20 个连接,多出来的线程只是换了个地方排队。最终参数必须靠接近真实流量的压测确定,压测时同时盯吞吐、P99、CPU、上下文切换和下游连接池。

按业务隔离线程池

共享池最大的风险是慢任务拖垮所有业务:报表导出把线程占满,支付回调就只能排队超时。按三个维度划分:优先级(核心交易与非核心通知分开)、耗时特征(快慢任务分开)、下游依赖(调用同一个慢下游的任务单独成池)。

一个真实的事故模式是线程饥饿死锁:父任务提交子任务后同步等待结果,两者共用同一个有界池。线程全部被父任务占满时,子任务进队列却无人执行,父任务又永远不释放线程,池彻底卡死。解法是父子任务拆池,或父任务改为非阻塞编排。当然池也不是越多越好,每个池都有线程、队列和监控成本,同类任务可以复用。

给线程池和线程命名

默认的 pool-1-thread-3 在线程转储里毫无意义,事故排查时等于大海捞针。自定义 ThreadFactory 的成本只有几行:

public final class NamingThreadFactory implements ThreadFactory {
private final AtomicInteger seq = new AtomicInteger();
private final String prefix;

public NamingThreadFactory(String prefix) {
this.prefix = prefix;
}

@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r);
t.setName(prefix + "-" + seq.incrementAndGet());
return t;
}
}

jstack、日志和监控里出现 order-query-7,就能直接把范围缩到具体业务。同理,线程池 Bean 本身也要起有意义的名字,监控指标按池名打标签。

监控指标:盯住四组数

ThreadPoolExecutor 自带的统计足够搭一套基础监控:

  • 活跃线程数(getActiveCount()):长期贴着 max 说明池已饱和。
  • 队列深度(getQueue().size()):持续上涨说明消费跟不上;要结合趋势看,从 500 降到 100 和从 10 涨到 100 风险完全不同。
  • 拒绝数:自定义拒绝策略里计数上报,核心业务出现拒绝应立即告警。
  • 任务耗时与排队时间:提交时打时间戳,开始执行和结束时各算一段。排队涨而执行稳,是容量不足;执行先涨、队列随后涨,多半是任务或下游变慢,此时加线程无济于事。

注意这些统计是近似值,看趋势够用,别拿来做强一致性判断。

常见事故案例

  1. 无界队列 OOM:newFixedThreadPool 遇到流量高峰,任务无限堆积把堆打满。教训:队列必须有界。
  2. 忘记关闭池:非守护线程一直存活,JVM 进程退不掉。教训:shutdown + awaitTermination + shutdownNow 三段式,挂到 Spring 的销毁钩子上。
  3. submit 吞异常:异常被封装进 Future,没人调 get() 就悄无声息。教训:消费每一个 Future,或重写 afterExecute 统一处理。
  4. 长任务占满共享池:一个慢导出任务让所有短任务排队超时。教训:快慢分池,长任务走独立资源。
  5. ThreadLocal 脏数据:线程复用导致上一个任务的上下文残留,甚至大对象泄漏。教训:统一的任务包装器在 finally 里清理。

小结

参数用公式估起点、靠压测定终点;按业务隔离防止互相拖累;命名是成本最低的排障投资;监控盯住活跃数、队列深度、拒绝数和排队耗时四组指标。线程池的稳定性不来自某个技巧,而来自这些基础动作的叠加。