
我印象特别深的一次线上事故订单服务在晚高峰突然开始疯狂打印RejectedExecutionException当时值班的同事第一反应是线程池满了。结果登上机器一看活跃线程只有3个核心线程池配的也是4最大线程配的是10队列是无界的LinkedBlockingQueue——按道理说这个配置永远不会拒绝任务可偏偏它就拒绝了。这个看似矛盾的现象背后藏着的正是线程池执行流程里最容易被忽略的一环无界队列只是永远不会因为队列满而创建新线程但当任务提交本身出问题时拒绝策略照样会触发。搞懂线程池到底是怎么把任务从提交送到执行、每一步分流逻辑是什么、为什么非核心线程反而比队列晚一步介入这篇文章应该能给你一个完整的答案。无论你用的是Java的ThreadPoolExecutor、C的手写线程池还是Go的协程池这套设计思想基本都是相通的。1. 一次任务提交的完整旅程从execute()到Worker真正跑起来很多人对线程池执行流程的理解是线程不够了就去开新线程这句话只对了一半。准确地说线程池是个非常保守的资源管理者它的原则是能用核心线程扛就绝不动队列能排队就绝不新建线程万不得已才扩容扩容也满了才拒绝。理解这个顺序是看懂一切的前提。1.1 先看核心分流逻辑execute()里的三次判断先看一段最典型的ThreadPoolExecutor执行入口实际源码略做精简但判断顺序一字未改public void execute(Runnable command) { if (command null) throw new NullPointerException(); int c ctl.get(); // 第一步工作线程数小于核心线程数直接创建核心线程 if (workerCountOf(c) corePoolSize) { if (addWorker(command, true)) return; c ctl.get(); } // 第二步线程数已到核心线先把任务丢进队列 if (isRunning(c) workQueue.offer(command)) { int recheck ctl.get(); if (!isRunning(recheck) remove(command)) reject(command); else if (workerCountOf(recheck) 0) addWorker(null, false); } // 第三步队列也满了尝试创建非核心线程最大线程数内 else if (!addWorker(command, false)) // 第四步非核心线程也满了执行拒绝策略 reject(command); }这段代码就是整个线程池的交通警察。四次判断对应四种处理路径顺序是死的一定不能乱先核心、再队列、再非核心、最后拒收。注意第二步这里workQueue.offer(command)是非阻塞的队列满了就立刻返回false不会让提交线程卡住等待——这正是整个执行流程里最关键的设计取舍之一。1.2 为什么先队列、后非核心线程这个顺序反直觉但非常合理大多数人的直觉是核心线程不够了应该先扩容到最大线程队列只是最后兜底。但JDK的设计恰恰相反。原因在于新建线程是有代价的——创建线程要分配栈内存、要触发系统调用线程销毁还要回收。一个线程如果只为了处理一个任务而创建任务跑完就销毁那还不如让任务先排队等着让核心线程忙完再处理。你可以把线程池想象成一个餐厅后厨核心线程是固定的几个厨师队列是等菜的外卖订单架子非核心线程是临时雇来的帮工。正常节奏下厨师做完手上的菜就去架子上拿下一个订单订单多到架子放不下的时候后厨才会考虑临时加人。加人有培训成本、有设备占用不是一忙就加。这个设计还有个很微妙的优势它天然实现了削峰填谷。流量瞬间暴涨时任务先在队列里堆积核心线程以恒定速率消费不会因为突发的任务洪峰而创建大量线程等流量过去线程又要销毁——白白浪费资源。1.3 Worker启动后到底干了什么一个线程为什么能反复执行任务线程池里的线程和普通new Thread()出来的线程最大的区别在于它们不是执行完一个任务就死掉的。addWorker()创建的是一个活的循环体final void runWorker(Worker w) { Thread wt Thread.currentThread(); Runnable task w.firstTask; w.firstTask null; while (task ! null || (task getTask()) ! null) { // 执行任务处理中断和异常 task.run(); task null; } }核心逻辑就是runWorker里的那个while循环先执行firstTask就是创建线程时传进来的那个任务执行完后进入getTask()从队列里取下一个任务取不到就阻塞等待。线程一旦空闲下来它不是在睡觉而是在队列上阻塞着等待新任务。getTask()里有几个容易被忽略的细节Runnable getTask() { boolean timedOut false; for (;;) { int c ctl.get(); int wc workerCountOf(c); // 核心线程是否允许超时回收 boolean timed allowCoreThreadTimeOut || wc corePoolSize; if ((wc maximumPoolSize || (timed timedOut)) (wc 1 || workQueue.isEmpty())) { if (compareAndDecrementWorkerCount(c)) return null; continue; } Runnable r timed ? workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) : workQueue.take(); if (r ! null) return r; timedOut true; } }这里有两个分支非核心线程用poll(keepAliveTime)超过keepAliveTime还拿不到任务就超时返回null线程退出核心线程用take()无限期阻塞等待除非开启了allowCoreThreadTimeOut。这也解释了为什么线程池默认情况下核心线程不会死非核心线程空闲一段时间后会被回收。从C的角度看手写线程池的思路几乎一模一样一个任务队列加若干工作线程线程循环从队列里取任务执行加互斥锁和条件变量控制等待和唤醒。区别仅在于Java把这些封装成了APIC需要自己管理生命周期。2. 阻塞队列选型这个看似不起眼的容器决定了线程池的上限与风险队列在线程池执行流程里是一个承上启下的角色。它在时间顺序上排在非核心线程扩容之前意味着队列的容量和类型直接决定了非核心线程有没有机会被创建。选错队列比配错线程数更隐蔽也更致命。2.1 SynchronousQueue不存任务的直接交接SynchronousQueue没有内部容量它做的事情是一手交任务、一手交线程——提交线程调用put()或offer()时必须有一个消费者线程在另一端等着否则就配对失败。虽然它实现了BlockingQueue接口但它不存储任何元素。这个队列配合线程池使用时执行流程会变成这样核心线程全部忙碌时新任务offer()到SyncQueue直接失败立刻触发非核心线程创建。这样线程池上限就不再受队列容量影响而是直接由maximumPoolSize决定。Executors.newCachedThreadPool()就是这种组合——SynchronousQueue 很大的最大线程数 60秒存活时间适合执行大量短小、互不阻塞的异步任务。注意风险配合无界队列时如果任务生产速度始终快于消费速度线程数会不断攀升直到打满maximumPoolSize甚至造成线程创建开销过大。这种队列本质上就是宁可开线程绝不排队。2.2 LinkedBlockingQueue默认无界最顺手也最容易翻车LinkedBlockingQueue如果不指定容量默认是Integer.MAX_VALUE——一个几乎永远装不满的无底洞。放在线程池里它的效果就是核心线程全忙时新任务永远能offer()成功第三步addWorker(command, false)永远走不到maximumPoolSize形同虚设。这就是我开头说的那起事故的根源之一。很多人配了核心线程数4、最大线程数10觉得这个参数很合理但队列用了默认的LinkedBlockingQueue——实际上线程数这辈子都不可能超过4个。无界队列只在一种场景下是安全的你对任务生产速率有绝对信心愿意让它们排队等核心线程慢慢处理同时不在乎延迟。任何涉及外部接口调用、突发流量、用户交互的场景无界队列都是一个隐雷——它不会报错只会让任务积压越来越深响应时间越拉越长最后内存被打爆。2.3 ArrayBlockingQueue有界数组让拒绝策略真正发挥作用ArrayBlockingQueue容量固定数组实现一旦队列满了offer()立刻返回false线程池就会走向创建非核心线程分支继而走向拒绝策略。这才是一个参数可控的线程池该有的样子任务太多时快速失败总比无限积压好至少业务方会立刻感知到压力而不是等到超时或OOM才追悔莫及。选它的时候要同时想清楚两件事队列容量设多少才合理队列满之后的拒绝策略是抛异常、丢弃还是调用方自己处理这两件事是联动设计不能只调一个参数。后面第三章我会给一个完整的配置计算方式。2.4 延迟与优先级两个被低估的阻塞队列PriorityBlockingQueue和DelayedWorkQueue都属于带特殊语义的队列分别应用于优先级任务和延迟任务。线程池本身不关心任务的优先级但队列的出队顺序决定了getTask()拿到的是哪个任务所以选对了队列就能实现紧急任务先执行或定时任务到点执行。ScheduledThreadPoolExecutor内部用的就是DelayedWorkQueue——它按照任务的延迟时间排序getTask()从队首取的是最近到期的那个任务。SynchronousQueue在JDK的Executors.newCachedThreadPool()里用的就是它。个人经验是自定义线程池时用到延迟队列的机会不多但如果你要做一个调度器完全不需要再引入额外中间件一个ScheduledThreadPoolExecutor加一个DelayedWorkQueue就够了。队列容量出队顺序适用场景配合线程池的效果SynchronousQueue0直接交接短任务、高并发无堆积非核心线程会被频繁创建LinkedBlockingQueue可配置默认无界FIFO生产速率稳定的批处理默认配置下最大线程数失效ArrayBlockingQueue固定FIFO流量可控的业务场景队列满时触发扩容/拒绝PriorityBlockingQueue可配置按优先级需要区分紧急程度的任务优先级高的任务先被消费DelayedWorkQueue无界按延迟时间调度/重试任务到期任务才被取出执行3. 参数不是拍脑袋核心线程、最大线程、存活时间到底怎么配线程池的参数看着就四个——corePoolSize、maximumPoolSize、keepAliveTime、workQueue但真正配好的人不多。大多数团队的配置方式是从网上抄一个公式或者干脆用Executors的四种现成线程池。用现成的后果是没搞清楚它内部执行流程的约束线上出了问题才回过头来查。3.1 三层联动的资源模型线程池的容量由三层构成第一层是corePoolSize个核心线程常驻不回收第二层是队列容量起缓冲作用第三层是maximumPoolSize - corePoolSize个非核心线程临时扩容通道。这三层有严格的填充顺序但这里的容量不是相加关系而是覆盖关系——核心线程满了先走队列队列满了才扩容非核心线程。所以你在配置的时候要回答三个问题平时正常的负载下需要几个线程常驻处理corePoolSize流量波峰到来时最多能接受多少个任务排队等待队列容量如果队列都堵满了最多能扩张到多少个线程去救火maximumPoolSize这三个答案不是随便写的要根据业务类型推算。3.2 CPU密集型和IO密集型的核心线程数差异业界最经典的估算公式是CPU密集型核心线程数 ≈ CPU核数 1。因为任务几乎不阻塞线程多了只会增加上下文切换开销1是为了在某些线程发生缺页中断或偶发暂停时顶上。IO密集型核心线程数 ≈ CPU核数 * 2或者更精细一点CPU核数 / (1 - 阻塞系数)。阻塞系数一般取0.8~0.9意思是任务里有80%~90%的时间都在等待IO。你调外部接口、查数据库、读文件线程都在阻塞状态这时候多开线程才能把CPU利用率打满。举个例子一台4核8线程的服务器业务是处理订单消息每条消息要查一次数据库IO密集阻塞系数大约0.8。那么corePoolSize 4 / (1 - 0.8) 20。如果你们机器常年4核、业务不复杂我一般建议先配corePoolSize2*cpu线上跑几天看真实曲线再微调——公式只是起点不是终点。3.3 两个被90%的人忽略的开关allowCoreThreadTimeOut和prestartAllCoreThreads是ThreadPoolExecutor里两个名字很长、出场率极低、但关键时刻特别好用的参数。allowCoreThreadTimeOut(true)允许核心线程也超时退出。如果你的核心线程数是20但真实的业务波动很大——白天忙夜里闲——你不希望深夜还挂着20个线程吃内存就可以开这个开关。注意开启后keepAliveTime对核心线程同样生效如果所有线程都空闲超时了线程数会归零下一个任务来时再全部重新创建。所以这个开关只适合任务稀疏且不想常驻线程的场景。prestartAllCoreThreads()会在线程池创建时立刻启动所有核心线程。默认情况下核心线程是懒加载的——第一个任务来了才创建第一个线程第二个来了才创建第二个。如果你的服务对首个请求的延迟很敏感比如网关、接入层这个预启动就很有价值能避免前N个请求因为线程创建而变慢。3.4 一个可落地的配置模板我拿一个订单处理场景举例假设是4核8G的机器任务内容是反查订单详情、调外部风控接口平均耗时200msThreadPoolExecutor orderPool new ThreadPoolExecutor( 8, // 核心线程4核 * 2 16, // 最大线程核心线程 * 2防止IO阻塞打满 60L, TimeUnit.SECONDS, // 非核心线程空闲60秒回收 new ArrayBlockingQueue(200), // 队列容量200积压超过200就扩容到16线程 new NamedThreadFactory(order-handler), new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝时由调用线程自己执行 );这个配置的思路是核心8个线程扛常规流量200ms每个任务8个并发一秒大概能处理40个任务。队列200个格子最多再缓冲200个任务等价于给系统5秒的风暴缓冲时间。如果队列也满了说明高峰期在一秒内来了超过240个任务此时扩容到16个线程去救火。如果16个线程也扛不住CallerRunsPolicy让提交任务的线程自己执行——相当于反馈式限流把压力传导给上游而不是硬生生丢任务。这是我比较推荐的组合。AbortPolicy默认策略在高并发下会直接抛异常让业务失败很多时候不是最优选择。4. 线上排查从异常现象反推线程池的真实状态配置讲完了回到文章开头那个事故。当时的配置核心4、最大10、无界队列理论上是不会因为队列满而触发拒绝的。那为什么会RejectedExecutionException排查思路应该从上到下走一遍完整流程。4.1 排查链路第一步看清异常堆栈指向的提交点RejectedExecutionException是运行时异常它的堆栈只会打到你调execute()或submit()的那行代码看不到线程池内部的任何状态。所以第一件事不是去看异常本身而是确认这个线程池实例到底是不是你以为是的那一个。很多人用Spring的ThreadPoolTaskExecutor时一个Async注解可能指向多个不同的池子结果排查了半天发现根本不是同一个实例。用命名线程工厂特别重要。我给所有线程池都要求配threadFactory并设置业务前缀ThreadFactory factory new ThreadFactory() { private final AtomicInteger seq new AtomicInteger(1); Override public Thread newThread(Runnable r) { Thread t new Thread(r, order-handler- seq.getAndIncrement()); t.setDaemon(false); return t; } };这样jstack打出来线程名一眼就能看出来是哪个业务池子的线程不用去翻代码确认。4.2 无界队列为什么会拒绝任务线程池状态比你想象的更重要回到执行流程代码注意第二步里的这段逻辑if (isRunning(c) workQueue.offer(command)) { int recheck ctl.get(); if (!isRunning(recheck) remove(command)) reject(command); }isRunning(recheck)判断的是线程池的运行状态只有在RUNNING状态下才接受新任务。当线程池被shutdown()之后execute()会直接尝试addWorker失败然后走到reject()——哪怕队列是空的也会拒绝。所以那个核心4、最大10、无界队列的服务之所以被拒绝很可能根本不是队列满了而是业务高峰期线程池被其他代码调用了shutdown()或者应用正在优雅停机Spring容器销毁了线程池此时还在提交任务的代码就疯狂打异常。无界队列只是让这个问题更难暴露一旦暴露就说明线程池生命周期管理出了问题。这个教训是排查拒绝异常时先看线程池状态再看队列和线程数顺序不能反否则容易被无界队列的假象带偏。4.3 监控线程池运行状态的正确姿势线上排查时最直接的手段是加一个定时任务周期性打印线程池的四个关键指标public String poolStats(ThreadPoolExecutor pool) { return String.format( poolSize%d, active%d, core%d, max%d, queueSize%d, completed%d, pool.getPoolSize(), pool.getActiveCount(), pool.getCorePoolSize(), pool.getMaximumPoolSize(), pool.getQueue().size(), pool.getCompletedTaskCount() ); }poolSize当前存活的线程数含空闲active正在执行任务的线程数queueSize队列积压量——这个指标最关键它能真实反映任务生产速度 消费速度completed累计完成任务数用来计算吞吐量趋势如果active长期等于poolSize说明线程始终繁忙如果queueSize持续增长说明核心线程数不够应该扩容核心线程而不是傻等如果poolSize频繁波动说明keepAliveTime太短或任务到达有周期性峰值非核心线程被反复创建销毁可以考虑调长存活时间或直接调大核心线程数。4.4 线程池用完不shutdown比不拒绝更可怕线程泄漏是线程池最隐蔽的问题。如果你的线程池是在某个组件里创建的组件被反复创建销毁但线程池没有跟着销毁那么每次都会残留N个核心线程——这些线程永远不会退出占着内存和句柄不释放。用久了线程数会涨到几千最典型的表现就是CPU不高、但内存和文件句柄持续上涨。规避办法有三条线程池尽量做成全局单例绑定到Spring容器或静态字段不要在每个业务请求里new。如果是订单处理这类任务密集的场景给线程池里的线程设成非守护线程确保JVM退出时还有任务在执行避免进程被强制结束导致任务丢失。如果确实需要动态创建线程池用完务必在finally里shutdown()并调用awaitTermination()等待线程真正结束再销毁容器。从C的角度看也一样手写线程池的析构函数必须小心要先置停止标志位再notify_all唤醒所有阻塞等待的线程最后join顺序不能反否则会有线程卡在条件变量上导致资源泄漏。这个流程和Java的shutdown()-awaitTermination()是同一个思路。最后说一点我自己的体会线程池的执行流程光看文章记不住我是建议你找个下午自己写个ThreadPoolExecutorcorePoolSize设2maximumPoolSize设4队列容量设3然后用循环提交20个任务在每个关键节点上打日志——哪些任务直接进了核心线程、哪些在队列里排队、哪些被非核心线程接走一步步看很快就通了。我踩过最大的坑就是参数抄别人不问为什么。线程池的执行流程本身并不复杂复杂的是它跟你的业务特性耦合在一起。队列选型、线程数估算、拒绝策略环环相扣牵一发而动全身。与其等线上报警了再连夜排查不如一开始就按照核心线程扛常规、队列缓冲峰值、非核心线程救急、拒绝策略保底这一套思路去设计一步到位。