Java 线程池最佳实践
线程池的问题大多不出在 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 { |
jstack、日志和监控里出现 order-query-7,就能直接把范围缩到具体业务。同理,线程池 Bean 本身也要起有意义的名字,监控指标按池名打标签。
监控指标:盯住四组数
ThreadPoolExecutor 自带的统计足够搭一套基础监控:
- 活跃线程数(
getActiveCount()):长期贴着 max 说明池已饱和。 - 队列深度(
getQueue().size()):持续上涨说明消费跟不上;要结合趋势看,从 500 降到 100 和从 10 涨到 100 风险完全不同。 - 拒绝数:自定义拒绝策略里计数上报,核心业务出现拒绝应立即告警。
- 任务耗时与排队时间:提交时打时间戳,开始执行和结束时各算一段。排队涨而执行稳,是容量不足;执行先涨、队列随后涨,多半是任务或下游变慢,此时加线程无济于事。
注意这些统计是近似值,看趋势够用,别拿来做强一致性判断。
常见事故案例
- 无界队列 OOM:
newFixedThreadPool遇到流量高峰,任务无限堆积把堆打满。教训:队列必须有界。 - 忘记关闭池:非守护线程一直存活,JVM 进程退不掉。教训:
shutdown + awaitTermination + shutdownNow三段式,挂到 Spring 的销毁钩子上。 - submit 吞异常:异常被封装进 Future,没人调
get()就悄无声息。教训:消费每一个 Future,或重写afterExecute统一处理。 - 长任务占满共享池:一个慢导出任务让所有短任务排队超时。教训:快慢分池,长任务走独立资源。
- ThreadLocal 脏数据:线程复用导致上一个任务的上下文残留,甚至大对象泄漏。教训:统一的任务包装器在 finally 里清理。
小结
参数用公式估起点、靠压测定终点;按业务隔离防止互相拖累;命名是成本最低的排障投资;监控盯住活跃数、队列深度、拒绝数和排队耗时四组指标。线程池的稳定性不来自某个技巧,而来自这些基础动作的叠加。

