
Java线程池的八股大概是Java工程师面试里被问得最勤的一块核心参数、提交流程、拒绝策略、队列选型背起来一套一套的。但真到了写代码和排查线上问题的时候很多人会发现八股背得越熟反而越容易忽略线程池背后那些为什么。我自己就处理过好几次因为线程池配置不当导致的故障比如队列无界导致内存被打满或者核心线程数拍脑袋决定之后高并发下任务疯狂堆积。所以这篇不打算把八股原文再复述一遍而是把ThreadPoolExecutor从构造参数到execute执行链路的源码逻辑拆开再结合一个能动手跑起来的精简实现让你既应付得了面试也真能把线程池用明白。1. 八股问得最勤可真到写代码还是会翻车1.1 线程池到底解决了什么问题先说基础。线程池的本质是复用线程 管理队列 控制并发核心价值不是帮你把代码写得短而是避免在高并发场景下出现频繁创建和销毁线程的开销。Java里new一个Thread本身并不贵贵的是线程创建时的系统调用、栈空间分配以及线程销毁时的上下文切换成本。如果每个请求来了都直接new一个线程去处理QPS稍微上来一点线程数量就会暴涨CPU大量时间花在线程调度上最后系统可能不是被数据库拖垮的而是被自己创建的线程活活压垮。线程池的另一个隐藏价值是限流。线程数是受控的队列大小是受控的任务提交不过来的时候还有拒绝策略兜底。这其实是在业务处理能力和系统资源上限之间加了一层缓冲。理解了这个你才能理解为什么面试官总爱问核心线程数怎么设、最大线程数怎么设、队列怎么选——因为他想知道你有没有从系统资源分配的角度去思考而不是背参数。1.2 面试喜欢问但代码里真正让你翻车的点八股里最经典的问题是ThreadPoolExecutor构造参数有哪些这个几乎人人会背但实际写代码时翻车的往往是另外几个细节。第一个坑是习惯性使用Executors工具类。Executors.newFixedThreadPool(10)看起来省事但它底层用的是无界的LinkedBlockingQueue任务提交速度大于处理速度时队列会无限增长最终导致内存溢出。Executors.newCachedThreadPool()更极端核心线程数是0最大线程数是Integer.MAX_VALUE只要任务来得够快它就会无限制地创建线程直接把系统资源打穿。所以很多规范里都明确要求不要用Executors而是用ThreadPoolExecutor手动指定有界队列和拒绝策略。第二个坑是核心线程数、最大线程数、队列容量三者之间的关系没捋清楚。很多人以为线程不够了就会创建新线程到最大线程数不对。真实逻辑是线程数小于核心数时创建新线程达到核心数之后任务先进队列队列满了才会去创建额外线程直到最大线程数最后队列也满、线程也到上限才会触发拒绝策略。这个优先级搞错后面所有调优都是白搭。第三个坑是不关注任务类型。CPU密集型和IO密集型任务的线程数设置逻辑完全不一样混合型任务更复杂。八股通常只给一句CPU密集用N1IO密集用2N但真实系统里几乎没有纯CPU或纯IO的任务这个公式只能作为起点不能作为最终答案。2. 线程池七个构造参数每一处都是设计权衡2.1 参数的隐藏含义ThreadPoolExecutor最全的构造方法有七个参数corePoolSize、maximumPoolSize、keepAliveTime、unit、workQueue、threadFactory、handler。每一个参数的背后都有明确的业务考量不是随便填的。corePoolSize是核心线程数正常情况下线程池会保持这么多线程存活哪怕这些线程处于空闲状态也不会被回收除非设了allowCoreThreadTimeOut。它代表的是系统常态下需要维持的处理能力。maximumPoolSize是最大线程数代表线程池在极端情况下能扩容到的上限。它和corePoolSize之间的差值是线程池应对突发流量时的弹性空间。keepAliveTime和unit决定的是非核心线程的空闲存活时间。注意默认情况下它只对超出corePoolSize的那部分线程生效核心线程即使空闲再久也不会被回收。如果想要核心线程也能回收需要显式调用allowCoreThreadTimeOut(true)。workQueue是任务队列线程数达到核心数之后新任务先排队。队列的选型和容量直接决定了线程池在负载压力下的行为。threadFactory用来创建线程核心目的是给线程取名字、设置优先级、设置是否为守护线程。取名字这个事听起来无关紧要但线上排查线程池问题的时候如果线程名是一串pool-1-thread-1你根本不知道是哪个业务的线程池出了状况。handler是拒绝策略。当队列满了、线程数也达到上限时新任务会被交给RejectedExecutionHandler处理。说白了这是系统到达能力极限之后的最后一道闸门。2.2 参数之间的联动逻辑这七个参数不是独立存在的它们是一条任务处理管线上的几个关卡。我习惯把它理解成一家餐厅核心线程是固定的几个厨师最大线程是高峰期能临时加进来的厨师队列是等餐区的桌椅拒绝策略是本店已满、不接客的牌子。顾客来了如果厨师没坐满直接安排厨师做厨师坐满了顾客去等餐区排队等餐区满了再叫临时厨师临时厨师也叫满了那就只能在门口挂拒绝牌。这个类比对应到源码里就是execute方法里的三步判断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); } }先判断线程数是否小于核心数是就直接创建线程执行不是就把任务放进队列队列放不进去再尝试创建新线程直到最大线程数这一步也失败就只能拒绝。很多人在八股里背过先判断核心线程再判断队列再判断最大线程但只有真正读过execute的实现才会发现第二步里还有一个recheck逻辑——放入队列之后会再次检查线程池状态如果线程池被关闭了就移除任务并拒绝如果已有线程都挂了就补一个空任务的worker避免任务在队列里没人消费。2.3 参数配置的一个实战例子拿一个实际场景来说假设我在做一个订单支付通知推送的服务上游每秒钟会往我这里投递一批回调任务平峰期每秒大概200个高峰期可能到1000个每个任务平均执行时间在50毫秒左右。单任务处理能力大约是每线程每秒20个。平峰期要处理200个任务理想线程数是200除以20也就是10个左右。所以corePoolSize可以先定10。高峰期1000个任务需要约50个线程因此maximumPoolSize可以定到50。队列容量留多少呢如果希望任务在队列里最多等1秒那么队列大小可以按高峰期每秒新增任务数 – 最大线程每秒能处理的任务数来估算也就是1000 – 50×20 0这种情况下队列设很小剩余流量直接靠最大线程扛或者用拒绝策略保护系统。当然这只是估算的起点真实系统还需要结合任务超时时间、机器核数、下游接口承受能力反复调整。3. 从 execute 到 worker一条任务提交的完整流转链路3.1 线程池核心状态与线程数是如何打包管理的ThreadPoolExecutor里有一个非常精妙的设计线程池状态和线程数量共用一个AtomicInteger这个字段叫ctl。ctl的高3位表示线程池状态低29位表示线程数量。为什么要用一个月把一个状态和一个数量打包在一起因为这两个数据在线程池的运行过程中需要经常一起读取和判断。如果分开用两个变量存在高并发环境下获取当前线程池状态当前线程数这个组合快照时就会出现不一致而用原子整数一次性读取就能保证读到的是一个稳定的组合值。这是并发编程里很典型的用不可分割的原子变量管理一组耦合状态的思路。线程池状态包括RUNNING、SHUTDOWN、STOP、TIDYING、TERMINATED。workerCountOf(c)取的是低29位的值用来判断当前线程数。每一步状态转换都会配合CAS操作保证线程安全。3.2 addWorker与Worker内部的循环取任务execute方法里反复调用了addWorker这一步才是真正创建线程的地方。addWorker会先做一系列状态检查比如当前线程数是否达到上限、线程池状态是否仍允许新增线程然后通过CAS把ctl中的线程数加一再构造一个Worker对象并启动它。Worker本身实现了Runnable接口Worker构造的时候会把第一个任务作为firstTask保存下来。start之后runWorker就会开始执行firstTask然后进入getTask循环从workQueue里不断取下一个任务。这就是线程复用的核心一个线程执行完当前任务后不是马上销毁而是再次回到队列去取新任务。getTask的逻辑里体现了keepAliveTime的作用。它调用poll(keepAliveTime, timeUnit)如果超过keepAliveTime还没取到任务线程就会退出循环走processWorkerExit逻辑把当前线程数减一最终这个线程自然结束。如果线程数小于等于corePoolSizedo-while循环会判断当前线程数是否已经不需要缩减避免多余的业务线程被清理掉。3.3 一个容易忽略的线程回收细节很多人背八股时只记住了非核心线程空闲keepAliveTime后被回收但实际源码里有个微妙点当线程数大于corePoolSize时工作线程在取任务时用的是poll(keepAliveTime)而线程数小于等于corePoolSize时用的是take()也就是阻塞等待。这意味着只要线程数降回到核心数以内工作线程不会被轻易回收。这保证了核心线程数的稳定性。另外如果设置了allowCoreThreadTimeOut(true)核心线程也会走poll逻辑空闲时间一到同样会被回收此时线程数可以降到0所有线程在空闲时都会被清理适合那些业务有明显波峰波谷、且不想长期占用线程资源的场景。不过这个开关我一般不推荐在核心业务线程池上开启因为线程重建本身也有成本频繁的从0扩容到峰值反而可能导致毛刺。4. 阻塞队列选错线程池的性能和稳定性全都会出问题4.1 主流阻塞队列的差异在哪里阻塞队列决定的是任务在等待被执行期间的排队规则。Java里ThreadPoolExecutor常用的队列有这么几种队列有界性特点适用场景ArrayBlockingQueue有界底层数组容量固定基于一把锁需要严格控制内存的任务队列LinkedBlockingQueue可选有界底层链表默认容量为Integer.MAX_VALUE需要高效入队出队默认无界需谨慎SynchronousQueue无容量每个任务都需要一个线程立刻处理不排队Executors.newCachedThreadPool默认使用DelayQueue无界任务到期后才能被取出定时任务线程池ScheduledThreadPoolExecutorPriorityBlockingQueue无界按优先级出队需要任务优先级排序的场景队列的内存特性要特别注意。ArrayBlockingQueue是数组实现容量固定不会因为积压任务而撑爆内存但缺点是队列满了以后只能拒绝任务。LinkedBlockingQueue如果不显式指定容量默认是Integer.MAX_VALUE也就是无界的看起来永远不会拒绝任务但代价是任务会一直堆积在内存里最终OOM。SynchronousQueue则是一个很有意思的存在它本身不存数据每个入队操作必须等待一个出队操作对接所以放到这个队列里的任务必须有一个空闲线程来取。这也解释了为什么cachedThreadPool用它很合适任务一提交进来队列存不住线程池就会判断没有空闲线程我直接新建一个线程。这就是cachedThreadPool在并发量大时线程数量无限膨胀的原因使用时要极其小心。4.2 无界队列为什么看起来安全实际危险生产环境我见过最多的问题是项目里用Executors.newFixedThreadPool任务处理不过来时队列把内存慢慢吃满最后接口超时、垃圾回收频繁、整个服务被打挂。这类问题排查起来特别难受因为它不是突然崩的而是缓慢变差等监控发现内存持续上升时往往已经积压了几百万个任务。无界队列最大的问题是它把系统过载的信号给屏蔽了。任务入队永远成功看起来每个请求都被接受了但事实上它们在队列里排队等待延迟会越来越高。客户端不知道你已经在内部积压了该发的请求还是会发过来最终整条链路雪崩。所以我现在对新搭建的线程池有一个硬性要求所有公共线程池必须使用有界队列队列容量根据业务场景估算拒绝策略明确选择。4.3 队列容量到底怎么估算队列容量没有万能的公式但可以按可容忍的排队延迟来反推。假设线程池核心线程数是N每个任务平均耗时T毫秒系统每秒最多能处理的任务数是N×1000/T。如果任务提交速率是Q超出处理能力的那部分任务必须排队。队列容量C建议满足C ≤ (可容忍排队时长/单任务平均耗时)×N。举个例子核心线程20个单任务平均耗时20毫秒可容忍排队1秒那么队列里最多堆50个任务因为20个线程每秒总共能处理1000个任务超过1000个就会产生延迟。当然这些计算都是粗粒度估算真正上线前最好做压测验证。5. 拒绝策略不只是一个枚举四种内置策略和业务兜底5.1 四种内置策略的区别当队列已满且线程数达到maximumPoolSize时再来的任务会进入拒绝处理逻辑。ThreadPoolExecutor内置了四种策略策略行为风险适用场景AbortPolicy直接抛RejectedExecutionException调用方需处理异常否则任务丢失默认策略对任务必须处理的场景不合适CallerRunsPolicy让提交任务的线程自己执行会阻塞调用方线程适合不希望任务丢失、可接受调用方阻塞的场景DiscardPolicy静默丢弃新任务任务无感知消失适合允许丢弃低优先级通知、日志类场景DiscardOldestPolicy丢弃队列中最老的任务老任务可能被丢弃适合追求新鲜度、可容忍旧任务失效的场景这里最容易被忽视的是CallerRunsPolicy。它名字里带个Caller意思是任务由调用方线程来跑。比如一个Web请求的线程在向线程池提交任务时发现池子满了那么这个Web请求线程就会自己执行这个任务。这样做的好处是任务不会丢坏处是请求线程被卡在任务执行上接口响应时间会明显变长。如果你只是不想丢任务但又不希望请求线程被锁住可以考虑自定义拒绝策略将任务持久化后异步补偿。5.2 业务场景下的拒绝策略选型拒绝策略的选择本质上是在数据完整性和系统稳定性之间做取舍。我一直强调一个原则没有最好的拒绝策略只有最适合业务形态的拒绝策略。如果是交易类、支付回调类场景任务绝对不能丢默认的AbortPolicy就不合适因为调用方不一定处理异常。这时候我通常会自定义一个策略把被拒绝的任务写到本地临时存储或者内存队列之外再通过一个独立的补偿任务去扫描重放。如果是日志上报、埋点数据这类对实时性要求不高的任务丢几条影响不大我会倾向于使用DiscardPolicy或者新任务直接丢弃确保主业务不被日志拖垮。如果是缓存刷新类的任务旧任务的价值低、新任务的价值高DiscardOldestPolicy反而更合适。5.3 自定义拒绝策略的一个典型实现自定义拒绝策略其实非常简单实现RejectedExecutionHandler接口就行public class RetryPersistPolicy implements RejectedExecutionHandler { Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { if (!executor.isShutdown()) { // 这里可以把任务序列化后保存到本地文件或MQ // 并在后续通过定时任务取出重试 saveToPersistentStore(r); } } }不过要注意被拒绝的Runnable对象不一定是单纯的任务它可能是包装了业务上下文的组合对象直接序列化不一定可行。所以更稳妥的做法是在提交任务之前就设计好一个可恢复的任务模型把任务的关键参数保存下来而不是保存Runnable本身。这也是很多人自定义拒绝策略时踩的坑。6. 手写一个精简线程池检验八股是否真的吃透了6.1 最小骨架核心线程、最大线程、队列、拒绝策略说了这么多源码最有效的验证方式是自己动手写一个精简版线程池。这里我不建议一上来就对着JDK源码抄而是先根据八股里的逻辑从零实现一个能跑的最小版本。我设计的MiniThreadPool包含四个核心组件核心线程数、最大线程数、阻塞队列、拒绝策略处理器。线程集合用一个List保存不需要额外状态管理因为我们的目标不是生产等价物而是理解原理。public class MiniThreadPool { private final int coreSize; private final int maxSize; private final long keepAliveTime; private final TimeUnit unit; private final BlockingQueueRunnable queue; private final ListWorker workers new ArrayList(); private volatile boolean running true; public MiniThreadPool(int coreSize, int maxSize, long keepAliveTime, TimeUnit unit, BlockingQueueRunnable queue, RejectPolicy rejectPolicy) { this.coreSize coreSize; this.maxSize maxSize; this.keepAliveTime keepAliveTime; this.unit unit; this.queue queue; this.rejectPolicy rejectPolicy; } public void execute(Runnable task) { if (!running) { rejectPolicy.reject(task, this); return; } synchronized (workers) { if (workers.size() coreSize) { Worker w new Worker(task); workers.add(w); w.start(); } else if (queue.offer(task)) { // 入队成功不做处理 } else if (workers.size() maxSize) { Worker w new Worker(task); workers.add(w); w.start(); } else { rejectPolicy.reject(task, this); } } } }这个execute方法完整复刻了JDK的三步判断先判断核心线程是否未满再尝试入队入队失败判断是否还能扩容到最大线程最后走拒绝策略。唯一偷懒的地方是用了全局锁来保证workers列表的线程安全JDK是通过CAS来完成的并发性能高得多。6.2 Worker如何持续消费任务Worker内部是一个包装了Runnable的线程。它启动后先执行firstTask然后循环从队列里取任务。这里要注意取任务不能简单地用queue.take()因为那会导致线程永远阻塞无法实现keepAliveTime回收逻辑。private class Worker extends Thread { private Runnable firstTask; Worker(Runnable firstTask) { this.firstTask firstTask; } Override public void run() { Runnable task firstTask; firstTask null; while (task ! null || (task getTask()) ! null) { try { task.run(); } catch (Exception e) { // 实际项目中要记录异常防止线程无故退出 e.printStackTrace(); } finally { task null; } } } private Runnable getTask() { try { return queue.poll(keepAliveTime, unit); } catch (InterruptedException e) { return null; } } }线程执行完firstTask之后会不断从队列里poll新任务。如果超过keepAliveTime也没有拿到任务poll返回nullrun方法退出线程结束。这就模拟了JDK中非核心线程的空闲回收机制。当然JDK里会有更精细的判断只有线程数大于核心数时才允许空闲线程退出。在MiniThreadPool中为了让所有线程都按统一超时机制退出我没有区分核心和非核心这个差异本质上是设计取舍不影响原理理解。6.3 关闭流程为什么重要线程池的关闭逻辑是很多手写实现最容易遗漏的部分。JDK提供了shutdown和shutdownNow两种方式。shutdown会等待队列中的任务全部执行完shutdownNow会尝试中断正在执行的任务并返回队列中尚未执行的任务列表。我的MiniThreadPool里至少要支持shutdownpublic void shutdown() { running false; for (Worker w : workers) { // 这里只是示例真正的中断要更谨慎 w.interrupt(); } }但如果直接把running置false并中断所有线程队列里尚未执行的任务就会丢失。实际项目中正确的做法是先标记关闭状态暂停接收新任务然后让Worker执行完当前任务和队列中的剩余任务后再退出。这也是为什么JDK的shutdown实现那么复杂它要在尽快关闭和处理完已有任务之间做平衡。手写线程池是对八股最好的检验。如果你能把这个类补充完整加上拒绝策略接口、关闭方法、队列容量控制并写一个简单的单元测试验证高并发场景下不炸内存、不丢任务面试时被问到线程池原理基本可以放心应对了。7. 生产环境线程池调优比八股答案难得多7.1 该监控哪些线程池指标八股很少讲线程池的监控和调优但在生产环境线程池的运行时指标才是判断配置是否合理的依据。我的习惯是至少监控六个指标当前线程数、活跃线程数、核心线程数、最大线程数、队列积压数量、已完成任务总数。ThreadPoolExecutor自带的部分方法可以拿到这些数据。getPoolSize()返回当前线程数getActiveCount()返回活跃线程数getQueue().size()返回队列积压量getTaskCount()和getCompletedTaskCount()可以推算任务处理速度。把这些指标通过定时任务上报到监控系统再结合告警规则线程池是否有问题很快就能暴露。一个非常实用的经验是如果队列长期积压但活跃线程一直没有增加到最大线程数说明corePoolSize设置偏小同时队列容量设置偏大导致任务都堆在队列里线程数量没有及时扩容。这种情况需要把核心线程数调大或者把队列容量调小让任务更早地触发扩容逻辑。7.2 动态调参的局限性与修正ThreadPoolExecutor提供了setCorePoolSize、setMaximumPoolSize、setKeepAliveTime等动态调整方法。但要注意这些方法调整的是后续行为已经创建的线程不会被强制回收。尤其是在调大maximumPoolSize时如果任务都已经在队列里排队新增的线程需要先从队列里取任务执行不会立刻体现效果。实际业务中我做调优一般是分步走的先看监控确认瓶颈再小步调整然后观察一个时间段内的指标变化最后用压测验证。不要一次性把核心线程数和最大线程数翻倍那样只会让线程争抢更激烈甚至可能因为线程数过多导致上下文切换开销上升性能反而下降。7.3 按业务隔离线程池比单个大线程池更稳很多人喜欢把所有异步任务都丢到一个全局线程池里图省事。但这个做法在业务复杂之后一定会出问题某个高频任务占满了线程其他低延迟任务必须排队等。更合理的做法是按业务维度拆分线程池比如支付回调一个池子、消息推送一个池子、日志处理一个池子每个池子独立设置参数和拒绝策略一个业务打爆只会影响自己。拆分之后还要考虑线程池之间的依赖关系。如果线程池A的任务会向线程池B提交任务A的拒绝策略和B的容量必须配套设计否则A被拒绝的任务可能在B里积压问题只是被延后了而不是被解决了。我曾经遇到过一个问题上游线程池把任务处理得飞快全堆到下游线程池的无界队列里下游内存一路飙升排查了半天才发现是队列选择的问题。这类问题光靠调线程数解决不了必须回到队列和整体链路设计上去看。如果线程池用在了异步任务较多的中间件场景还可以尝试对线程池做一个薄封装统一管理命名、监控、告警、优雅关闭。把线程池的创建收敛到一处比散落在业务代码各处容易维护得多排查问题的时候也不会到处找配置了。