1. 线程池到底解决了什么问题第一次接触线程池很多人是被面试题逼着背下“核心线程数、最大线程数、阻塞队列、拒绝策略”这几个名词的。但真到了线上环境面对一个接口响应突然从 50ms 飙到 2s 的故障光会背名词是救不了火的。我见过太多项目线程池参数是复制粘贴来的队列用的是无界队列结果某天流量一冲内存直接被任务对象撑爆整个服务假死。所以这篇内容我不打算按教科书的顺序讲而是从“为什么需要它”和“它内部到底怎么运转”这两个角度把 Java 线程池的实现原理拆开揉碎。线程池这个东西本质上是一个资源池化的思路。线程的创建和销毁是有成本的在操作系统层面创建一个线程需要分配栈空间、注册到调度器销毁又要回收这些资源。如果每个任务都new Thread()然后 start在高并发场景下光是线程的创建销毁就能吃掉大量 CPU 时间更别说线程数量失控之后频繁的上下文切换会让 CPU 利用率急剧下降。线程池要做的就是把线程这种资源复用起来同时给任务排队、限流、兜底让整个系统在压力下有一个可控的行为边界。它适合谁来深入我觉得有三类人最该把这块吃透一是正在做后端服务、需要处理异步任务和批量任务的开发者二是遇到过线上线程相关问题、想搞清楚根因的运维和排查人员三是准备系统学习并发编程、想把 JUC 这套体系串起来的人。线程池是 JUC 里承上启下的一个组件往上看是 Executor 框架往下看是 AQS、CAS、阻塞队列把它搞明白很多并发组件的设计思路就通了。2. 从 Executor 框架看线程池的整体设计2.1 为什么要有 Executor 这层抽象如果直接去看ThreadPoolExecutor的源码会觉得方法特别多、状态特别绕。但如果先理解它头顶上的Executor框架思路会清晰很多。Executor这个接口只有一个方法execute(Runnable command)它的设计意图是把“任务的提交”和“任务的执行”解耦。提交任务的人不需要关心这个任务是被新线程执行、被池化线程执行还是被调用者线程直接执行。这个解耦带来的好处是上层业务代码可以面向Executor编程底层换实现不影响调用方。比如你写了一个异步发通知的逻辑今天用线程池跑明天想改成用消息队列跑只要换一个 Executor 实现就行。ExecutorService在Executor基础上增加了生命周期管理shutdown、awaitTermination和批量提交invokeAll、invokeAny的能力而ThreadPoolExecutor则是这套框架里最核心、最常用的落地实现。2.2 线程池的核心组成部件一个完整的线程池内部其实是由几个部件协作运转的理解它们的分工比死记参数重要得多。工作线程集合workers一个 HashSet存放当前池子里所有的 Worker 对象每个 Worker 持有一个线程和一个待执行任务。任务队列workQueue一个阻塞队列当核心线程都在忙时新提交的任务会先进入这里排队。核心线程数corePoolSize池子希望长期保持的线程数量即使它们空闲。最大线程数maximumPoolSize池子允许创建的线程上限。线程工厂threadFactory负责创建线程可以自定义线程名、优先级、是否为守护线程。拒绝策略handler当队列满且线程数达到上限时对新任务的处理方式。空闲线程存活时间keepAliveTime超过核心线程数的那部分线程空闲多久之后被回收。这几个部件不是孤立的它们通过一套状态机协同工作。线程池内部用一个 32 位的整数ctl同时保存了运行状态和线程数量高 3 位是状态低 29 位是线程数。这个设计很巧妙一次 CAS 操作就能同时判断状态和修改线程数避免了用两个变量带来的同步问题。2.3 五种运行状态的流转逻辑线程池不是只有“运行”和“关闭”两种状态它有五种流转顺序是单向的理解这个对排查问题很关键。状态含义能否接受新任务能否处理队列任务RUNNING正常运行是是SHUTDOWN调用 shutdown 后否是STOP调用 shutdownNow 后否否TIDYING所有任务已终止线程数为 0否否TERMINATEDterminated 方法执行完毕否否这里有个容易踩的坑调用shutdown()之后线程池不会立刻停止它会先把队列里已经排队的任务执行完这个行为是符合预期的但如果你在关闭之后还往里面提交任务就会触发拒绝策略。而shutdownNow()会尝试中断正在执行的任务并返回队列里没执行的任务列表但“尝试中断”不等于“一定中断成功”如果任务里没有响应中断它照样会跑完。这一点在做优雅停机的时候必须注意。3. 任务提交后到底发生了什么3.1 execute 方法的三步走策略这是线程池最核心的一段逻辑我把它拆成三步来看比看源码注释清楚得多。第一步判断当前线程数是否小于核心线程数。如果是就直接创建一个新的核心线程来执行这个任务。注意这一步不管有没有空闲的核心线程只要数量没到 corePoolSize就会新建。这也是为什么预热之后池子会稳定在核心线程数上。第二步如果核心线程数已满就尝试把任务放进任务队列。这里有个细节入队之后会再做一次双重检查如果线程池状态已经不是 RUNNING 了就把任务移除并按拒绝策略处理如果当前线程数为 0就补一个工作线程出来。这个双重检查是为了处理“入队瞬间线程池被关闭”和“核心线程全部被回收”这两种边界情况。第三步如果队列也满了就尝试创建非核心线程直到线程数达到 maximumPoolSize。如果还是创建不了就执行拒绝策略。用一段伪代码来概括这个流程会更直观if (workerCount corePoolSize) { addWorker(command, true); return; } if (workQueue.offer(command)) { // 双重检查 if (!isRunning() remove(command)) { reject(command); } else if (workerCount 0) { addWorker(null, false); } return; } if (!addWorker(command, false)) { reject(command); }3.2 为什么队列满了才创建非核心线程很多人第一次看这个逻辑会觉得反直觉为什么不是先创建线程而是先排队这其实是一个资源利用效率的权衡。创建线程是有成本的如果一有任务就创建新线程那线程数会迅速膨胀上下文切换的开销就上来了。而队列的作用是缓冲突发流量让已有的线程慢慢消化。这个设计隐含了一个前提你用的队列必须是有界的。如果用的是LinkedBlockingQueue默认的无界构造队列永远不会满那么 maximumPoolSize 这个参数就形同虚设线程数永远停在 corePoolSize任务全部堆在队列里。一旦生产速度持续大于消费速度队列会无限增长最终 OOM。这是线上最常见的一类线程池事故我在排查内存问题时遇到过好几次堆 dump 出来一看全是排队中的任务对象。3.3 Worker 的复用与自旋取任务任务提交之后真正干活的是 Worker。每个 Worker 本身实现了 Runnable它被创建时会绑定一个 firstTask然后启动自己的线程。Worker 的 run 方法会调用runWorker这里面有一个 while 循环逻辑是这样的先执行 firstTask执行完之后只要还能从队列里取到任务就继续执行取不到就根据配置决定是阻塞等待还是超时退出。这个循环就是线程复用的关键。线程不会因为一个任务执行完就销毁而是回到循环里继续取下一个任务。取任务用的是getTask()方法它会根据当前线程数是否超过核心线程数来决定是用take()无限阻塞还是用poll(keepAliveTime)超时等待。如果超时了还没取到任务并且当前线程数超过核心线程数这个线程就会被回收。这里有个细节值得注意getTask()里对线程数的判断和回收是配合ctl的 CAS 操作完成的保证不会出现“所有线程都退出了但队列里还有任务”的情况。源码里通过一个timedOut标志和循环重试来处理这种竞争逻辑相当精巧。4. 参数配置与队列选型的实战考量4.1 核心线程数到底怎么定这是被问得最多的问题但网上的答案往往两极分化要么说“CPU 密集型设为核数1IO 密集型设为核数*2”要么说“看压测结果”。我的经验是公式只能给你一个起点真正的值必须靠压测和监控来定。先解释一下公式背后的逻辑。CPU 密集型任务线程大部分时间在算上下文切换是纯开销所以线程数接近核数最划算加 1 是为了在某个线程偶尔缺页中断时顶上。IO 密集型任务线程大部分时间在等网络或磁盘CPU 是空闲的所以可以多开一些线程让 CPU 忙起来。但“核数*2”这个系数太粗糙了实际系数取决于 IO 等待时间和 CPU 计算时间的比值。一个更靠谱的估算方式是线程数 核数 * (1 平均等待时间 / 平均计算时间)。比如一个任务平均计算 10ms等待 90ms那比值是 9核数是 8算出来就是 80 个线程。但这个值只是理论上限实际还要考虑内存、下游服务的承受能力。线程开太多把下游打挂了反而引发雪崩。我的做法是先按公式算一个初值然后压测观察 CPU 利用率、任务队列长度、任务平均等待时间这三个指标。如果 CPU 没跑满但队列在涨说明线程不够如果 CPU 跑满了但吞吐上不去说明线程太多在频繁切换。逐步调整找到吞吐量的拐点。4.2 队列选型的三种典型场景队列的选择直接决定了线程池的行为特征我把它分成三类来说。有界队列 较大 maxPoolSize适合任务处理快、允许一定排队、但绝不能无限堆积的场景。比如 Web 请求处理队列设个几百到几千maxPoolSize 设大一些突发流量来了先排队排不下就扩容线程再不行就拒绝。这种配置的拒绝是有意义的它保护了系统不被压垮。有界队列 maxPoolSize 等于 corePoolSize这是一种“固定大小”的用法线程数不变队列满了直接拒绝。适合对并发数有严格限制的场景比如访问某个脆弱的第三方接口必须严格控制并发。SynchronousQueue 较大 maxPoolSize队列不存储任务来一个就必须有一个线程接接不了就创建新线程创建不了就拒绝。这种配置响应最快没有排队延迟但线程数波动大。Executors.newCachedThreadPool()用的就是这个组合适合大量短任务、对延迟敏感的场景。但要注意它的 maxPoolSize 是 Integer.MAX_VALUE理论上可以创建无限线程所以生产环境不建议直接用这个工厂方法。队列类型特点适用场景风险ArrayBlockingQueue有界、数组实现需要严格控制队列长度容量设置不当易频繁拒绝LinkedBlockingQueue可选有界、链表实现通用场景默认无界易 OOMSynchronousQueue不存储、直接移交短任务、低延迟线程数易失控PriorityBlockingQueue优先级排序任务有优先级无界且不保证同优先级顺序DelayQueue延迟执行定时任务无界需自行控制4.3 拒绝策略的选择与自定义JDK 提供了四种拒绝策略但实际能用的场景有限。AbortPolicy直接抛 RejectedExecutionException。这是默认策略适合那些“拒绝就是异常必须让调用方知道”的场景。CallerRunsPolicy让提交任务的线程自己执行。这个策略很有意思它会阻塞提交线程从而形成一种反压让上游慢下来。适合不想丢任务、又能接受提交方被拖慢的场景。DiscardPolicy静默丢弃。这个策略太危险任务丢了连日志都没有除非你明确知道丢的任务无所谓否则别用。DiscardOldestPolicy丢掉队列里最老的任务再尝试提交。适合任务有时效性、旧任务没意义的场景。实际项目里我更多是用自定义策略。比如把拒绝的任务落库或发到补偿队列等系统恢复后再重试或者记录详细的拒绝日志包括当前线程数、队列大小、任务类型方便事后分析。自定义策略实现RejectedExecutionHandler接口就行但要注意别在拒绝策略里做太重的操作否则会拖慢提交线程。5. 源码里那些容易被忽略的细节5.1 addWorker 的并发控制addWorker是创建线程的入口它内部有一个 for 循环配合 CAS 来增加线程数。这个循环会先检查线程池状态如果状态是 SHUTDOWN 且队列非空是不允许创建新线程的如果状态是 STOP 或更高直接返回 false。然后通过 CAS 尝试把 ctl 的低位加一失败就重试。这个 CAS 循环是典型的乐观锁思路避免了加锁的开销。但要注意addWorker里还有一段用mainLock加锁的逻辑用来把新建的 Worker 加入 workers 集合并检查是否超过了最大线程数。这里用锁是因为要保证 workers 集合的线程安全同时要处理“线程工厂创建线程失败”的回滚。这段代码的异常处理很值得学习它在 catch 块里会把之前 CAS 增加的线程数减回去保证状态一致。5.2 线程池的预热与冷却默认情况下核心线程是在第一次提交任务时才创建的这叫“懒加载”。但有些场景下我们希望服务启动时就把核心线程创建好避免第一批请求的延迟。这时候可以调用prestartAllCoreThreads()或prestartCoreThread()。反过来如果想让核心线程也支持超时回收可以调用allowCoreThreadTimeOut(true)。这个在流量有明显波峰波谷的场景下很有用波谷时把线程都回收掉节省资源。但要注意开启之后核心线程也会在空闲 keepAliveTime 后被回收下次流量来了又要重新创建会有一个短暂的延迟。5.3 线程池的监控指标光会配不会看等于白配。线程池暴露了几个关键方法可以在监控系统里定时采集。getPoolSize()当前线程总数。getActiveCount()正在执行任务的线程数这是个近似值。getQueue().size()队列中等待的任务数。getCompletedTaskCount()已完成任务总数。getLargestPoolSize()历史峰值线程数。我一般会重点盯三个指标活跃线程数 / 最大线程数、队列长度、任务平均等待时间。如果活跃线程数长期接近最大值说明线程不够如果队列长度持续增长说明消费速度跟不上如果等待时间越来越长说明系统在恶化。把这些指标接到告警上比出事之后再翻日志强得多。6. 常见问题与排查实录6.1 任务丢失了但没有任何异常这种情况大概率是用了 DiscardPolicy或者队列是无界的但任务被静默丢弃。排查思路是先确认拒绝策略是什么再确认队列类型和容量。如果是自定义策略检查策略里有没有吞掉异常。我遇到过一次某同学自定义了拒绝策略里面 catch 了所有异常只打了一行 debug 日志结果生产环境日志级别是 info任务丢了完全不知道。6.2 线程池里的任务不执行了可能的原因有几个一是线程池被 shutdown 了新任务全部被拒绝二是所有线程都卡在某个任务上比如死锁或者等待一个永远不会到来的资源三是队列满了但拒绝策略是 Discard任务被丢了。排查时先看线程池状态再看线程栈用jstack把线程 dump 出来看 Worker 线程都卡在哪里。如果是死锁栈里会直接告诉你。6.3 内存持续增长最后 OOM十有八九是用了无界队列。Executors.newFixedThreadPool()和newSingleThreadExecutor()内部用的都是无界 LinkedBlockingQueue任务生产速度大于消费速度时队列无限膨胀。解决办法是换成有界队列并配一个合理的拒绝策略。另外如果任务对象本身很大比如携带了大图片或大文件即使队列有界也要注意队列容量乘以单任务大小不能超过可用内存。6.4 线程名都是 pool-1-thread-1 排查困难默认的线程工厂创建的线程名没有业务含义出问题时根本分不清是哪个池子的线程。解决办法是自定义 ThreadFactory给线程起一个有意义的名字比如“order-async-pool-thread-1”。这样在 jstack 和日志里一眼就能定位。这个改动成本极低但收益巨大我强烈建议每个线程池都配上。问题现象可能原因排查手段解决方向任务丢失无异常Discard 策略或吞异常检查拒绝策略和日志换策略、补日志任务不执行池关闭、线程卡死、队列满jstack、看池状态修死锁、调参数内存 OOM无界队列堆积堆 dump 看队列换有界队列排查困难线程名无意义看线程名自定义 ThreadFactory响应变慢线程不足或队列过长看活跃数和队列长度调参或扩容6.5 优雅停机怎么做才靠谱直接shutdownNow()会中断任务可能导致数据不一致。我的做法是分两步先调用shutdown()停止接收新任务然后awaitTermination等待一段时间让队列里的任务跑完。如果超时还没结束再调用shutdownNow()强制中断并记录哪些任务没跑完。同时在任务代码里要响应中断比如在循环里检查Thread.currentThread().isInterrupted()捕获 InterruptedException 后要么继续向上抛要么设置中断标志。不响应中断的任务shutdownNow()也拿它没办法。7. 动态调整与扩展思路线程池的参数一旦创建就不能直接改了corePoolSize和maximumPoolSize虽然有 setter但改完之后不会自动调整现有线程数需要配合其他操作。实际生产中流量是变化的固定参数很难一直最优。所以很多团队会做动态线程池把参数放到配置中心运行时监听变更并调用 setter。但要注意setter 只是改了阈值线程的增减还是靠原有的逻辑触发。比如把 corePoolSize 调大不会立刻创建新线程要等下次提交任务时才会创建。把 maximumPoolSize 调小也不会立刻回收线程要等线程空闲超时。所以动态调整的效果是有延迟的不能指望改完立刻见效。另一个扩展方向是给线程池加任务包装比如在任务执行前后打点、传递上下文如 traceId、做超时控制。这些都可以通过重写beforeExecute和afterExecute来实现。我一般会在这两个钩子里记录任务耗时和异常方便做链路追踪。但要注意这两个方法是在 Worker 线程里执行的如果里面抛异常会导致 Worker 退出所以一定要做好异常捕获。线程池这块内容光看源码容易陷入细节光看博客又容易停留在表面。我的建议是先理解它要解决的问题和整体流程然后自己写几个小 demo 验证参数变化带来的行为差异最后在压测环境里观察监控指标。踩过几次坑之后那些参数和状态就不再是抽象的概念而是你能感知到的东西了。