线程池的参数和执行顺序这两个问题几乎天天出现在各种技术讨论里。但说实话我见过不少工作了三五年的开发者能把七个参数背得滚瓜烂熟一被问到“任务提交之后到底走哪条路”就开始凭感觉猜了。最常见的错误答案要么是“先加满线程再排队”的扩容顺序论要么是不清楚队列到底在哪个环节出场的缓冲论。今天这篇不做理论搬运我就把 ThreadPoolExecutor 的参数含义和执行流程从头到尾捋一遍同时把我在实际项目里调参、排障、压测过程中踩过的坑一起交代清楚。适合刚接触线程池的同学建立完整认知也适合写了好几年线程池但没真正抠过细节的人拿来对照自查。1. 先纠个偏任务来了线程池不是“先加满线程再排队”1.1 两种常见的错误认知我面试和帮同事 review 代码时最常听到的解释是“任务先交给核心线程核心线程满了就创建非核心线程直到线程数达到最大线程数剩下的任务才进入队列排队。”这个理解在顺序上完全是反的。正确顺序里队列排在创建非核心线程之前。核心线程满了之后新任务先进队列缓冲队列塞不下了才轮到非核心线程出场最后才是拒绝策略。这里有个反直觉的点线程池宁可让任务在队列里等着也不愿意轻易给你创建新线程。它的设计初衷就是控制线程这种昂贵资源的使用量让突发流量先被队列吃掉而不是让线程数跟着流量一起疯涨。还有一种错误认知是“任务提交后会优先复用空闲线程”。实际上只要当前工作线程数小于 corePoolSize即便现在有核心线程闲着线程池也会走“创建新线程”这条分支而不是先让空闲线程去接新任务。这个反直觉行为的根源在 execute() 的第一段判断后面第 3 章会详细拆。1.2 用一道基础题检验你的直觉先做一道题假设一个 ThreadPoolExecutor 配置是核心线程数 2、最大线程数 6、队列容量 4一次性提交 10 个任务会发生什么按正确顺序结果是提交的任务处理方式此时的线程池状态第 1、2 个任务创建 2 个核心线程执行工作线程数 2第 3、4、5、6 个任务全部进入队列队列占用 4工作线程数仍是 2第 7、8 个任务创建 2 个非核心线程执行工作线程数 4第 9、10 个任务触发拒绝策略默认抛 RejectedExecutionException这才是完整的执行路径核心线程 → 队列 → 非核心线程 → 拒绝。很多人想象中是“核心线程 → 非核心线程 → 队列”但这和源码行为完全不符。为什么要这样设计队列的本质是缓冲区用来吸收瞬时的任务波动。大多数业务场景下任务都是短时间集中到达核心线程很快就能消费完队列起了“削峰填谷”的作用。如果动不动就创建非核心线程线程反复创建销毁的开销会非常大还不如多等一会儿。所以非核心线程是留给“缓冲都装不下”这种情况的持续高压自然要被安排在队列后面。这个结论还有一个例外如果队列用的是 SynchronousQueue队列容量为 0任务根本放不进去于是提交一个任务就会立刻尝试创建非核心线程。换句话说队列类型会直接影响“先排队还是先扩线程”的观感这一点放到第 4 章细说。2. 七个参数逐个拆每个旋钮转对了线程池才听话ThreadPoolExecutor 构造器里最长的那一个签名一共有 7 个参数。它们之间的关系可以用一张表先总览参数作用默认/常用值容易踩的坑corePoolSize核心线程数常驻线程按 CPU/IO 场景估算设得过大导致常驻资源浪费maximumPoolSize最大线程数包含核心线程核心线程数的 1~4 倍不设或设错队列不满时形同虚设keepAliveTime非核心线程空闲存活时间30~60 秒太短导致线程频繁创建销毁unit时间单位TimeUnit.SECONDS和 keepAliveTime 一起配套使用workQueue任务队列有界队列更推荐用无界队列时 max 参数失效threadFactory线程工厂自定义命名不自定义线上排障困难handler拒绝策略AbortPolicy选错策略在高并发下会拖垮调用线程2.1 corePoolSize 与 maximumPoolSize一个管下限一个管上限corePoolSize 是线程池的“常驻编制”。默认情况下这些线程创建之后即使空闲也不会被回收除非手动开启 allowCoreThreadTimeOut(true)核心线程也会超时回收线程池可以空到 0 个线程。有一个细节很多人不知道核心线程不是线程池创建时就全部建好的而是任务提交时按需创建。也就是说一个 core4 的线程池刚 new 出来时线程数是 0提交第一个任务才创建第一个线程直到总数接近 4。想让它启动时就把 4 个线程都准备好需要显式调用 prestartAllCoreThreads()。maximumPoolSize 是“极限编制”而且它包含核心线程在内。maximum10、core4 时实际允许的工作线程总数最多是 10而不是 104。如果 coremaximum线程池就固定成常驻大小Executors.newFixedThreadPool 就是这种玩法。之所以分核心和非核心就是为了实现“日常用小编制高峰期用大编制高峰期过后非核心线程自动离场”的弹性策略。2.2 keepAliveTime 与 TimeUnit非核心线程的“待机时间”keepAliveTime 只对超出 corePoolSize 的那部分线程生效。当工作线程数大于 corePoolSize 时空闲线程等待超过 keepAliveTime 就会被回收直到线程数回到 corePoolSize 为止。这里有一个很容易踩的坑如果 keepAliveTime 设得太短比如 5 秒峰值一过非核心线程立刻被回收下一次突发流量又马上创建新线程线程反复横跳创建销毁开销直接抵消掉线程池带来的收益。设得太长空闲期线程继续占着内存和系统调度资源。我一般习惯设 30~60 秒既能让短暂的峰值波动不至于频繁重建线程又能在大流量退去后较快释放资源。2.3 threadFactory 与 handler小配角在关键时候救命threadFactory 用来定制线程名、优先级、是否为守护线程。我强烈建议每个线程池都自定义 threadFactory哪怕只是给线程名加个业务前缀。不自定义的话线程名默认是 pool-1-thread-1 这种格式项目里只要有两个以上线程池线上日志一出问题你根本分不清是哪个模块的线程在干活。自定义之后线程名变成 notify-worker-1、report-scan-2 这种一眼就能定位。handler 是拒绝策略。第 4 章会专门展开这里先强调一句话默认的 AbortPolicy 直接抛异常是最严格也最不容易出事的策略如果业务上不能接受丢任务至少要选一个带日志的自定义策略别让任务在静默中消失。3. execute()里四个判断任务提交后到底走了哪条路3.1 一份伪源码的人肉走查想看执行顺序最准确的办法直接看 execute() 方法的源码逻辑。为了让不习惯看源码的同学也能跟上我把它提炼成下面这段伪代码public void execute(Runnable command) { // 第一步线程数小于核心线程数 if (workerCount corePoolSize) { addWorker(command, true); return; } // 第二步线程数已到核心尝试入队 if (isRunning workQueue.offer(command)) { // 入队成功但还要二次检查 if (!isRunning remove(command)) { reject(command); } else if (workerCount 0) { addWorker(null, false); } return; } // 第三步入队失败尝试创建非核心线程 if (addWorker(command, false)) { return; } // 第四步创建失败拒绝 reject(command); }第一步里的 addWorker(command, true) 表示以“核心线程”的身份创建线程。这里有个反直觉的点哪怕现在有核心线程闲着只要总线程数小于 corePoolSize线程池也会新建线程而不是先复用空闲线程。这样做的目的是尽快把核心线程数填满让线程池处于“满编待命”状态而不是让少数线程堵在队列口上一个个拉任务。源码写得很直白没有任何“复用优先”的逻辑。第二步的条件是工作线程数已经达到或超过 corePoolSize这时线程池尝试把任务放进队列。注意这里用的是 offer() 而不是 put()。put() 是阻塞方法offer() 是非阻塞方法放得进去就放放不进去立刻返回 false。这一步决定了队列在整条链路里的位置它不是兜底方案而是第二步就出场的主力缓冲。第三步发生在队列放不进去时线程池尝试创建非核心线程。addWorker(command, false) 返回 false只有一种可能当前工作线程数已经等于 maximumPoolSize也就是线程池已经满编。这时进入第四步调用拒绝策略。如果走到第四步才发现拒绝说明系统已经处于“核心线程忙不过、队列塞不下、非核心线程也顶满”的三重饱和状态这种极端情况下唯一能做决策的就是 handler。3.2 入队之后的二次检查为什么明明入队了还要补一个线程很多读源码的人会在第二步卡住任务已经成功入队了为什么后面还要判断 workerCount 0然后 addWorker(null, false) 再补一个线程这个逻辑对应一个很隐蔽的竞态场景。假设某个时刻线程池里所有线程恰好都已经退出了。比如非核心线程由于超时空闲被回收核心线程又因为某种原因没有创建此时队列里躺着一个任务但没有工作线程去消费它。如果不在入队后补一个 worker这个任务就会一直排队直到下一次任务提交才可能被处理延迟不可控。所以源码的做法是入队成功后重新读一次线程数发现工作线程已经是 0就补一个“空任务”的线程。这个线程启动后直接从队列里取任务执行不会空转。这一步是很多教程不会展开讲但真正导致线上任务“进了队列却不执行”的关键细节。3.3 ctl 的低29位线程数是怎么被记在状态里的还有一个小知识点面试里提出来会很加分ThreadPoolExecutor 并不会分别用两个变量记录线程池状态和工作线程数而是用一个 AtomicInteger 类型的 ctl 同时记录两件事。高 3 位存线程池状态RUNNING、SHUTDOWN、STOP、TIDYING、TERMINATED低 29 位存工作线程数。为什么用 29 位因为 int 是 32 位去掉高 3 位低 29 位能表示的最大值是 2^29-1大概 5.3 亿远远超过实际需要的线程数上限。workerCountOf(c) 就是 ctl CAPACITY 做位运算取出的整数。了解了这一点再看 execute() 里动不动就 ctl.get()、workerCountOf(c)就不觉得奇怪了。4. 队列和拒绝策略决定线程池性格的隐藏旋钮4.1 常用队列对执行顺序的影响任务队列选哪种直接影响第 3 章第二步“入队”是否会成功也就决定了非核心线程有没有出场机会。我整理了四种常见队列的对比队列存储特性容量max 参数是否生效典型场景LinkedBlockingQueue链表结构可无界可有界不指定时 Integer.MAX_VALUE无界时不生效默认推荐但务必指定容量ArrayBlockingQueue数组结构必须有界指定固定容量生效需要严格控制内存占用时SynchronousQueue无缓冲一手交钱一手交货0生效且扩线程非常积极希望任务立刻被执行的即时处理PriorityBlockingQueue按优先级出队无界Integer.MAX_VALUE不生效需要插队执行的紧急任务最坑的是 LinkedBlockingQueue 不指定容量的情况。很多人 new 一个线程池时直接 new LinkedBlockingQueue()容量默认变成 Integer.MAX_VALUE。意思是队列永远“放得下”第二步入队永远成功非核心线程永远没有机会创建maximumPoolSize 完全成了摆设。任务一多全部塞进队列内存吃紧延迟飙升问题还特别难排查。SynchronousQueue 正好相反它连一个任务都存不了。提交任务时必须有一个线程立刻 take 走如果没有线程等着offer() 就失败线程池直接跳到创建非核心线程。Executors.newCachedThreadPool 就是 SynchronousQueue 加 0 核心线程配超大 max 的经典组合它创建线程非常积极适合任务处理时间极短、量大但不需要排队的场景。用这种队列时一定要预估好 maximumPoolSize否则疯狂拒绝。4.2 四种拒绝策略怎么选当队列已满、线程已满第四步就会触发拒绝策略。JDK 内置了四种策略行为优点风险AbortPolicy直接抛 RejectedExecutionException开发者能立刻感知不捕获就会中断调用方流程CallerRunsPolicy提交任务的线程自己执行不丢任务高并发下可能拖垮调用线程DiscardPolicy静默丢弃无异常干扰任务丢了你不知道DiscardOldestPolicy丢弃队列头部的老任务重新提交新任务保证新任务有机会执行已排队任务可能永远无法执行我的建议很直接生产环境默认先别改用 AbortPolicy配合上层捕获异常或者加监控告警。如果业务能接受降级可以自定义一个策略把被拒绝的任务写进本地缓冲队列或者消息队列由定时任务稍后重新投递。这样既不丢任务又给线程池留出喘息空间。CallerRunsPolicy 看起来很好用但它是把双刃剑。如果提交任务的线程是 Tomcat 的请求线程一旦进入拒绝分支请求线程就要去执行线程池里的任务接口响应时间被直接拉长请求并发一大整个 Web 容器都可能被拖垮。这个策略更适合“内部生产者 允许一定程度阻塞”的场景而不是高并发入口。5. 不同业务场景怎么给参数从“能跑”到“跑得好”5.1 先分清你的任务是 CPU 密集还是 IO 密集参数没有万能解第一步是先判断任务性质。CPU 密集型任务线程数超过 CPU 核心数反而会因为上下文切换拖慢速度一般设 N1N 是 CPU 核数。加 1 是为了应对偶尔的内存页缺失、调度停顿等情况下有个补位线程。IO 密集型任务线程大部分时间在等待远程接口、数据库、磁盘真正占用 CPU 的时间很少线程数可以明显大于核心数。一条常见经验公式是线程数 N * (1 平均等待时间 / 平均计算时间)没有数据时从 2N 或 2N1 起步压测后再调。混合型任务如果计算和 IO 能拆开我强烈建议拆成两个线程池一个管计算一个管 IO 等待否则两个特性互相干扰参数很难调。5.2 一个异步通知服务的真实配置拿我之前维护的一个异步回调通知服务举例。这个服务负责给上游系统发 HTTP 回调单条通知平均耗时 80 毫秒几乎全部耗在网络 IO 上CPU 占用很低。高峰期每分钟会涌入几万条通知。我当时给的配置是ThreadPoolExecutor pool new ThreadPoolExecutor( 4, 8, 60, TimeUnit.SECONDS, new ArrayBlockingQueue(500), new NamedThreadFactory(notify-worker), new ThreadPoolExecutor.CallerRunsPolicy() );core4 足够承接平时流量线程创建频率低队列 500 用来吸收瞬时尖峰正常情况下不会触发拒绝max8 给真正的峰值兜底保证高峰期有更多任务在跑而不是全排队。拒绝策略选了 CallerRunsPolicy是因为这里任务的提交方是内部异步生产者阻塞它比丢任务划算得多。这里我想强调一点CallerRunsPolicy 之所以能用是因为我确认过提交方不是 Web 请求线程。一旦提交方变成高并发接口这个策略就要重新考虑。5.3 根据指标调参而不是一步到位参数调整一定建立在监控数据上。我每到一个项目都会先把线程池的这几个指标接进监控系统poolSize、activeCount、queueSize、completedTaskCount、rejectedCount。调整节奏上我习惯一次只动一个参数压测两轮后再做下一步判断。给几个可以直接抄的观察结论队列经常满说明 max 偏小或队列容量偏小线程数长时间跑满且队列积压说明任务量太大已经不是调参能解决的要考虑扩容线程很多但长期空闲说明 core 设大了浪费资源rejectedCount 上涨说明系统已经饱和需要降级或扩容。6. 用一个小实验验证执行顺序顺便盘点我踩过的坑6.1 可复现的验证Demo只看源码不亲手验证印象总是不够深。我写了一个小 Demo你可以直接复制运行观察线程名变化就能直观看到第 1 章说的执行顺序import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; public class ThreadPoolOrderDemo { public static void main(String[] args) throws InterruptedException { // 自定义线程工厂给线程起个一眼能看懂的名字 ThreadFactory factory new ThreadFactory() { private final AtomicInteger idx new AtomicInteger(1); Override public Thread newThread(Runnable r) { return new Thread(r, demo-worker- idx.getAndIncrement()); } }; ThreadPoolExecutor pool new ThreadPoolExecutor( 2, // corePoolSize 6, // maximumPoolSize 30, TimeUnit.SECONDS, // keepAliveTime new ArrayBlockingQueue(4), // 有界队列容量 4 factory, new ThreadPoolExecutor.AbortPolicy() ); CountDownLatch latch new CountDownLatch(10); for (int i 1; i 10; i) { final int taskNo i; pool.execute(() - { System.out.println(Thread.currentThread().getName() 开始执行任务 taskNo); try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { latch.countDown(); } }); TimeUnit.MILLISECONDS.sleep(50); } System.out.println(当前工作线程数: pool.getPoolSize()); System.out.println(队列中等待任务数: pool.getQueue().size()); latch.await(); pool.shutdown(); } }预期结果是这样前两个任务由 demo-worker-1、demo-worker-2 执行任务 3 到 6 进入队列主线程打印队列等待任务数为 4任务 7、8 触发了 demo-worker-3、demo-worker-4 的创建任务 9、10 提交时线程数已是 6队列已满addWorker 失败直接触发 AbortPolicy 抛异常。如果不想让程序在 9、10 任务处中断可以把拒绝策略临时改成 DiscardPolicy或者用 try-catch 把第 9、10 次提交包起来。运行后你会发现demo-worker-1 和 demo-worker-2 一直忙碌线程数从 4 跳到 6 是在任务 7、8 提交时发生的这个时机正好验证“队列先于非核心线程出场”的结论。6.2 坑1无界队列让最大线程数形同虚设有一次我在一个定时批处理任务里看到奇怪的现象业务量翻了 3 倍线程数监控始终只有 core 那么多处理延迟却从几秒涨到几分钟。排查下来创建线程池时写的是 new LinkedBlockingQueue()没指定容量。任务全部堆进了无界队列工作线程永远没有一个“空闲”非核心线程自然不需要创建maximumPoolSize 设置了等于没设置。后来改成 new ArrayBlockingQueue(1000)配合日志告警问题立刻清晰了。无界队列不是不能用而是你要清楚它在悄悄吞掉两个东西一个是内存另一个是你的最大线程数。6.3 坑2默认线程名让线上排查变成大海捞针线程名这个细节我在很长时间里都没当回事。直到有一次线上三个线程池同时在跑日志文件里刷出来的全是 pool-1-thread-5、pool-2-thread-3 这种名字根本分不清是哪个业务在打点排查一个问题花了整整一个下午。从那以后所有线程池都强制自定义 ThreadFactory线程名按业务模块命名。这个习惯成本极低收益极高。你甚至可以把线程的优先级、是否守护线程一起在工厂里定好一条指令的事。6.4 坑3CallerRunsPolicy 在高峰期把上层线程也卷进去了有一回我给一个接口用的线程池配置了 CallerRunsPolicy想着“能多跑一个是一个总比丢任务好”。结果高峰流量一到接口线程池里的任务被拒绝然后请求线程自己跑去执行线程池任务接口响应时间直接被拖到几秒后续请求又堆积在 Web 容器里整条链路被打穿。这个教训让我明白拒绝策略选型必须看提交方是谁。如果提交方是用户请求线程宁可抛弃一部分非核心任务也不能把请求线程拖进业务任务里。从那以后高并发入口一律用 AbortPolicy 加告警把“拒绝”变成一种明确的信号而不是让后续请求互相踩踏。6.5 坑4只调参数不盯指标等于闭眼开车最后一个坑其实是“方法论”层面。线程池参数的最佳值一定是从监控里长出来的而不是从配置文件里抄出来的。我见过有人照着一张旧截图把 corePoolSize 从 8 改成 64理由是“并发上来了”结果线程上下文切换开销暴涨吞吐量反而下降。我的习惯是参数调整后先稳定运行一段时间盯着 queueSize、rejectedCount、completedTaskCount 这几个指标看趋势。队列经常空说明参数偏宽拒绝一直为 0 但延迟在涨说明队列缓冲反而掩盖了消费能力不足的问题。每个指标都在讲一个故事读懂了参数自然就知道怎么调了。线程池的参数和执行顺序说到底不是背出来的是调出来的。我每次调整参数前都会问自己三个问题现在队列是不是经常满线程是不是长时间饱和拒绝计数有没有在涨把这三个问题的答案摸清楚再动手改参数方向基本不会跑偏。下次你再被问到“任务提交后线程池会发生什么”或者线上线程池突然出问题的时候希望你能比之前的自己多一点底气。