
凌晨一点线上群突然开始刷屏RejectedExecutionException一条接一条往外蹦。我点进监控面板线程池活跃线程卡在 200阻塞队列积压了上万条任务下游接口响应时间从 50ms 飙到 5 秒。最后查下来根因就是线程池参数配置失误——核心线程数拍脑袋定的队列用了无界队列还把 CPU 密集和 IO 密集的任务混在同一个池子里。如果你写 Java线程池应该是绕不开的话题。它是并发编程里最经典的工具同时也是面试八股文重灾区、线上事故高发区。这篇东西不打算复述教科书里的定义而是从这几年真实踩过的坑出发把ThreadPoolExecutor的参数设计、阻塞队列选型、拒绝策略、动态调参这些事彻底讲明白适合 1-5 年的后端工程师也适合准备 Java 面试、想一次性搞透线程池的人。1. 线程池到底在解决什么问题1.1 每次 new Thread 的成本比你想象中高得多很多刚接触并发的人第一反应就是并行就new Thread呗。这种写法在本地跑一两个 demo 没什么毛病但一到生产环境QPS 稍微上来一点就会出问题。原因在于创建线程不是免费的。JVM 创建一个线程底层要调用操作系统接口来创建原生线程这个过程涉及用户态到内核态的切换还要为线程分配独立的调用栈默认栈大小在 1MB 左右再把线程挂到操作系统的调度队列里。线程多了之后CPU 需要在这些线程之间做上下文切换保存和恢复寄存器、程序计数器、栈指针这些状态。线程数一上百切换开销就能吃掉大量 CPU 资源。用餐厅打个比方你开了一家店每来一桌客人都临时雇一个服务员客人吃完就把人解雇。客人少还无所谓客人一多就会发现招人、培训、辞退的成本远高于客人消费带来的利润。线程池就是那个把服务员长期养在店里的人事制度。固定一批线程常驻任务来了直接派活没任务就让它们待命既省掉反复创建销毁的开销又限制了同时干活的人数不会让系统被并发打爆。1.2 复用只是表面它还在帮你做限流线程池除了复用线程之外还有一个被很多人低估的能力限流。线程池的队列通常是有界的当任务提交速度快于处理速度、队列堆满之后就会触发拒绝策略。这实际上就是一道熔断阀相当于给系统装了一个保险丝。很多系统挂掉不是因为代码逻辑错而是流量超出预期时没人拦。如果你写的是无脑new Thread流量暴涨时 JVM 会把内存一点点耗在创建线程和堆任务上最终直接 OOM甚至来不及发告警。而线程池会在拒绝策略里抛出异常日志、监控、告警全都能接住。这也是为什么线上系统一定要用线程池而不是裸写并发。另外线程池还承担了解耦的任务。提交方只关心“任务交出去了”执行方关心“按什么节奏消费”中间靠队列缓冲可以平滑掉流量尖峰。这种生产者和消费者模式是很多中间件设计的底层逻辑。2. 七个核心参数不能有一个想当然2.1 核心线程数、最大线程数、存活时间ThreadPoolExecutor的构造参数里最基础的四个是corePoolSize、maximumPoolSize、keepAliveTime和unit。corePoolSize是常驻线程数可以理解为正式员工默认情况下即使空闲也不会被回收。maximumPoolSize是允许达到的最大线程数相当于正式员工加临时工的上限。keepAliveTime和unit决定了非核心线程临时工空闲多久之后会被回收。有一个很容易被忽略的配置项allowCoreThreadTimeOut。默认情况下核心线程闲着也不会被回收但有些业务有明显的波峰波谷比如大促期间流量是平时的十倍平时根本用不到那么多核心线程。把allowCoreThreadTimeOut(true)打开后核心线程空闲超时也会被回收资源能得到更充分的利用。不过要提醒一点这个开关打开后核心线程数设置为 0 时线程池会退化submit任务会走额外的判断逻辑性能会有一点损耗日常稳定流量的场景不建议开。2.2 阻塞队列线程池的分诊台workQueue是任务进来后的第一个中转站不同队列的行为差异非常大选错了整个线程池的表现会完全变样。ArrayBlockingQueue基于数组的有界队列容量在构造时固定。可以通过构造参数启用公平锁但默认是非公平的。优点是内存可控不会无限堆积缺点是队列一旦满拒绝策略触发得比较频繁。LinkedBlockingQueue基于链表的队列可以指定容量也可以不指定。Executors.newFixedThreadPool用的就是无界版本这是个天坑后面详细说。SynchronousQueue这个队列不存储任务每个put必须等待另一个线程take才能成功相当于一个线程直接交接的通道。配合较大的maximumPoolSize使用能做到“任务来了就临时加线程空闲了再回收”Executors.newCachedThreadPool就是这种组合。PriorityBlockingQueue支持按优先级出队适合带优先级的任务。但它也是无界队列使用时一定要想清楚任务量的上限。DelayQueue延迟队列ScheduledThreadPoolExecutor依赖它实现定时任务和延迟任务。这里有个常见的配置误区很多人同时配了ArrayBlockingQueue(100)和很大的maximumPoolSize以为队列满了之后就会立刻创建线程。实际上线程池的流程是任务先入队队列满了之后才去创建非核心线程。如果你把这个先后关系搞反了线程池的行为会跟你预期的完全不同。2.3 线程工厂与拒绝策略配置的最后两块拼图ThreadFactory是最容易被忽略的参数。默认的DefaultThreadFactory创建的线程名字是pool-1-thread-1这种一旦线上出了问题你用jstack导线程栈满屏的pool-1-thread-N根本分不清是哪个业务的线程。所以生产环境的线程池必须自定义ThreadFactory给线程起一个能直接定位业务的名称比如biz-order-pool-thread-1。在线程工厂里还可以设置线程优先级、是否 daemon、是否记录线程创建日志。这些细节平时看起来无关紧要真正排查问题时价值巨大。我自己习惯在工厂里加一个创建日志配合线程名的业务关键字用jstack排查线程泄漏时一眼就能看出来是哪个池子在异常增长。拒绝策略有四种内置实现但很多人选的时候没有认真想过区别AbortPolicy是默认策略直接抛RejectedExecutionException让调用方感知到压力属于快速失败。CallerRunsPolicy不丢弃任务回退给提交任务的线程自己执行天然自带背压。缺点是提交任务的线程会被阻塞可能拖慢其他逻辑。DiscardPolicy静默丢弃新提交的任务有数据丢失风险不推荐。DiscardOldestPolicy丢弃队列头部的旧任务然后重新提交当前任务适合对实时性要求高、旧任务可以放弃的场景比如秒杀、报价等。我的习惯是核心业务不用默认策略自己写一个自定义拒绝策略输出被拒绝的任务信息、线程池实时状态、队列占用然后打点上报。线上真正触发拒绝时默认策略只是干巴巴抛个异常如果日志再没接好问题很容易被淹没。3. 任务提交之后线程池内部发生了什么3.1 三步决策顺序不能乱任务提交时走的是execute方法内部的判断逻辑分四步顺序非常关键如果当前工作线程数小于corePoolSize创建核心线程执行这个任务。如果工作线程数已经不小于corePoolSize尝试把任务放入阻塞队列。如果队列也满了尝试创建非核心线程直到线程数达到maximumPoolSize。如果线程数也达到上限执行拒绝策略。很多人问我为什么不是队列满了就先加线程而是先把任务放队列这个设计是有意为之的。核心线程没全部忙起来时让任务排队等一等可以让线程数保持稳定避免频繁创建销毁线程带来的抖动只有核心线程全忙、队列也装不下时才去扩充线程数。如果任务一进来就拼命加线程流量波动时线程数会疯狂抖动系统负载反而更不稳定。3.2 用例子完整走一遍流程假设corePoolSize2maximumPoolSize5ArrayBlockingQueue(3)我们依次提交九个任务看看会发生什么提交 task1当前 worker0 2创建线程 A 执行 task1。提交 task2当前 worker1 2创建线程 B 执行 task2。提交 task3当前 worker2不小于 2尝试入队队列空入队成功。提交 task4队列还剩两个空位入队成功。提交 task5入队成功。提交 task6队列已满worker2 5创建线程 C。提交 task7worker3 5创建线程 D。提交 task8worker4 5创建线程 E。提交 task9worker5不再小于 5队列又满触发拒绝策略。这个流程建议面试时能闭眼默写出来这是面试官最喜欢考的点。注意一个细节第 6 步创建的线程 C执行的是 task6而不是去队列里取一个任务。新创建的线程会先处理当前提交的任务然后再去队列消费。3.3 execute 与 submit选错会导致任务静默消失execute(Runnable)直接提交执行没有返回值Runnable内部的异常会直接抛给线程处理。submit(Callable)会把任务包装成FutureTask再提交返回一个Future对象。这里最大的坑在于submit提交的任务如果抛了异常异常不会直接抛出来而是被FutureTask捕获并存储只有调用future.get()时才会重新抛出。如果你提交了任务但从来不去调用get()任务就算执行失败你也完全感知不到。日志里什么都没有看起来就是“任务凭空消失了”这是线上排查时最难定位的问题之一。所以有两条经验不需要返回值的任务用execute而不是submit。用submit的地方必须保证get()被调用过而且最好带超时避免Future.get()无限阻塞。4. 生产环境配置实战从公式到可直接抄的模板4.1 核心线程数到底怎么定流传最广的经验公式是CPU 密集型任务核心线程数 CPU 核数 1。IO 密集型任务核心线程数 CPU 核数 * 2或者 CPU 核数 / (1 - 阻塞系数)阻塞系数一般在 0.8 到 0.9 之间。但公式只是起点不是终点。CPU 密集型任务比如计算、压缩、加解密线程数超过核数只会增加上下文切换开销IO 密集型任务比如 HTTP 调用、数据库读写、文件传输线程在等待 IO 时 CPU 是空闲的可以多配线程填上等待空档。实际业务里的线程池往往不是纯粹的 CPU 密集或 IO 密集而是两者混合的。我的做法是先用 IO 密集型的上限估算再通过压测逐步回调重点观察 CPU 使用率、队列积压、响应时间三个指标。调参的过程永远比公式重要公式只是给你一个安全的初值。4.2 为什么规范都禁用 Executors 工厂方法面试高频题为什么阿里开发规范不推荐用Executors创建线程池原因非常具体newFixedThreadPool用的是无界LinkedBlockingQueue任务可以无限堆积。当任务处理不过来时队列会一直膨胀最终导致内存溢出 OOM。newCachedThreadPool的maximumPoolSize是Integer.MAX_VALUE加上SynchronousQueue没有队列缓冲任务一多就疯狂创建线程线程数无上限直接把系统拖垮。newScheduledThreadPool的maximumPoolSize同样拉满也有线程爆炸的风险。说白了Executors的几个工厂方法都容易在极端场景下让资源失控。规范要求用ThreadPoolExecutor显式声明参数只是把选择权交还给开发者并且强制你思考队列边界和拒绝策略。加上自定义线程名之后出问题也更好排查。4.3 一份可以抄作业的生产配置模板这里给出一个适合大多数内部异步任务的配置 参数已经标注好含义ThreadPoolExecutor executor new ThreadPoolExecutor( 8, // 核心线程数 16, // 最大线程数 60L, TimeUnit.SECONDS, // 非核心线程空闲回收时间 new ArrayBlockingQueue(1000), // 有界队列防止任务无限堆积 new ThreadFactory() { // 自定义线程工厂 private final AtomicInteger counter new AtomicInteger(1); Override public Thread newThread(Runnable r) { Thread t new Thread(r, async-order-pool- counter.getAndIncrement()); t.setDaemon(false); return t; } }, new ThreadPoolExecutor.CallerRunsPolicy() );这里贴的是我自己的模板。选CallerRunsPolicy是经过权衡的内部异步任务的失败风险通常低于丢任务的风险回退给调用线程后等于把压力继续向后传递配合队列水位监控可以在系统真正被打挂之前提前报警。这个配置不万能但比Executors默认工厂安全得多至少不会因为无界队列把 JVM 内存打爆。补充一点如果是 Java 8 以上ThreadFactory可以用 lambda 简化或者用 Guava 的ThreadFactoryBuilder。但我建议把线程池配置统一收敛到一个工具类里方便统一监控、控制拒绝日志、支持后续动态调参。4.4 定时任务场景的线程池扩展线程池家族里还有个成员叫ScheduledThreadPoolExecutor用于定时任务、延迟任务内部使用DelayQueue按执行时间排序。如果你的项目还在用Timer建议尽早换掉——Timer是单线程的一个任务抛异常会影响后续所有任务ScheduledThreadPoolExecutor支持多线程调度可靠性和灵活性都好很多。Spring 的Async底层也是线程池但默认的SimpleAsyncTaskExecutor并不复用线程真正的生产环境要用自定义TaskExecutor把ThreadPoolExecutor的参数接过去否则异步接口的性能提升非常有限。5. 线程池状态流转与优雅停机5.1 从 RUNNING 到 TERMINATED中间经历了什么线程池有五个状态JDK 内部用 int 值表示大小关系是RUNNING SHUTDOWN STOP TIDYING TERMINATEDRUNNING创建后的默认状态可以接收新任务同时处理队列中的任务。SHUTDOWN调用shutdown()后进入不再接收新任务但会继续处理队列中已存在的任务。STOP调用shutdownNow()后进入不再接收新任务会中断正在执行的任务并清空队列返回未执行的任务列表。TIDYING所有任务结束、worker 数为 0进入终止前的状态会执行terminated()钩子方法。TERMINATEDterminated()执行完后的最终状态。面试高频题shutdown()和shutdownNow()有什么区别简单回答就是shutdown是“等手头活干完再关店”shutdownNow是“直接清场”。注意shutdownNow会中断正在执行的任务并尝试返回队列中未执行的任务列表但中断一个线程不保证它一定停下来——比如线程正在做不可中断的 IO 操作就未必能及时响应中断。shutdownNow只是尽力中断不是保证中断。5.2 优雅停止线程池的完整模板很多代码里直接调用shutdownNow()然后什么都不管任务被中断之后数据丢失也没有补偿机制。我推荐的做法是“先礼后兵”executor.shutdown(); // 停止接收新任务 try { if (!executor.awaitTermination(60, TimeUnit.SECONDS)) { executor.shutdownNow(); // 宽限期超时后强制结束 if (!executor.awaitTermination(60, TimeUnit.SECONDS)) { // 第二次等待仍失败打印线程池状态 } } } catch (InterruptedException e) { executor.shutdownNow(); Thread.currentThread().interrupt(); }核心思路先给存量任务一个宽限期宽限期内没结束再进入强制阶段最后检查状态。这个模板适合应用停机、服务发布、灰度切流时使用。如果你对任务可靠性要求特别高还可以在强制阶段把shutdownNow()返回的任务列表落库服务起来之后重新提交。5.3 execute 前的状态检查execute方法在入队之前会检查线程池状态不是RUNNING就会触发拒绝策略。如果你在一个已经shutdown的线程池上提交任务会直接抛RejectedExecutionException异常信息里通常会包含 “Pool is shutdown” 字样。很多人第一次遇到这个异常很慌其实先看一眼线程池状态就明白了。6. 监控、动态调参和实战避坑指南6.1 线程池状态的可观测性线程池本身提供了不少监控指标getPoolSize()拿到当前线程数getActiveCount()拿到活跃线程数getTaskCount()拿到历史累计任务数getCompletedTaskCount()拿到已完成任务数getQueue().size()拿到队列积压量。常规做法是写个定时任务每隔几秒把这些指标打到监控系统。注意getTaskCount()是历史累计值不是当前值要看当前积压要对队列size做趋势分析。更省心的方式是引入 Micrometer 这类指标库线程池有现成的监控适配器直接暴露 Prometheus 格式指标Grafana 面板一配水位变化一目了然。6.2 动态调整核心线程数生产环境的流量是会变的硬编码corePoolSize并不灵活。JDK 提供了setCorePoolSize和setMaximumPoolSize可以配合配置中心做动态调整。调整时有两个细节要注意调小corePoolSize线程池不会立刻中断多余线程而是等线程空闲后自然退出调大maximumPoolSize新线程也要等下一个任务提交时才会创建并不会立即把线程池铺满。业界已经有一些成熟的动态线程池实践思路就是把线程池参数全部收敛到配置中心通过控制台统一修改并实时展示当前线程池状态。原理不复杂难在参数校验和监控告警要跟上——一次错误调整同样会引起线上故障。6.3 踩过的坑 Top 5值得收藏无界队列加固定线程数等于内存炸弹。任务生产速度大于消费速度时队列无限膨胀最终 OOM 把 JVM 直接带走。线上不要用Executors.newFixedThreadPool的默认配置。submit后不get()异常被Future吞掉任务静默失败告警完全查不到。统一提交规范要结果就带超时get不要结果就execute。ThreadLocal在线程池里复用会导致数据串位。线程池里的线程长期存活任务里写了ThreadLocal又不清理下一个任务拿到的是上一个任务残留的数据非常隐蔽。这种问题在传递用户上下文、链路追踪 ID 的时候特别容易踩。线程池任务里再套线程池双重异步导致链路无法追踪。日志里没有 trace id出了问题没法串联上下游。拒绝策略图省事选了AbortPolicy但异常日志没接好线上毫无感知。至少要统一处理拒绝逻辑把异常打到独立日志并挂上告警。6.4 高频面试题速查我整理了一些面试里经常被问到的题目和答题要点可以参考问题答题方向核心线程数怎么定结合 CPU 密集/IO 密集经验公式给出初值后重点强调压测验证线程池的线程什么时候创建提交任务时按需创建除非调用了prestartAllCoreThreads()为什么先入队再创建非核心线程稳定线程数避免流量波动带来的线程抖动队列作为缓冲区队列满、线程数没到 max新任务怎么办创建新非核心线程执行这个新任务shutdownNow()能保证任务不丢吗不能返回的未执行任务列表需要人工处理不会自动重新提交线程池里的线程异常退出后会怎样worker 线程退出线程池会补充新线程保持线程数量线程池适合做限流吗适合有界队列加拒绝策略本身就是背压机制核心线程数为 0 会怎样任务提交后线程池也能正常工作但会有额外的状态判断开销最后再分享一个我自己一直坚持的实践。每次新建业务线程池我都会在代码里加一个启动日志把核心线程数、最大线程数、队列容量、拒绝策略全部打出来同时暴露一个 HTTP 接口可以随时查看线程池实时状态。排查问题的时候这个接口往往比翻代码快得多。线程池这种底层的组件平时不声不响一出事就是大事提前做好可观测性绝对值得投入。