某个周五下午线上突然刷出一屏RejectedExecutionException紧接着业务群里就有人问“回调消息怎么越积越多”。我第一反应不是看业务代码而是打开监控面板看了十几个线程池的活跃线程数和队列水位果然有一个池子的核心线程数被写成了 4队列还是无界队列最大线程参数形同虚设。这种问题我见过太多次了线程池出故障绝大多数不是因为“并发量太高”而是参数配得不合理拒绝策略也没想清楚。这篇文章想把线程池拒绝策略、CPU密集与 IO 密集场景下的线程数计算公式一次说透。内容偏实践核心就是那套“黄金公式”怎么来的、怎么用以及四种拒绝策略背后的源码逻辑和线上真实表现。适合对 Java 并发有基本了解、想真正把线程池参数调明白的后端开发者也适合那些已经被线上告警炸过、准备回头好好补课的同学。1. 拒绝策略被触发的那一刻线程池到底在做什么1.1 先看完整执行链路拒绝策略只是最后一道闸门很多人把拒绝策略当成线程池里一个孤立配置项其实它是整条执行链路流水线的最后一环。要理解它得先清楚任务从提交到执行的完整过程提交任务时如果当前工作线程数小于corePoolSize直接创建新线程执行任务哪怕已经有空闲线程依然优先新建直到达到核心线程数。核心线程都忙时新任务会被放入workQueue等待队列入队成功就返回线程处理完手头任务后从队列取任务。队列也满了再尝试创建新线程线程总数不能超过maximumPoolSize。如果线程数已经达到maximumPoolSize队列也满了只能触发RejectedExecutionHandler拒绝策略。这里面最关键也最反直觉的一点是队列和最大线程数是前后置关系队列不满最大线程数这一个参数根本没有出场机会。很多线上事故都栽在这上面——为了“稳妥”把队列设成无界或者超长队列结果maximumPoolSize永远不触发任务全部积压在队列中响应延迟越来越高最终把内存或下游打爆。为了方便理解可以把线程池想象成一家咖啡店核心线程是在编的正式咖啡师工作队列是客户手里的排队号最大线程数是高峰期临时调来的外援咖啡师拒绝策略则是店长在“实在接不下单”时跟客户怎么交代。咖啡师满员、排队号也发完了、外援也全都在干活再进来的客户总不能让他干等总得有个明确说法——这个“说法”就是拒绝策略。1.2 四个内置拒绝策略逐个拆解含源码级理解ThreadPoolExecutor提供了四个内置拒绝策略都是RejectedExecutionHandler接口的实现类。每个策略本质就几行代码但业务影响天差地别。AbortPolicy默认策略public void rejectedExecution(Runnable r, ThreadPoolExecutor e) { throw new RejectedExecutionException(Task r.toString() rejected from e.toString()); }直接抛RejectedExecutionException任务不执行提交方调用execute()的线程立刻收到异常。为什么 JDK 默认选这个因为“宁可让你知道系统扛不住了也不能让任务无声无息地消失”。如果你在任务里没有捕获运行时异常这个异常会沿着调用栈往上抛在execute()的调用点直接暴露适合希望立刻感知过载、能接受失败重试或者有降级兜底的业务。CallerRunsPolicypublic void rejectedExecution(Runnable r, ThreadPoolExecutor e) { if (!e.isShutdown()) { r.run(); } }不抛异常也不丢弃而是让“提交任务的那个线程”自己去执行这个任务。比如提交方是一个请求处理线程那这个请求线程会直接跑任务跑完才继续处理下一个动作等于在源头形成了自然背压。这是“不想丢任务”时比较实用的选择但要注意如果调用方是线程组或者高并发入口这个策略会把压力反向传导给调用方导致整条链路变慢甚至雪崩。DiscardPolicypublic void rejectedExecution(Runnable r, ThreadPoolExecutor e) { }空实现直接忽略这个任务静默丢弃。这个策略是四个里最危险的。它不会告诉你任何信息任务就这么“人间蒸发”了如果任务里有订单状态更新、消息通知之类的关键操作数据丢失都未必能及时察觉。我在踩过一次坑之后就立了个规矩生产环境禁止使用 DiscardPolicy除非你能百分之百确认任务丢了也无所谓。DiscardOldestPolicypublic void rejectedExecution(Runnable r, ThreadPoolExecutor e) { if (!e.isShutdown()) { e.getQueue().poll(); e.execute(r); } }把队列里最老的任务丢弃然后重新尝试提交新任务思路是“腾位置给新任务”。这个策略本身逻辑自洽但有一个隐藏很深的坑如果workQueue用的是PriorityBlockingQueuepoll()丢掉的并不是最老的任务而是优先级最高的任务。你用优先级队列时往往是把重要任务排在前面的结果被这个策略直接丢了最不该丢的线上真的遇到过。四个策略的横向对比我整理成一张表方便不同场景直接选策略处理动作对提交线程的影响典型适用场景风险点AbortPolicy抛异常拒绝任务提交线程收到异常需要立刻感知过载并触发告警未捕获异常可能导致局部故障放大CallerRunsPolicy提交线程自己执行任务提交线程被占用而变慢不允许丢失任务、愿接受回压回压可能沿调用链向上传导DiscardPolicy静默丢弃任务提交线程无感知允许丢数据的非核心场景业务数据丢失难以定位DiscardOldestPolicy丢弃队头最老任务后重新入队一般无感知新任务比老任务更有价值优先级队列下会误丢高优任务1.3 自定义拒绝策略的正确姿势内置策略不够用的时候可以自己实现RejectedExecutionHandler。实际项目中我见过比较实用的自定义策略有几种拒绝后把任务写入本地文件或数据库任务表由定时补偿任务拾取重试。拒绝后发送告警消息到监控群同时记录拒绝次数交由人工介入处理。拒绝后把任务暂存到 Redis 延迟队列错峰重放。给一个相对通用的自定义拒绝策略示例里面同时做了计数和告警核心思路是不会因为一次拒绝就把任务弄丢public class AlarmRejectedHandler implements RejectedExecutionHandler { private final AtomicLong rejectedCount new AtomicLong(0); Override public void rejectedExecution(Runnable r, ThreadPoolExecutor e) { long count rejectedCount.incrementAndGet(); // 实际项目里这里替换成监控上报/告警通知 System.err.println(线程池任务被拒绝累计次数: count); } public long getRejectedCount() { return rejectedCount.get(); } }用它构造线程池时直接把实例传给ThreadPoolExecutor构造器的最后一个参数即可。需要提醒的是自定义拒绝策略一定不要在这个方法里做耗时操作因为它是被提交任务的线程同步调用的处理太久会直接拖慢业务。2. 拒绝策略与线程池参数的联动参数没配好策略再对也白搭2.1 队列长度决定最大线程数有没有资格出场这是我特别想强调的一个点。前面说过队列不满就不会创建超过核心线程数的线程。举个极端例子corePoolSize 4maximumPoolSize 10queue LinkedBlockingQueue(10000)。在这种情况下前 4 个核心线程忙不过来时第 5 个任务开始进队列要到队列攒满了 10000 个任务才会创建第 5 个线程。高峰流量下你看着activeCount一直是 4任务却在队列里越堆越多内存上涨、延迟飙升而最大线程数的 10 根本没有意义完全被队列“藏”了起来。把话说直白点队列越长maximumPoolSize越形同虚设。两者必须放在一起权衡。如果你的设计目标是“高峰期允许临时扩线程顶流量”队列就不能设太长否则扩线程的机制永远触发不了。反过来如果你非常依赖队列做缓冲希望用队列吸收流量尖峰那最大线程数就不要潜意识里当它是兜底资源。生产环境里队列容量有一个比较容易上手的估算思路以“业务允许的最长等待时间”为锚点。比如任务每秒入队 1000 个业务能接受任务在队列里最多等 5 秒那队列容量可以设置在 5000 左右。想再保守一点结合线程的消费速度队列积压 入队速率 × 等待时间 − 线程消费量最好再留 20% 到 30% 余量。2.2 线程存活时间、线程工厂等参数的配合逻辑很多人把keepAliveTime当成一个“随便填 60 秒就行”的参数其实它决定了非核心线程的空闲回收时机。当线程数超过corePoolSize空闲线程等待超过keepAliveTime就会被回收这是为了在流量回落后释放资源。如果你设的太短比如 5 秒高峰期线程刚创建又开始回落可能频繁创建销毁线程设得太长比如 600 秒流量低谷期资源还占着。一般 30 到 60 秒是比较常用的区间具体看业务峰谷节奏。还有一个容易被忽略的点是allowCoreThreadTimeOut(true)。默认情况下核心线程即使空闲也不会被回收池子里始终有核心线程数那么多线程在待命。但在夜间低峰的场景中这些核心线程就等于白白占用内存。把allowCoreThreadTimeOut打开之后核心线程空闲超过keepAliveTime也会退出适合 7×24 小时运行但流量有明显波动的服务。ThreadFactory也必须自己配但很多团队懒得做。用默认工厂创建的线程名是pool-1-thread-1这种格式线上jstack下来根本分不清是哪个业务线程池。自定义工厂只是改个名字的事ThreadFactory factory r - { Thread thread new Thread(r, biz-order-pool- ThreadLocalRandom.current().nextInt(1000)); thread.setDaemon(false); return thread; };线程名字带上业务标识以后排查问题时的效率提升是肉眼可见的。你不需要靠猜去反查线程是哪个池子创建的jstack一眼就能看出来。2.3 有界队列 明确拒绝策略 监控告警才是生产标准组合我认为生产环境里能被叫做“标准配置”的组合应该是有界队列 一种明确的拒绝策略 拒绝监控告警三者缺一不可。有界队列保证系统有明确的容量上限不会因为流量尖峰把内存打爆明确的拒绝策略保证过载时可以预期、可处理监控告警保证每次拒绝都能被看到而不是业务异常攒了一堆才发现。之前在某支付系统的回调模块干过一例任务写死成newCachedThreadPool没有核心线程、没有队列限制高峰期每秒创建几千个线程直接把系统资源打满。改成有界队列 AbortPolicy 之后拒绝发生时消息会推送告警过载链路自动进入降级流程。同样的流量系统反而更稳。原因很简单无界扩张的代价是不可控的资源争抢有界加上拒绝策略后系统在明确边界内运行剩下的问题只是“边界设多大”。3. CPU 密集与 IO 密集的黄金公式到底是怎么推导出来的3.1 CPU 密集型任务N1 背后的道理先共识一个概念N是Runtime.getRuntime().availableProcessors()拿到的 CPU 核心数不是逻辑线程数那种花哨指标就是物理可并行执行的任务单元数。CPU 密集型任务的特点是线程几乎不等待外部资源就一直在做计算。这种场景下真正能同时执行的线程数上限就是核心数。设成 N 是不是就够了理论上够但实际运行时有扰动因素线程偶尔会因缺页中断、内存访问停顿而短暂让出 CPU。JVM 的 GC 线程会占用短暂 CPU 时间。操作系统本身还有其他系统线程在调度。多出来的那 1 个线程就像篮球比赛里的替补队员在某一个内核短暂停顿时能补上去不至于让 CPU 内核闲着。这也是为什么 N1 在业内被称为“保底公式”。但有一点必须说清楚CPU 密集型任务的线程数不是越多越好。一旦超过 N1线程之间开始频繁竞争 CPU 时间片上下文切换的代价会吃掉性能任务总耗时反而可能上升。8 核机器开 8 个线程跑一个纯计算任务和开 16 个线程跑同样任务后者通常会慢一些因为光保存和恢复线程上下文就已经在烧性能了。所以 CPU 密集场景下用 N1 作为初始值最多压测时微调不要妄图靠线程数把 CPU “多用几倍”。3.2 IO 密集型任务从 N*(1W/C) 到 2N 的完整推导IO 密集型任务里线程大部分时间不是在计算而是在等待外部资源返回。这里的等待对象包括数据库查询、远程 HTTP 调用、消息中间件读写、文件 IO 等。假设一次任务里真正 CPU 计算时间为C毫秒等待时间为W毫秒那么一个线程完成一趟任务的总耗时是C W。在这段时间里CPU 真正被这个线程占用的只有C毫秒。一个核上的 CPU 资源是固定的如果只放一个线程这个核在W毫秒里就完全是闲置的。想让 CPU 不闲就得在时间轴上叠加多个线程一个线程在等待时另一个线程来跑它的计算片段。每个核上能放多少个这样的“交替线程”答案是(C W) / C个。总线程数再乘以核心数 N线程数 N × (C W) / C N × (1 W/C)这就是黄金公式。为了直观我举个具体例子。假设你的机器是 8 核某个任务平均计算耗时 20msIO 等待耗时 80ms那么 W/C 4合理的线程数 8 × (1 4) 40 个。如果你按默认经验填个 8 或者 10CPU 大量时间在打盹任务吞吐根本拉不上去。另一个常见写法是线程数 N / (1 − 阻塞系数)其中阻塞系数 W / (C W)。把分母展开看1 − 阻塞系数 C / (C W)所以 N / (1 − 阻塞系数) N × (C W) / C跟上面的公式本质是同一个式子只是从另一个角度表达而已。如果 W 和 C 很难精确统计工程上还有一条经验值IO 密集型初期直接用 2N。这个 2N 对应的隐含假设是 W/C ≈ 1即等待时间和计算时间差不多此时每个核上放两个线程。很多业务接口恰好落在类似比例上所以 2N 成了流传最广的经验值。要提醒的是2N 只适合“做初始值”不能拿来当最终答案。你现在可以先去压测环境跑一轮看看 QPS、延迟、CPU 占用率再决定往上调还是往下调。下面这张表可以直观看到 W/C 比例对线程数的影响N 8即 8 核机器场景特征计算耗时 C等待耗时 WW/C黄金公式线程数纯计算100ms0ms08 或 9轻度 IO50ms50ms116中度 IO20ms80ms440重度 IO5ms95ms19160重度 IO 场景算出来 160 个线程看着很多但要注意下方还有一个资源上限问题。3.3 公式之外的资源约束连接数、内存、下游容量黄金公式给了你一个“理论上的理想线程数”但实际能设多少还要受其他资源的硬约束。最常见的是数据库连接池。如果每个任务执行时都要拿一个数据库连接而连接池最大只有 50 个你按公式算出 80 个线程那么必然有 30 个线程拿不到连接在等待连接的过程中白白占着线程资源甚至互相等待形成死锁。此时线程数得向连接池看齐公式值要跟连接池上限取最小值。第二个约束是内存。每个线程都有独立的线程栈默认大小通常在 1MB 左右JVM 参数可调1000 个线程就意味着近 1GB 的栈内存。再加上队列里积压的任务对象内存很容易成为瓶颈。所以当公式算出上百线程时先冷静看一眼堆内存和线程栈设置。第三个约束是下游依赖方的承载能力。你把线程数调到 200确实能让上游任务快速被消费但如果下游服务只能扛每秒 100 并发那多出来的线程只会排队等下游响应线程堆积反而加剧问题。IO 密集型调优从来不只盯着一方要看整条链路里最薄弱的环节。4. 生产环境参数调优实操从初始化到动态调整4.1 推荐初始参数与一次算齐的步骤与其看一堆抽象规则不如直接给一套可落地的初始参数模板。假设场景是一个 16 核 CPU 的支付回调任务处理服务任务是 IO 密集型的每次要发起一个远程服务调用平均计算耗时 5ms等待耗时 25ms。第一步确定核心线程数 CORE 16 × (1 25/5) 96。第二步确定最大线程数。最大线程数的作用是吸收突发流量一般为核心线程数的 1.5 到 2 倍预留一定余量但不至于无限扩张。建议此处取 144 左右。如果核心线程数本身已经很大这个倍数可以降到 1.2避免过度预留资源。第三步确定队列容量。假设业务高峰期每秒提交约 800 个任务允许任务在队列里最多等 3 秒队列容量 ≈ 800 × 3 2400取整加一点余量设 2500 是合理的。如果算出来队列比任务积压容忍度还大那说明你要么调大核心线程数要么重新评估业务对延迟的容忍度。第四步定存活时间和拒绝策略。存活时间 60 秒是保守值拒绝策略先选 AbortPolicy 并配上告警如果要更稳可以自定义一个“记录并上报”的 handler。这套流程整合下来的核心思路是先算公式值再约束资源上限最后压测验证。不要一步到位写死留出调参空间。4.2 完整代码示例按公式初始化线程池把上面的场景落成代码大概是下面这样public class IoIntensiveThreadPoolExample { private static final int N_CPU Runtime.getRuntime().availableProcessors(); // 假设 W/C 5 private static final int CORE (int) (N_CPU * (1 5)); private static final int MAX (int) (CORE * 1.5); private static final int QUEUE_CAPACITY 2500; public static ThreadPoolExecutor createPool() { ThreadPoolExecutor executor new ThreadPoolExecutor( CORE, MAX, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(QUEUE_CAPACITY), r - { Thread t new Thread(r, biz-callback-pool- ThreadLocalRandom.current().nextInt(10000)); t.setDaemon(false); return t; }, new AlarmRejectedHandler() ); return executor; } public static void main(String[] args) { ThreadPoolExecutor executor createPool(); for (int i 0; i 1000; i) { final int taskId i; executor.execute(() - { // 模拟 IO 等待 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } System.out.println(任务 taskId 执行完成); }); } executor.shutdown(); } }这里有一个容易被新手忽略的小细节ArrayBlockingQueue和LinkedBlockingQueue在传入容量时的行为不同ArrayBlockingQueue是必须有界队列而LinkedBlockingQueue不传容量时默认是Integer.MAX_VALUE的无界队列。很多线上事故就是 “new LinkedBlockingQueue() 不传参” 造成的千万记得传容量。4.3 运行时动态调参参数不要写死在配置里线程池的四个核心参数都提供了 setter 方法可以在运行中调整setCorePoolSize(int)setMaximumPoolSize(int)setKeepAliveTime(long, TimeUnit)这意味着参数调优不必重启服务。实际操作中我会把线程池参数放到配置中心让 QA 在压测时能动态调整观察不同参数组合下的表现后再固化下来。比如收到告警说拒绝次数持续增加可以先临时调大maximumPoolSize而不是半夜赶着改代码发版。但动态调参有一个注意点调小maximumPoolSize时现有线程不会立即中断只是停止新建线程线程数会随着空闲逐渐降下来。调小corePoolSize同理。所以动态调参适合用于“渐变的资源收缩”不适合做紧急抢救——紧急情况更稳妥的办法是配合线程池的拒绝策略做降级而不是硬调参数。4.4 线程池预热与优雅关闭两个和管理相关的实操细节。第一个是预热。线程池默认是懒创建线程的第一个任务进来时才创建核心线程高峰期第一波请求可能要额外承担线程创建的开销。如果想让线程池启动时就就绪可以调用executor.prestartAllCoreThreads();这个操作非常实用比如定时任务调度系统启动时先把核心线程全部拉起来避免高峰期首波激增时临时建线程。第二个是优雅关闭。关闭线程池时不要直接调用shutdownNow()除非你确定任务可以立刻中断。推荐顺序是executor.shutdown(); // 不再接受新任务已提交任务继续执行 if (!executor.awaitTermination(30, TimeUnit.SECONDS)) { executor.shutdownNow(); // 等待超时后强制取消未完成任务 }5. 监控指标与常见问题排查实录5.1 调优必须盯住的核心指标调优之后怎么判断效果不能凭感觉说一句“看起来快了”。建议至少盯住这几个指标活跃线程数线程池里正在执行任务的线程数。如果长期贴近corePoolSize且队列水位同步上涨说明核心线程可能不够用。队列积压量当前排队任务数这个指标比线程数更早暴露问题。队列持续上涨意味着消费跟不上生产。拒绝次数自定义计数器或RejectedExecutionException出现次数。出现就是过载信号必须配告警。完成任务总数/吞吐单位时间完成任务数用来验证参数调整是否带来了吞吐提升。线程创建销毁频率如果看到线程数频繁波动大概率是keepAliveTime太短线程反复创建销毁。监控代码其实很简单就是定时把算子暴露给监控系统public void reportMetrics() { log.info(active{}, queueSize{}, completed{}, poolSize{}, rejected{}, executor.getActiveCount(), executor.getQueue().size(), executor.getCompletedTaskCount(), executor.getPoolSize(), alarmHandler.getRejectedCount()); }5.2 常见问题速查表与踩坑记录现象可能原因解决参考任务静默丢失日志无异常使用了 DiscardPolicy换 AbortPolicy 或自定义落库策略加拒绝告警CPU 不高但任务处理很慢队列太长线程数起不来调短队列或调大 corePoolSize内存持续上涨直到 OOM用了无界队列任务疯狂积压换有界队列设置容量上限数据库连接池被打满线程数太大每个任务长占连接线程数向连接池上限看齐缩短事务时间拒绝异常大量出现线程数与队列不匹配过载动态调参 拒绝告警 链路降级jstack 里线程名全是 pool-x 无法区分没自定义 ThreadFactory自定义命名带上业务标识5.3 一次真实线上问题排查实录我用一个虚构但极其典型的生产案例把完整的排查思路串起来。某系统有一个“通知回调任务”线程池负责把商家订单状态变更推送给下游服务。某天业务方反馈说“回调消息有延迟”监控面板显示队列积压量从几百涨到几万CPU 使用率只有 40%。第一轮排查先看线程池参数结果是corePoolSize 4maximumPoolSize 20队列用了new LinkedBlockingQueue()无界队列拒绝策略是默认的AbortPolicy。看到这里问题已经清楚了大半队列无界导致maximumPoolSize的 20 几乎不会触发核心线程 4 对于回调这种 IO 密集场景远远不够任务全部堆在队列里等待积压量自然一路飙升。第二轮排查验证任务类型。回调任务要发 HTTP 请求给下游商家服务器一次任务大概 10ms 计算、90ms 等待这是一个典型的 IO 密集型任务。16 核机器上按黄金公式计算线程数 16 × (1 90/10) 160。当前核心线程 4差距接近 40 倍积压完全是必然的。第三轮处理先用动态调参把核心线程数临时拉高到 160再把队列换成有界ArrayBlockingQueue(2000)拒绝策略换成自定义的告警策略。积压在几分钟内被消费完毕监控慢慢回落。这个案例里最核心的问题不是“流量太高”而是参数设计时没有基于任务类型来定线程数。如果当初按公式先算一轮把初始值就在 96 到 160 的区间后来这一串告警大概率都不会发生。工作里我每看到一个线程池脑子里第一反应永远是三个问题这个池子处理的任务是 CPU 密集还是 IO 密集它的 W/C 大致是多少队列和最大线程数是不是匹配的把这三个问题想明白调参就不会再靠拍脑袋。最后再给一句实在话黄金公式只是出发点真正靠谱的线程数一定是在压测环境里一轮一轮跑出来的——先用公式算出合理区间再根据监控数据慢慢收敛比直接抄网上的某个数字要靠谱太多。