
面试了这么多年人线程池这道题几乎每次都会出现在Java面试题里。我手里有两道经典的线程池面试题一道让你现场写一个线程池一道问你“提交一个任务后线程池内部发生了什么”。别看这两道题平时被各路人马反复讲真能在白板上写对、再连环追问不倒的候选人真的不多。这篇文章不打算复读面试答案而是顺着这两道题把线程池从参数到源码、从生产事故到避坑经验完整过一遍最后还有一份我自己整理的连环17问清单方便你拿去自测或复盘。1. 面试题入口两道必考题考的是你有没有“用过”线程池1.1 第一道题手动创建线程池七个参数一个都不能错面试时经常这样出题请写一段代码创建一个适合业务场景的线程池要求能应对短时峰值流量同时不要出现任务堆积导致内存溢出。很多人顺手就是Executors.newFixedThreadPool(10)或者newCachedThreadPool()。这两个在日常demo里没问题但在面试和生产里都经不起追问。newFixedThreadPool用的是无界LinkedBlockingQueue队列可以无限增长任务积压时会悄悄吃掉内存newCachedThreadPool最大线程数是Integer.MAX_VALUE线程数可以无限膨胀最后全是上下文切换开销甚至直接OOM。所以面试官让你手动写ThreadPoolExecutor本质就是看你是不是真的了解线程池的边界在哪里。给一个能过关的创建代码ThreadPoolExecutor executor new ThreadPoolExecutor( 5, // corePoolSize核心线程数 10, // maximumPoolSize最大线程数 30L, TimeUnit.SECONDS, // 非核心线程空闲回收时间 new ArrayBlockingQueue(100), // 有界阻塞队列 new ThreadFactory() { private final AtomicInteger seq new AtomicInteger(1); Override public Thread newThread(Runnable r) { Thread t new Thread(r, biz-pool- seq.getAndIncrement()); // 自定义异常兜底 t.setUncaughtExceptionHandler((th, ex) - log.error(thread {} caught exception, th.getName(), ex)); return t; } }, new ThreadPoolExecutor.CallerRunsPolicy() );写完千万别直接交卷你还要能说出这套参数组合背后的容量关系核心线程5个队列容量100最大线程10个意味着系统最多能同时承接“执行中 等待中”的任务是 5 100 10 115 个。超过115个后面的任务就会触发拒绝策略。还要说清楚为什么maximumPoolSize要大于corePoolSize留出扩容空间为什么队列要有界防止任务无限堆积。这三句话加起来才是一道完整的答案。1.2 第二道题提交一个任务线程池内部走了几步第二道题通常在第一道题的基础上继续追问现在有一个新任务提交进来内部的处理顺序是什么标准的判断流程分四步当前工作线程数小于corePoolSize直接新建线程执行任务不会复用空闲线程。当前工作线程数已经大于等于corePoolSize把任务尝试塞入阻塞队列workQueue塞得进去就排队等待。队列塞不进去比如有界队列满了再看当前线程数是否小于maximumPoolSize如果小于就继续新建线程。线程数已经达到maximumPoolSize新任务只能交给拒绝策略RejectedExecutionHandler处理。这个顺序不少人背得滚瓜烂熟但面试官真正想考的是后面这句为什么是先入队再创建新线程为什么不一开始就扩到最大线程数先说结论线程池的设计哲学是“缓冲优先扩容兜底”。线程的创建和销毁成本很高不是万不得已不要轻易加线程。队列的作用就是吸收短暂的流量波动让任务先排队等核心线程忙完一个再来拿下一个。只有当队列都填满了说明压力不是“拍一下就走”而是持续性的这时候才启动扩容把maximumPoolSize的线程用起来。如果你一个任务来就扩一个线程峰值一过线程全空转池化复用的意义就没了。这个设计意图比背那四步流程重要得多。2. 参数、队列、拒绝策略线程池的骨架与边界2.1 七个核心参数逐个拆解别再念说明书corePoolSize叫核心线程数但它不是“启动时立刻创建的线程数”。线程池默认是懒加载的任务来了才会创建新线程直到数量达到核心线程数。这就有个面试陷阱空闲的线程池任务数是多少答案是0一个线程都没有。maximumPoolSize是最大线程数但只有在“核心线程满了 队列也满了”两个条件同时满足时才会继续创建线程到最大数量。keepAliveTime是非核心线程的空闲存活时间。线程数超过核心数之后多余线程空闲超过这个时间就会被回收。注意它只管“超出核心线程数的那部分”。workQueue是任务队列参数章节单独讲。threadFactory是线程工厂很多人不写导致线上日志里线程名全是pool-1-thread-1出了问题都不知道是哪个池子。生产环境必须自定义线程工厂把线程名、异常处理都配上。handler是拒绝策略默认的AbortPolicy会直接抛RejectedExecutionException如果你的代码没捕获任务就丢了看起来很“暴毙”。所以生产上往往要自己选一个合适的策略。还有一个容易忽略的allowCoreThreadTimeOut它是个布尔值默认false。设为true之后核心线程空闲超过keepAliveTime也会被回收线程数可以降到0。这个参数适合流量有明显潮汐特征的场景比如深夜完全没有任务你又不想让一批线程空挂在那里。2.2 阻塞队列怎么选以及容量怎么定面试题里“线程池的阻塞队列选择”是高频考点。队列选型直接决定了线程池的脾气不同队列的语义差得非常多。队列特点适用场景LinkedBlockingQueue默认可以无界也支持构造时指定容量常见于固定线程池但无界使用容易积压任务ArrayBlockingQueue有界队列容量固定支持公平/非公平锁最推荐业务使用内存可控满了可以触发扩容SynchronousQueue不存储任务任务必须立刻交给线程处理适合内部处理非常轻量的场景newCachedThreadPool用它PriorityBlockingQueue优先级队列任务可以按优先级出队需要任务排级的场景但要注意优先级反转问题DelayQueue延迟队列任务到时间才能取出ScheduledThreadPoolExecutor用它做定时任务队列容量怎么定这不是拍脑袋要和线程数、最大线程数一起算。假设核心5个最大10个队列100个那“并发天花板”就是115个任务。如果任务本身要执行200ms你的接口QPS比如是100那每个任务平均排队时间就是在排队任务数乘以单任务耗时算下来很容易超过1秒。所以队列容量不是越大越好大了能扛流量但会让任务延迟变得不可控小了呢又会频繁触发扩容和拒绝。我的习惯是队列容量按业务可接受的排队时间 / 单个任务平均耗时来估。比如一个任务耗时200ms你允许它在队列里最多等2秒那队列容量大约可以定10个左右。再配合核心线程数做容量兜底基本不会出现内存被无界队列吃掉这种事故。2.3 四种拒绝策略别让业务任务静默消失线程池实在“装不下”的时候就会走RejectedExecutionHandler。Java 提供了四种现成的策略AbortPolicy直接抛出RejectedExecutionException默认策略。CallerRunsPolicy任务不在线程池里执行而是由调用者线程自己执行。DiscardPolicy静默丢弃任务不抛异常也不执行。DiscardOldestPolicy丢弃队列里最老的任务然后重新尝试提交当前任务。很多人一看就选CallerRunsPolicy因为它不丢任务。但这里有个坑如果调用者就是处理请求的主线程而主线程本来想快速返回结果被任务拖住了整个接口耗时会被任务耗时直接拉高。这就是一种“自动限流”你要能接受这个代价才用它。DiscardPolicy和DiscardOldestPolicy我几乎不用。静默丢弃意味着订单、消息这类核心任务可能无声无息就没了排障极其痛苦。如果真的允许丢弃部分任务比如流量打点、非关键日志也要在丢掉的地方打一个warn日志或者加一个丢弃计数指标出了问题能查。3. 线程池连环17问从背题到理解原理先把我整里的“连环17问”清单放出来你可以对着自查分类问题概念1. 为什么不建议直接用Executors 2. corePoolSize与maximumPoolSize什么关系 3. keepAliveTime回收的是谁 4. 任务提交后判断顺序是什么线程复用5. 工作线程是怎么复用的 6. Worker封装了什么 7. 非核心线程什么时候回收 8. 核心线程能回收吗 9. 线程执行任务时异常退出会怎样异常与关闭10. execute和submit有什么区别 11. submit的任务异常去哪了 12. shutdown和shutdownNow区别 13. 线程池有哪些状态容量与工程14. CPU密集和IO密集怎么算线程数 15. 队列选有界还是无界容量怎么定 16. 线程池参数能否动态调整 17. ForkJoinPool和ThreadPoolExecutor有什么不同接下来挑几组重点展开全部理解透面试基本就稳了。3.1 打工人的一生线程创建、复用与回收第5问工作线程是怎么复用的很多人以为线程池里有个线程每次都会“被分到任务”其实不是。核心线程在创建后会进入一个while循环不断从workQueue里take()任务。队列里有任务就取出来执行队列空了就阻塞等待。这个循环才是线程复用的真相。所以你往队列里塞任务核心线程就会被唤醒拉取不用每次创建新线程。第6问Worker封装了什么Worker是线程池内部包装任务执行的一个类它本身是一个Runnable同时继承了AbstractQueuedSynchronizerAQS。继承AQS是为了实现“任务执行中不能被中断”的控制保证shutdownNow()只中断等待中的线程而不是打断正在跑的任务。这里的细节平时不用深抠但知道“Worker AQS”这个组合面试会明显加分。第7、8问要一起答非核心线程在keepAliveTime时间内从队列拿不到任务就会退出循环被回收核心线程默认永远存活除非你设置了allowCoreThreadTimeOut(true)这时候核心线程也会走一样的超时回收逻辑线程数可以降到0。注意就算keepAliveTime0非核心线程处理完当前任务后也会立刻退出。第9问线程执行任务时抛异常了会发生什么如果是execute()提交且没有在任务里自己捕获异常那么当前线程会直接终止。线程池发现 Worker 数量少了一个会再用线程工厂重新创建一个线程补齐所以表面上线程数没变但线程已经“换过人”了。如果是submit()提交异常会被封进Future线程不会死后面详细讲。3.2 execute与submit异常到底去了哪里第10问execute()和submit()都会向线程池提交任务但submit()返回值是Future你可以拿它获取执行结果execute()只管执行没有任何返回值。第11问是高频坑submit()的任务如果抛了异常这份异常不会直接抛到调用方而是被捕获并保存到返回的Future对象里。如果你后续没有调用future.get()异常就会一直躺在Future里日志上什么都看不到。这是线上“任务静默失败”的经典元凶之一。实操建议需要异步执行的任务要么在任务内部自己try/catch并打日志要么坚持用execute()提交并配合自定义UncaughtExceptionHandler。如果非要用submit()那就必须在得到Future后同步调用一次get()来刷新异常哪怕是包装成try { future.get(); } catch (Exception e) { log.error(...); }也无所谓。很多人写着写着会忘记这步所以我通常更推荐execute()加try/catch的组合。第12问shutdown()是平缓关闭不再接受新任务但队列里已存在的任务会继续执行完。shutdownNow()是强制关闭它会尝试中断正在执行的线程并返回队列里尚未执行的任务列表。实测中shutdownNow()对“正在执行中的任务”只能发中断信号如果任务不响应中断线程还是会继续跑。第13问线程池有5种状态RUNNING运行中、SHUTDOWN平缓关闭中、STOP强制关闭中、TIDYING收尾中、TERMINATED终结。它的状态是用AtomicInteger的高3位存储的低29位存线程数一个变量存了两份信息。知道这个设计面试官对你的源码熟悉度会立刻高看一档。3.3 容量设计CPU/IO密集怎么算队列用多大第14问线程数到底设置多少这是所有面试里最难绕过去的计算题。CPU密集任务线程数建议设为CPU核数 1。多出来的1个线程是为了应对偶发的页缺失、线程调度卡顿让一个线程等待时还有替补顶上核心线程数等于物理核数时性能其实已经到顶了再往上只会增加无意义的上下文切换。IO密集任务经验值是CPU核数 * 2更严谨的公式是线程数 CPU核数 / (1 - 阻塞系数)阻塞系数一般取0.8~0.9。比如8核机器阻塞系数0.8算出来就是 8 / (1-0.8) 40 个线程。因为IO等待期间线程阻塞不占CPU所以可以多开线程同时等待。但线程也不是越多越好线程过多导致内存占用上升、上下文切换开销变大、甚至CPU cache命中率下降所以在8核机器上开200个IO线程效果往往不如40个。第15问队列选有界还是无界答案基本只有一个生产环境用有界队列。无界队列意味着任务可以无限排队内存迟早被撑爆而且线程数永远达不到maximumPoolSize因为newFixedThreadPool这种默认配置根本走不到第3步。这就是为什么很多人说Executors是“线程池禁用工具类”的原因。3.4 进阶玩法动态配置、线程工厂与ForkJoinPool第16问线程池参数能不能动态调整可以。ThreadPoolExecutor提供了setCorePoolSize()、setMaximumPoolSize()、setKeepAliveTime()等方法而且这些改动是线程安全的。动态调整核心线程数时有个细节如果你传入的新corePoolSize小于当前存活线程数那些超出部分会在空闲后慢慢被回收而不是立刻被干掉。这在做流量高峰应对、夜间缩容时特别有用。第17问ForkJoinPool和ThreadPoolExecutor有什么不同ForkJoinPool是专门为“分治任务”设计的核心机制是“工作窃取”。每个线程有自己的双端队列自己队列空的时候可以从别的线程队尾偷任务把任务粒度削到很细。而ThreadPoolExecutor是共享一个全局队列适合任务互相独立、没有依赖关系的场景。如果你只是在普通业务里用优先ThreadPoolExecutor真正做递归分解型任务排序、大文件并行处理才考虑ForkJoinPool并且要关注它的并行度参数默认是CPU核数。关于线程工厂再补充一条经验ThreadFactory里除了设置线程名和UncaughtExceptionHandler你还能控制线程是否为守护线程以及设置线程优先级。生产上我几乎不给业务线程设优先级默认就行改动优先级容易引发难以排查的调度问题。线程名则一定要带业务标识比如order-thread-1出问题时能一眼定位是哪个业务线程池出了问题。4. 线上踩坑实录与排查思路4.1 无界队列把内存吃满线程池形同虚设之前线上有个批处理服务某天监控突然告警老年代内存占用一路飙升GC越来越频繁。当时很多人怀疑是内存泄漏后来dmp一看内存几乎全部被一堆LinkedBlockingQueue的节点占着里面全是待处理的短信发送任务。原因很简单这个服务图省事用了Executors.newFixedThreadPool(10)默认队列是容量Integer.MAX_VALUE的无界队列。业务高峰期短信任务持续涌入核心线程根本消费不过来任务全堆在队列里响应越来越慢内存也越来越高。排查步骤其实就是“三板斧”先看监控确认内存曲线与任务量关系再抓一次jstack看线程状态最后dmp内存分析定位到队列类型。修复方案也不复杂把无界队列换成有界ArrayBlockingQueue并用CallerRunsPolicy做兜底问题立刻消失。这个案例你应该记住线程池的队列容量就是你的安全阀千万别用无界的。4.2 线程数开太高CPU全耗在上下文切换上另一个经典事故某团队处理一批IO密集型任务把线程池设置在64核机器上开了1024个线程结果CPU使用率到了90%以上但任务处理吞吐反而下降。这其实是上下文切换打爆了CPU。线程从“运行中”到“等待中”再到“运行中”每次切换都要保存和恢复寄存器状态1024个线程来回抢CPU真实干活的时间所剩无几。我们从jstack里看大量线程处于RUNNABLE状态但实际都在争抢CPUvmstat里的cscontext switch列高得吓人。解决方式就是按公式重新计算把线程数降到合理区间再把队列设成有界的。这类事故也提醒我线程数并不是越大越好线程池的价值是复用不是“多开”。4.3 日志里没有任何异常任务却悄悄失败第三个坑是“无日志失败”特别隐蔽。有次一个定时任务调度服务任务列表中有一批数据一直没处理但日志里干干净净没有任何报错。查到最后发现任务提交用的是executor.submit(task)返回值Future被直接扔掉了。任务执行过程中某个依赖接口超时抛了RuntimeException异常被FutureTask包装吞掉既不会打日志也不会影响其他线程。因为没有调用future.get()异常就像被藏在了保险柜里没有任何人能看见。修复方案前面也提到了要么在任务内部自己兜住异常要么统一改用execute()配合UncaughtExceptionHandler或者提交后保留Future并统一get()。从那之后“谁提交、谁负责处理异常”就成了我在代码评审里必查的一条规则。5. 我现在的线程池配置模板与监控手段5.1 一套可复用的配置模板带计算过程分享一个我在真实业务里用过的模板业务场景是订单状态同步平均耗时约150ms接口峰值QPS大约508核机器。核心线程数8 * 2 16 最大线程数16 * 2 32 队列容量允许排队2秒单任务150ms则队列 ≈ 2 / 0.15 ≈ 13这里取16 回收时间60s业务突发流量不会持续太久60秒足够 线程名order-sync-pool-N 拒绝策略CallerRunsPolicy宁可让主线程慢一点不能丢任务要注意这个模板只适用于“IO密集 可容忍少量阻塞”的场景。如果任务本身会占用大量CPU就得按CPU密集来算开太多线程反而帮倒忙。模板的意义在于公式和推导逻辑而不是让你照抄每个数字。5.2 线程池监控别等事故后才发现线程池不是new完就丢的。我的习惯是给核心线程池加监控至少保持三个指标getActiveCount()当前活跃线程数反映瞬时负载。getQueue().size()队列积压数这个指标比活跃线程更早暴露问题。getCompletedTaskCount()累计完成任务数用来计算吞吐趋势。再配合重写RejectedExecutionHandler把每次拒绝都记一笔Metrics拒绝量一旦突增就告警。这样“队列堆了”“任务被拒了”“线程来不及消费”都能第一时间被看到不用等内存满了才惊觉。最后再分享一个小经验线程池的动态调参不是花架子我现在会在流量高峰来临前通过setCorePoolSize和setMaximumPoolSize预先扩容量高峰过后再缩回去实测下来比固定参数稳定很多。线程池这个东西面试时是“八股文”线上用起来就是一门“配置和运维的艺术”两者都得会才算真正入门了。