最近帮一个学弟做模拟面试,我让他先讲讲线程池的七个参数,他背到第四个就卡住了。其实不怪他,线程池这块的八股文确实又多又杂,网上随便一搜就是几十篇文章,但大部分都是抄来抄去,没有一个能让人真正"打通任督二脉"。我自己面试别人也快五年了,发现面试官问线程池,翻来覆去其实就那么几个点:七个参数、执行流程、阻塞队列怎么选、execute和submit的区别、拒绝策略、核心线程数怎么配。只要把这些点真正吃透,线程池面试基本就稳了。这篇就把我自己总结的线程池八股文完整盘一遍,适合正在准备Java面试的人,也适合想系统把线程池搞明白的后端开发者,尤其是那些背了很多却串不起来的同学。
1. 线程池到底解决了什么问题
1.1 先搞清楚为什么要用线程池
很多新手背线程池八股,上来就背参数,但问一句"为什么需要线程池"就懵了。其实这个问题的答案才是理解整棵知识树的根。Java里创建一个Thread,背后要做的事情比你想象的多得多:JVM要申请内存、创建本地线程、分配栈空间(默认1MB)、注册到操作系统线程调度器,等任务执行完还要再销毁。在高并发场景下,如果每个请求都现开一条线程,线程的创建销毁开销会占用大量CPU和内存资源,更重要的是线程数量完全不可控——流量一上来,光线程的栈空间就能把内存挤爆,紧接着就是频繁的上下文切换,系统性能断崖式下跌。
线程池的核心思路就是"池化":提前创建一批线程放在池子里,任务来了不用现开线程,直接扔给池里的空闲线程执行;线程用完也不销毁,而是留着复用。这个思路跟数据库连接池、对象池一模一样,都是典型的空间换时间、复用换性能。另外,线程池还解决了另一个痛点——统一管理。你要控制并发度、要拿到任务执行结果、要等待一批任务全部完成、要做定时任务,用原生Thread都很别扭,而ExecutorService这套接口把这些都封装好了。
1.2 从Executor到ThreadPoolExecutor的类层级
线程池相关的类层级,面试偶尔会考,但大多数时候是作为背景知识。我建议把这几个接口和类的职责理清楚,否则看源码的时候容易迷路。
Executor:最顶层接口,只有一个方法execute(Runnable),语义是"执行任务,但不关心结果"。ExecutorService:继承Executor,扩展了submit、shutdown、shutdownNow、invokeAll等方法,增加了生命周期管理和任务结果获取能力。AbstractExecutorService:抽象类,用模板方法模式把submit的流程固定下来——把任务包装成FutureTask,再调用execute去执行。ThreadPoolExecutor:核心实现类,真正干活的家伙,线程管理、任务调度、队列交互全在这里。ScheduledThreadPoolExecutor:继承ThreadPoolExecutor,额外支持定时和延迟任务的调度。
submit和execute的关系,在这个继承链里已经能看出七八成了:submit最终也是走到execute,只是外面多包了一层FutureTask,后面我详细说。
2. 七个参数和任务执行流程,面试必背
2.1 七个参数逐个拆解
ThreadPoolExecutor 最全的构造器有七个参数,这是线程池八股文的地基,前面的corePoolSize和maximumPoolSize是最常被问到的。我用一张表把七个参数一次性捋清楚:
| 参数 | 含义 | 关键说明 |
|---|---|---|
| corePoolSize | 核心线程数 | 即使空闲也默认存活(除非设置了allowCoreThreadTimeOut) |
| maximumPoolSize | 最大线程数 | 线程池允许创建的线程上限,包含核心+非核心 |
| keepAliveTime | 非核心线程空闲存活时间 | 超过这个时间没有任务,非核心线程被回收 |
| unit | keepAliveTime的时间单位 | 秒、毫秒、微秒等 |
| workQueue | 任务队列 | 核心线程忙时,任务先塞给队列 |
| threadFactory | 线程工厂 | 用来创建新线程,可以自定义线程名、优先级 |
| handler | 拒绝策略 | 线程数和队列都满时,怎么处理新任务 |
这里有几个八股文很少讲但面试很容易追问的细节。第一,核心线程数是"懒加载"的,线程池刚创建时并不会立刻创建核心线程,而是等第一个任务提交时才创建,除非手动调用prestartAllCoreThreads()预热,这个细节在需要控制启动耗时或者提前验证线程池配置的时候很实用。第二,keepAliveTime默认只对非核心线程生效,但你可以通过allowCoreThreadTimeOut(true)让核心线程空闲超时后也被回收,这在核心线程数配多了、想动态降资源的时候有用。第三,threadFactory强烈建议自定义,至少要把线程名设置成有业务含义的(比如order-async-thread-1),否则线上排查问题时一看到pool-3-thread-1这种默认名字,你根本不知道是哪个业务在跑,定位问题的成本会高很多。
2.2 提交一个任务,线程池内部到底干了什么
这是面试问的概率最高的题,也是很多人都背反了的一道题。先记住结论:优先用核心线程,核心不够就进队列,队列满了才创建非核心线程,线程数到上限就触发拒绝策略。很多人的错误认知是"核心线程满了先创建新线程,新线程也满了再进队列",这是完全错误的。
我用一个具体例子把流程走一遍。假设线程池配置为:corePoolSize=2、maximumPoolSize=5、workQueue容量=3。这时候依次提交 10 个任务:
- 第 1 个任务:线程数(0)< 核心线程数(2),创建核心线程 A 执行。
- 第 2 个任务:线程数(1)< 核心线程数(2),创建核心线程 B 执行。
- 第 3 个任务:核心线程 A、B 都忙,线程数(2)已达到核心数,任务进入队列,队列现在有 1 个。
- 第 4、5 个任务:同理,继续进队列,队列现在有 3 个(队满)。
- 第 6 个任务:核心线程还是 2 个,队列已满,此时创建非核心线程 C 执行。
- 第 7、8 个任务:队列依然满载,继续创建非核心线程 D、E,线程数达到最大值 5。
- 第 9、10 个任务:线程数已经是最大值 5,队列也满了,触发拒绝策略。
这个过程可以用一段伪代码表示:
提交任务execute(command): 1. 如果当前线程数 < corePoolSize: 创建核心线程执行任务,返回 2. 否则,尝试把任务放入workQueue: 如果入队成功,返回 3. 否则,如果当前线程数 < maximumPoolSize: 创建非核心线程执行任务,返回 4. 否则: 执行拒绝策略handler.rejectedExecution(command)这里看源码时有个细节:第2步入队成功后,源码里还会再检查一次线程池状态和线程数,如果发现线程池关闭了就把任务从队列移除并走拒绝策略;如果发现线程数变成0了,会新建一个线程去处理队列里的任务。这个保护逻辑是为了避免线程池里一个线程都没有、队列里却堆着任务却没人执行的情况。
2.3 线程池的5种状态和生命周期
线程池的状态也是高频考点,我见过不少面试官把状态和shutdown/shutdownNow放在一起考。ThreadPoolExecutor 里有五个状态:
| 状态 | 含义 | 能否接受新任务 | 能否处理队列任务 |
|---|---|---|---|
| RUNNING | 运行中 | 能 | 能 |
| SHUTDOWN | 已调用shutdown | 不能 | 能,处理完队列才真正结束 |
| STOP | 已调用shutdownNow | 不能 | 不能,还会中断正在执行的任务 |
| TIDYING | 任务全部清空,线程数归零 | 不能 | 不能 |
| TERMINATED | terminated()执行完毕 | 不能 | 不能 |
状态流转的触发点:shutdown()从 RUNNING 到 SHUTDOWN;shutdownNow()从 RUNNING 到 STOP;SHUTDOWN/STOP 状态下,线程池里的任务清空、线程数归零后进入 TIDYING;最后执行terminated()钩子方法后进入 TERMINATED。
这里说个源码层面的硬核细节,面试讲出来很加分:线程池的运行状态和线程数量是打包存在一个AtomicInteger变量ctl里的,高3位存状态,低29位存线程数。之所以用原子变量而不是两个变量,是为了保证状态和线程数的一致性——比如判断"线程池是否在运行"和"线程数加一"这两个操作必须是一个CAS,否则并发下会出现状态已经变了但线程还在继续创建的竞态问题。
3. 阻塞队列怎么选,这道菜怎么配
3.1 常用阻塞队列横向对比
阻塞队列的选择直接决定了整个线程池面对压力时的行为,面试官爱问这个,其实是考你对"线程池的各个参数是怎么协同工作的"有没有真正理解。我先把常用的几个队列摆出来对比:
| 队列 | 有界/无界 | 底层结构 | 锁机制 | 典型场景 |
|---|---|---|---|---|
| ArrayBlockingQueue | 有界 | 数组(循环队列) | 单锁(put和take共用一把锁) | 有界队列控流,用得最多 |
| LinkedBlockingQueue | 可指定,默认无界 | 链表 | 双锁(put和take各一把) | 吞吐量高,但默认无界有OOM风险 |
| SynchronousQueue | 不存储任务 | 直接交接 | 基于CAS或锁 | 任务不排队,直接交给线程执行 |
| PriorityBlockingQueue | 无界 | 堆 | 单锁 | 任务需要按优先级执行 |
| DelayQueue | 无界 | 优先队列 | 单锁 | 延迟任务,ScheduledThreadPoolExecutor专用 |
很多人记不住ArrayBlockingQueue和LinkedBlockingQueue的区别,我问你一个问题就能记住:**为什么LinkedBlockingQueue默认不用指定容量?**因为它是链表结构,理论上可以无限往后面挂节点,而ArrayBlockingQueue是数组,创建时长度就固定了。所以LinkedBlockingQueue如果你不传容量,它就是无界的,任务可以无限堆积——这是后面要重点讲的OOM隐患。
3.2 不同队列组合下的线程池行为差异
队列不是孤立存在,它和corePoolSize、maximumPoolSize、拒绝策略的组合,决定了线程池面对高并发时的几种典型行为。我挑三种最常见的组合来说。
组合一:LinkedBlockingQueue无界 + 固定线程数(比如newFixedThreadPool)。因为队列永远不会满,线程数永远保持在corePoolSize(也就是maximumPoolSize),非核心线程永远不会创建,拒绝策略永远不会触发。看起来"稳定",实际上是个定时炸弹——任务积压时,队列长度无限膨胀,内存消耗越来越大。Java的OutOfMemoryError: insufficient memory经常就是这么来的,后面我会展开讲。
组合二:SynchronousQueue + 很大的maximumPoolSize(比如newCachedThreadPool)。SynchronousQueue不存任务,每个任务必须找一个空闲线程接手,如果没有空闲线程,就创建一个新线程。所以这个组合下线程数会随着并发量飙升而暴涨,最高可以到Integer.MAX_VALUE。适合执行时间短、数量大的异步任务,但如果任务执行时间稍微长一点,加上高并发,线程数量就会失控。
组合三:ArrayBlockingQueue有界 + 自定义拒绝策略。这是生产环境最稳的组合。队列容量设一个合理上限,比如2000;拒绝策略用CallerRunsPolicy或者带告警的自定义策略。这样队列积压可控,超了就用"让提交线程自己执行"来背压,既不会丢任务也不会内存爆炸。
3.3 队列选型建议
选队列,本质是在选线程池面对压力的"脾气"。我的实践经验是:第一,只要任务有积压风险,就坚决用有界队列,容量按"高峰时的可容忍积压量"来定,比如削峰场景允许积压1000个任务,那就设1000。第二,任务需要按优先级处理(比如会员订单优先),用PriorityBlockingQueue,但一定要考虑优先级一直很高的任务会不会饿死其他低优任务。第三,延迟调度任务,比如订单超时关闭、缓存过期清理,用DelayQueue或者直接用ScheduledThreadPoolExecutor,别自己拿普通线程池在那 sleep 轮询。第四,追求极低延迟、短任务的场景,可以考虑SynchronousQueue,但必须给maximumPoolSize设一个合理的上限,别像CachedThreadPool那样放任自流。
4. 说烂了但必须会:execute和submit、拒绝策略
4.1 execute和submit到底差在哪
execute和submit这个题几乎每一场面试必考,但很多人只背了"一个没有返回值,一个有返回值"就以为会了。这个题至少要答出三个层次:返回值、异常处理、实现方式。
第一个层次,返回值:execute接收Runnable,没有返回值;submit接收Runnable或Callable<T>,返回Future<T>,可以用来获取执行结果。
第二个层次,异常处理,这才是这个题的精髓。用execute提交任务,如果任务抛异常,异常会直接抛到当前线程(也就是Work线程),线程池会把这个线程移出,然后创建一个新线程补充,同时异常会通过Thread.uncaughtExceptionHandler打印到控制台。用submit提交任务,任务内部抛出的异常会被封装到Future对象里,如果没有调用future.get(),这个异常就静默丢失了。这是生产环境一个非常隐蔽的bug来源——任务执行失败了你完全不知道,日志里干干净净,但数据就是不更新。
// 错误示范:异常被静默吞掉 ExecutorService pool = Executors.newFixedThreadPool(4); Future<?> future = pool.submit(() -> { throw new RuntimeException("任务执行失败"); }); // 不调用 future.get(),堆栈里看不到任何异常 // 正确做法:必须get try { future.get(); } catch (ExecutionException e) { log.error("任务执行异常", e.getCause()); }第三个层次,实现方式:submit内部应用的是模板方法模式。AbstractExecutorService.submit把任务包装成一个RunnableFuture(本质是FutureTask),然后调用execute(runnableFuture)。也就是说submit底层也是走execute的,区别只在于外包了一层"取结果"的能力。面试如果能答出这一层,说明你真看过源码,不是背的。
4.2 四种拒绝策略怎么用
当线程池达到 maximumPoolSize、队列也满时,新任务就会交给拒绝策略处理。ThreadPoolExecutor 内置了四种策略:
| 策略 | 行为 | 使用建议 |
|---|---|---|
| AbortPolicy(默认) | 直接抛RejectedExecutionException | 不用的话,业务方根本感知不到任务被拒 |
| CallerRunsPolicy | 由提交任务的线程自己执行该任务 | 生产环境最推荐,天然背压 |
| DiscardPolicy | 静默丢弃,不抛异常 | 不推荐,丢任务无感知 |
| DiscardOldestPolicy | 丢弃队列最前面的任务,然后重新提交 | 适合丢弃旧任务、保新任务的场景 |
生产环境我见得最多的两个选择:CallerRunsPolicy和自定义策略。CallerRunsPolicy的核心价值在于"谁提交谁执行",提交任务的上游线程被当前任务占用了,等于强迫上游放慢提交速度,形成一种天然的背压保护机制,而且因为任务由提交线程执行,这个任务一定不会丢,只是延迟了。
如果你需要在拒绝的时候做告警、记录日志、把任务转存到数据库或者MQ,就自定义RejectedExecutionHandler:
public class AlarmRejectedExecutionHandler implements RejectedExecutionHandler { @Override public void rejectedExecution(Runnable r, ThreadPoolExecutor e) { // 告警:线程池已满,发送监控通知 alarmService.send("线程池已满 task=" + r.toString() + " activeCount=" + e.getActiveCount() + " queueSize=" + e.getQueue().size()); // 兜底:转存到MQ,等低峰期再消费 mqProducer.send(new TaskMessage(r)); } }注意,自定义RejectedExecutionHandler里可以访问ThreadPoolExecutor对象,所以能拿到当前活跃线程数、队列大小这些指标,这对于做监控告警非常有用。
5. 线程池配置:核心线程数到底填多少
5.1 两种经典估算公式
"线程池配置多少个线程合适"是面试官非常喜欢追问的开放题,因为它没有标准答案,考的就是你有没有实际调优经验。先记住两个最经典的估算公式。
CPU密集型任务:线程数 = CPU核数 + 1。为什么要加1?因为CPU密集型的核心瓶颈是计算能力,线程数等于核心数就能把CPU跑满,加1是为了保证某个线程偶尔因为页缺失、GC暂停等问题被阻塞时,还有一个线程能顶上,让CPU不空转。
IO密集型任务:线程数 = CPU核数 * 2,或者更精确一点,CPU核数 * (1 + 等待时间/计算时间)。IO密集型的特点是线程大部分时间在等待IO返回(网络请求、数据库查询、磁盘读写),等待时CPU是空闲的,所以要多开一些线程把CPU占满。举个例子,一台16核的机器,某个任务平均计算耗时 20ms,等待IO耗时 160ms,那等待时间/计算时间就是8,理论线程数 = 16 * (1 + 8) = 144。
但我要强调一点:公式只是起点,不是终点。真实业务比公式复杂得多,比如一个接口里同时有CPU计算和IO等待,再比如多个线程池之间还会共享CPU。真正可靠的办法是:先按公式估算一个量级,然后到压测环境跑负载,观察CPU使用率、线程池队列长度、任务执行RT、系统吞吐量,再逐步调整。我一般以CPU使用率在 70%~85% 之间为调优目标,如果线程数太小,CPU使用率上不去,吞吐量被压住了;如果线程数太大,上下文切换开销会明显增加,RT反而变高,吞吐量下降——这就是传说中的"线程数过多反而更慢"现象。
5.2 Executors快捷工厂方法:方便但有毒
很多Java初学者最早接触线程池,用的都是Executors里的快捷方法,因为一行代码就能创建线程池,非常方便。但几乎所有大厂的代码规范都明确禁止使用Executors创建线程池,因为它的默认配置在生产环境都是有坑的。
newFixedThreadPool(10)底层用的是无界的LinkedBlockingQueue,任务堆积时队列无限增长,直接耗尽内存;newSingleThreadExecutor同理,也是无界队列;newCachedThreadPool更夸张,最大线程数是Integer.MAX_VALUE,高并发下系统会创建出成千上万个线程,线程栈内存、上下文切换都会压垮机器;newScheduledThreadPool的最大线程数同样是Integer.MAX_VALUE。看到没有,这四个快捷方法,要么队列无界,要么线程数无上限,都埋着OOM的雷。
我自己就踩过这个坑,后面踩坑章节详细讲。正确的做法是直接用new ThreadPoolExecutor(...)显式传七个参数,哪怕麻烦一点也要写清楚,这是对线上稳定性负责。
5.3 SpringBoot里的线程池配置方式
真实项目里不太可能让你手动new线程池然后到处传递,大多是用Spring管理。SpringBoot里用线程池最常见的姿势是配合@Async做异步任务,但这里有个大坑:如果只加了 @EnableAsync 和 @Async,而没有自定义线程池,Spring默认用的是SimpleAsyncTaskExecutor,它根本不是池化线程池——每次请求都新建一个线程,执行完就销毁,并发高的时候线程数完全失控。正确做法是自定义一个线程池配置类,替换掉默认的:
@Configuration public class AsyncConfig implements AsyncConfigurer { @Bean("bizAsyncExecutor") public ThreadPoolTaskExecutor bizAsyncExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); // 核心线程数,按IO密集型估算:CPU核数 * 2 executor.setCorePoolSize(32); // 最大线程数:核心线程数 * 2 executor.setMaxPoolSize(64); executor.setQueueCapacity(1000); executor.setKeepAliveSeconds(60); executor.setThreadNamePrefix("biz-async-"); executor.setRejectedExecutionHandler(new CallerRunsPolicy()); executor.initialize(); return executor; } }然后通过@Async("bizAsyncExecutor")指定线程池。这里还有几个要注意的细节:一个是ThreadPoolTaskExecutor是Spring封装的,它底层包了真实的ThreadPoolExecutor,队列容量需要显式设置,不要用默认值;另一个是Spring默认的Bean初始化顺序可能导致线程池未被完全初始化,建议在initialize()之前把所有参数设置完,再在应用启动时做一次预热,避免第一个请求触发线程创建带来的RT毛刺。
再延伸一个场景:热词里有个springboot + sseemitter + 线程池,SSE(Server-Sent Events)是服务端向浏览器单向推送消息的技术。SSE连接是长连接,如果每个连接都占用一个Tomcat工作线程,几百个连接就把Tomcat线程池打满了。所以正确的做法是把SSE推送消息的任务丢到一个独立的业务线程池里去执行,让Tomcat工作线程立刻释放,SSE连接只负责单向输出。这个场景其实就是"把IO密集的长连接从Tomcat默认线程池里剥离出来,用独立的、配置合理的线程池来管理"。
6. 踩坑实录:线程池常见问题和排查
6.1 线上OOM和任务堆积
我印象最深的一次故障,就是用Executors.newFixedThreadPool(10)去消费MQ消息。白天流量低,一切正常;到了晚上促销,大量消息一下子涌进来,线程池里的10个线程根本消费不过来,消息任务全部堆积在无界队列里。每个任务对象里还引用了一大批业务数据,堆积了成千上万条之后,堆内存直接被打满,应用开始疯狂Full GC,最后抛出java.lang.OutOfMemoryError: insufficient memory,整个服务宕机重启。
这个故障的本质,就是无界队列 + 固定线程数这个组合,让任务积压量失去了上限。排查这种问题,我建议分三步走:第一步,用jstack看一下线程栈,确认哪些线程在运行、哪些在等待,如果看到很多调度线程都在处理同一个业务的任务,基本就能锁定线程池位置;第二步,用jstat -gcutil看堆内存占用和GC频率,OOM之前通常会有连续的Full GC,且内存一直降不下来;第三步也是最重要的一步,把线程池的关键指标——活跃线程数、队列大小、完成任务数、拒绝次数加到监控系统里,这样问题出现时你是有数据支撑的,不用瞎猜。
解决方式也很明确:把无界队列换成有界队列,容量按可容忍的积压量设置;同时给线程池配一个拒绝策略,CallerRunsPolicy或者自定义告警策略,一旦队列满了要能立刻感知,而不是闷头堆积。这次故障之后,我对八股文的态度彻底变了——那些面试题背后真的是血泪。
6.2 线程泄漏与任务丢失
线程池第二类经典问题,是"看起来在执行,实际上一堆bug"。第一个常见坑是ThreadLocal没有清理。线程池里的线程是复用的,ThreadLocal作为线程私有变量,在线程被复用时不会被自动清空。你在一个任务里往ThreadLocal塞了数据,下一个任务会拿到上一个任务残留的脏数据,轻则日志串了,重则业务逻辑直接出错。解决办法是每次任务执行完后,在finally块里调用remove()清理。
第二个坑是拒绝策略导致任务静默丢失。默认的AbortPolicy虽然抛异常,但很多新手没有捕获,异常打在日志里也没人去翻;而DiscardPolicy更狠,直接把任务丢掉,不抛异常不记录,如果这个任务是订单数据,那就等于订单凭空消失了。所以生产环境我几乎只用CallerRunsPolicy或者自定义策略,前者保证不丢,后者至少要打日志、做告警,绝对不能静默丢弃。
第三个坑是线程池关闭时机不对。shutdown()和shutdownNow()很多人分不清:shutdown()是不接受新任务,但会等队列里的任务全部执行完才真正关闭,适合优雅停机;shutdownNow()是不接受新任务、清空队列、中断正在执行的任务,并返回队列里未执行的任务列表,适合需要快速释放资源的场景。如果线上用的是shutdownNow(),一定要处理返回的未执行任务列表,否则这部分任务就丢了,需要自己转存或者补偿执行。
6.3 高频追问速查表
最后把我这几年面试中被问到的高频线程池问题整理成一张速查表,面试前过一遍,基本不会慌了:
| 问题 | 标准回答要点 |
|---|---|
| 核心线程会不会被回收? | 默认不会,即使空闲也保留;设置 allowCoreThreadTimeOut(true) 后,核心线程空闲超过 keepAliveTime 也会被回收 |
| 非核心线程什么时候创建? | 不是核心满就立刻创建,而是核心满 + 队列满后才创建 |
| 非核心线程什么时候回收? | 任务执行完,空闲时间超过 keepAliveTime 就被回收 |
| 线程池参数能动态调整吗? | 可以,setCorePoolSize、setMaximumPoolSize、setKeepAliveTime;也可以借助动态线程池框架可视化调整 |
| execute和submit怎么选? | 要执行结果用submit,只要触发不关心结果用execute;submit必须get,否则异常会丢 |
| 怎么给线程池的线程命名? | 自定义threadFactory,设置带业务含义的线程名 |
| 线程池会提前创建线程吗? | 默认不会,懒加载;prestartAllCoreThreads() 可以预热所有核心线程 |
| 怎么监控线程池状态? | getPoolSize、getActiveCount、getQueue().size()、getCompletedTaskCount 等指标,接入监控系统 |
我个人在实际排查里还发现一个实用的技巧:面试八股和真实调优最大的差距在于,代码里的参数会告诉你"怎么配",但不会告诉你"为什么这么配"。如果你时间有限,与其背十篇八股,不如写个demo,把corePoolSize=2, maxPoolSize=5, queue=3的线程池跑起来,提交十几个任务,打印每个任务的执行线程名和线程池的activeCount、queueSize变化。这个demo跑一遍,你就能亲眼看到任务是怎么从核心线程走到队列、从队列走到非核心线程、最后被拒绝的。比死记硬背执行流程有效十倍。面试官问到你线程池熟悉吗,你把这些链路串起来讲一遍,再补一句"这个组合的线上表现我实际验证过",基本就过关了。