面试了这么多年人,线程池这道题几乎每次都会出现在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,同时继承了AbstractQueuedSynchronizer(AQS)。继承AQS是为了实现“任务执行中不能被中断”的控制,保证shutdownNow()只中断等待中的线程,而不是打断正在跑的任务。这里的细节平时不用深抠,但知道“Worker + AQS”这个组合,面试会明显加分。
第7、8问要一起答:非核心线程在keepAliveTime时间内从队列拿不到任务,就会退出循环被回收;核心线程默认永远存活,除非你设置了allowCoreThreadTimeOut(true),这时候核心线程也会走一样的超时回收逻辑,线程数可以降到0。注意,就算keepAliveTime=0,非核心线程处理完当前任务后也会立刻退出。
第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状态但实际都在争抢CPU,vmstat里的cs(context switch)列高得吓人。
解决方式就是按公式重新计算,把线程数降到合理区间,再把队列设成有界的。这类事故也提醒我:线程数并不是越大越好,线程池的价值是复用,不是“多开”。
4.3 日志里没有任何异常,任务却悄悄失败
第三个坑是“无日志失败”,特别隐蔽。有次一个定时任务调度服务,任务列表中有一批数据一直没处理,但日志里干干净净,没有任何报错。
查到最后,发现任务提交用的是executor.submit(task),返回值Future被直接扔掉了。任务执行过程中某个依赖接口超时抛了RuntimeException,异常被FutureTask包装吞掉,既不会打日志,也不会影响其他线程。因为没有调用future.get(),异常就像被藏在了保险柜里,没有任何人能看见。
修复方案前面也提到了:要么在任务内部自己兜住异常,要么统一改用execute()配合UncaughtExceptionHandler,或者提交后保留Future并统一get()。从那之后,“谁提交、谁负责处理异常”就成了我在代码评审里必查的一条规则。
5. 我现在的线程池配置模板与监控手段
5.1 一套可复用的配置模板(带计算过程)
分享一个我在真实业务里用过的模板,业务场景是:订单状态同步,平均耗时约150ms,接口峰值QPS大约50,8核机器。
核心线程数: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预先扩容量,高峰过后再缩回去,实测下来比固定参数稳定很多。线程池这个东西,面试时是“八股文”,线上用起来就是一门“配置和运维的艺术”,两者都得会,才算真正入门了。