
1. 线程池七个参数真正的坑不在背参数而在参数之间的联动关系线程池的七个参数几乎是人人都能背出来的东西corePoolSize核心线程数、maximumPoolSize最大线程数、keepAliveTime空闲存活时间、unit时间单位、workQueue阻塞队列、threadFactory线程工厂、handler拒绝策略。但面试问七参数真正想听的不是你背得多溜而是你对参数之间如何互相制约的理解。很多候选人会答“核心线程数满了之后任务进队列队列满了之后创建非核心线程线程数到最大之后走拒绝策略”这个流程背诵得滚瓜烂熟。但只要我把问题稍微变一下就露馅了。比如“线程池是先扩容到最大线程数还是先用队列缓冲”答案是用队列缓冲不是先扩容。也就是说线程池的执行优先级是核心线程 - 阻塞队列 - 非核心线程 - 拒绝策略。这个顺序理解错了后面所有问题都会带偏。再往深问一层非核心线程什么时候开始空闲回收keepAliveTime是只对非核心线程生效吗实际上如果你调用了allowCoreThreadTimeOut(true)核心线程也会被回收。这是很多人在生产环境里设置了keepAliveTime但发现线程数量没有降下来排查了半天才找到的原因。默认情况下核心线程即使空闲也不会被回收除非你显式开启allowCoreThreadTimeOut。这个细节面试官很爱往这个方向挖。还有一个很隐蔽的参数关系corePoolSize和maximumPoolSize之间的差值区间。当线程数在core和max之间时新任务到底是继续创建线程还是进队列答案是只要线程数还没到maximumPoolSize并且队列已满就会继续创建线程。但线程数在core到max之间、队列还没满的时候新任务会优先进队列。所以队列的长度、corePoolSize、maximumPoolSize这三个参数实际上是一个“先缓冲、后扩容”的三级杠杆。你调整任何一个都会影响另外两个发挥作用的条件。线程工厂这个参数很多人直接忽略或者只知道它能给线程起名字。但线程工厂真正重要的价值在于设置了自定义的UncaughtExceptionHandler。线程池里的线程在执行任务时如果抛出了未捕获的异常会导致该线程直接销毁并重建你要是不通过线程工厂里的UncaughtExceptionHandler去记录日志线上出了异常你根本不知道是哪个任务、哪个线程出了问题。我经手过的故障排查里至少有两三次是靠自定义线程工厂里埋的异常日志才定位到根因的。至于拒绝策略这里埋着一个面试官百问不厌的变体AbortPolicy、CallerRunsPolicy、DiscardPolicy、DiscardOldestPolicy你能说出区别吗AbortPolicy是直接抛RejectedExecutionExceptionDiscardPolicy是静默丢弃DiscardOldestPolicy是丢掉队列里最老的任务再提交当前任务而CallerRunsPolicy是让提交任务的线程自己跑这个任务。绝大多数人停在这里就结束了。但面试官真正想听的是你在生产环境里用过哪种拒绝策略哪一种会让调用线程阻塞哪一种会让你丢掉业务数据我的经验是AbortPolicy最安全但也最粗暴一饱和就抛异常调用方如果没接住任务就丢了CallerRunsPolicy会把压力反馈给调用线程相当于一个天然的背压机制但代价是提交任务的线程会被阻塞如果提交线程是Tomcat的请求线程那Tomcat的线程也会被拖慢。Discard和DiscardOldestPolicy在业务上风险较高因为它们会静默丢数据。生产环境里我更倾向CallerRunsPolicy或者自定义拒绝策略把被拒绝的任务持久化到本地表或者消息队列里后续再补偿。这个点在面试里说出来面试官基本会点头。2. execute和submit同样是提交任务面试官就在等你踩这个坑execute和submit的区别是线程池面试里出镜率极高的一个考点。你要是只说“execute提交Runnable任务submit可以提交Runnable和Callable并返回Future”那就太浅了。面试官下一个问题一定会跟着来submit的异常去哪儿了为什么我用execute执行任务时catch不到异常改用submit就能catch到先说execute。execute(Runnable command)是Executor接口的方法提交的任务如果执行过程中抛出了RuntimeException这个异常会直接抛出到线程池的内部正常情况下你的外层try-catch根本捕获不到因为异常发生在线程池里的工作线程上不在你提交任务的调用线程上。如果你没有在threadFactory里设置UncaughtExceptionHandler这个异常会被线程池吞掉只会在日志里留下一行堆栈甚至有些情况下连日志都没有。而submit(Runnable task)就不一样了。submit返回Future对象真正执行的时候内部会把你的Runnable或Callable包装成FutureTask异常会被捕获并保存在FutureTask内部直到你调用future.get()的时候才重新抛出来。所以如果你submit任务之后从来不调用get()异常同样也是静默丢失。这一点特别容易踩坑很多人说“我用了submit就能捕获异常了”实际上是因为他调用了get()如果没调get()异常照样不吭声。再说一个很多人没注意过的细节submit(Runnable task)返回的Futureget()返回的是nullsubmit(Runnable task, T result)返回的Futureget()返回的是你传入的result对象。submit(Callable task)返回的是Callable的返回值。这三个重载方法如果能在面试时全部讲清楚面试官会认定你是真的用过而不是背过。还有一个隐藏很深的点submit方法内部依然是通过execute方法执行任务的。所以execute和submit本质上是同一条执行链路只是submit在外面包了一层FutureTask。这意味着如果你提交的任务本身是个Runnablesubmit之后在线程池里执行时实际执行的是FutureTask的run方法。FutureTask的run方法里面会捕获所有异常放进outcome字段。这个底层机制了解清楚后你再去看FutureTask的源码就能把整个异常传递链路串起来了。面试官另一个高频追问是execute抛出RejectedExecutionException那submit会不会抛出答案是会而且submit返回的Future根本拿不到因为异常在提交阶段就抛出来了。所以如果你用submit提交任务但对拒绝策略没有兜底拒绝异常依然会出现在提交线程上。这个问题在面试里少有人能主动提出来你能说出来就很加分。生产环境里的建议是如果你不需要关心任务执行结果用execute提交即可但务必配合自定义线程工厂的UncaughtExceptionHandler做异常兜底如果你需要拿到执行结果或捕获任务执行异常用submit并且一定要调用get()方法或者至少调用Future.isDone()/isCancelled()确认任务状态。不要提交完就不管了那是把异常埋进地雷里。3. 阻塞队列怎么选官方默认的LinkedBlockingQueue不一定适合你阻塞队列选择这个方向在面试中比重不低而且在生产环境里踩坑的概率极高。很多人直接使用Executors.newFixedThreadPool以为万事大吉。但Executors工厂方法返回的线程池底层用的是LinkedBlockingQueue这个队列默认容量是Integer.MAX_VALUE换句话说这几乎是一个无界队列。无界队列意味着什么意味着maximumPoolSize参数永远不会被触发拒绝策略也永远不会生效因为任务会一直堆到内存耗尽为止。线上OutOfMemoryError就是这么来的。先把Executors的坑说透。newFixedThreadPool用无界LinkedBlockingQueue线程数固定任务无限堆积newSingleThreadExecutor同理也是无界队列newCachedThreadPool用SynchronousQueue线程数几乎无上限来一个任务就创建一个线程空闲线程60秒回收如果任务提交速度超过处理速度线程数会暴涨直接把CPU和内存打满。newScheduledThreadPool用的是DelayedWorkQueue这个队列不是BlockingQueue接口的实现而是ScheduledThreadPoolExecutor内部自建的一个延迟队列。所以面试题如果问“Executors工厂方法有哪些坑”你只要把上面三条说清楚基本就是标准且深度到位的答案。但如果你能在回答后面补一句“所以我在生产环境中一律使用自定义ThreadPoolExecutor不会直接用Executors”效果会更好。再来看阻塞队列的选型逻辑。ArrayBlockingQueue是有界队列必须指定容量这一点强制你思考任务的堆积上限LinkedBlockingQueue有界时可以指定容量但你手动new的时候很容易漏掉容量参数导致变成无界SynchronousQueue是一种特殊队列不存储任何任务每个入队操作必须等待一个出队操作这相当于直接把手递交给工作线程所以它天然适合线程数弹性很大的场景也就是CachedThreadPool的底层逻辑。面试官喜欢这样问“给你一个场景核心线程数10最大线程数20队列容量是30现在第几个任务开始创建非核心线程第几个任务开始触发拒绝策略”很多人会算错。要算清楚这个问题得记住任务的流转路径前10个任务直接由核心线程执行第11到第40个任务进入队列队列容量30第41个到第50个任务创建非核心线程执行最多再创建10个第51个任务才开始触发拒绝策略。所以触发拒绝策略的总容量是core queueCapacity (max - core)也就是10 30 10 50。这种计算题在面试里出现频率极高本质考的还是参数联动逻辑。但实际生产中阻塞队列选型的核心矛盾从来不是“容量该设多少”而是“当队列堆积时你的业务能不能接受延迟”。如果你的任务对实时性要求很高队列容量就应当设置得小一些宁可触发拒绝策略走降级也不要让任务在队列里等太久。如果任务对实时性不敏感比如定时统计、异步日志、数据同步队列可以大一些但要配合容量监控。我踩过一次坑就是给一个异步通知任务配了一个很大的LinkedBlockingQueue结果下游服务挂了消息全堆积在内存队列里等下游恢复后一瞬间全部冲到下游接口上直接把下游打到雪崩。所以队列不只是缓存还是一个流量蓄水池它会把波峰的压力延迟释放但延迟释放同样可能造成破坏。另外LinkedBlockingQueue和ArrayBlockingQueue在锁粒度上有区别。LinkedBlockingQueue用两把锁take和put各自锁分离并发吞吐更高ArrayBlockingQueue只用一把锁入队出队互斥。所以在高并发场景下LinkedBlockingQueue通常优于ArrayBlockingQueue这也是ThreadPoolExecutor默认使用LinkedBlockingQueue的原因之一。但这个知识点面试里不会细问更多人关心的是容量和内存的关系——队列里每一个等待任务都是实实在在持有着对象引用的任务本身引用的对象全部无法被GC回收。所以无界队列在长时间任务堆积场景下就是内存溢出的温床。还有一个容易被忽略的队列PriorityBlockingQueue。它是有界的但它的“有界”是个假象PriorityBlockingQueue的扩容逻辑是自动增长的它支持优先级排序定长参数并不限制元素个数。ThreadPoolExecutor允许你传入PriorityBlockingQueue但任务的执行顺序会变成按优先级排列而不是FIFO。这个机制在面试里可以作为加分项提一下现实场景中比如你有一个任务队列希望优先处理VIP用户的任务就可以用PriorityBlockingQueue搭配一个自定义Comparator。但要注意线程池的队列是工作队列不保证所有等待任务的有序性如果需要更强的优先级控制更好的做法是拆成多个线程池而不是依赖一个优先级队列。4. 拒绝策略之外线程池状态流转才是决定生死的隐藏逻辑线程池不是一直在RUNNING状态它有自己的生命周期。很多人背出了五态RUNNING、SHUTDOWN、STOP、TIDYING、TERMINATED但对状态之间如何切换、切换后对任务的影响是什么根本说不清楚。RUNNING状态接受新任务并处理队列任务调用shutdown()后进入SHUTDOWN不再接受新任务但会继续处理队列里的存量任务调用shutdownNow()后进入STOP不再接受新任务同时中断正在执行的任务并清空队列返回未执行的任务列表当任务全部终止、队列清空后进入TIDYING执行terminated()钩子方法最后进入TERMINATED线程池完全终止。这里面试官最常挖的坑是shutdown()之后正在执行的任务会被中断吗答案是不会shutdown()只是拒绝新任务存量任务会继续跑直到全部完成线程池才会走向TIDYING。如果你希望正在执行的任务被中断需要使用shutdownNow()。但shutdownNow()也并不是真的能中断所有任务它只是向线程发送interrupt信号如果任务里没有响应中断的逻辑比如没有在循环里检查Thread.currentThread().isInterrupted()或者没有调用会抛InterruptedException的阻塞方法任务依然会继续执行。所以shutdownNow()的“立即停止”只是一个尽力而为的行为不是强杀。再想深一层shutdown()之后队列里的任务还在跑但此时你调用submit会怎样会直接抛RejectedExecutionException而且抛异常的时机在提交代码的调用线程上不是在池内线程上。很多人在服务关闭时还在向外部的异步任务处理器提交任务结果服务明明在优雅停机却报了一堆RejectedExecutionException。这个问题的本质是优雅停机不只是调一下shutdown()就完事了还需要在提交端做好开关控制。还有一种情况在面试中出现的频率也不低线程池的线程在执行任务时抛出异常那这个线程会被回收还是继续存活答案是工作线程会退出线程池会创建一个新的工作线程补充上来保持线程数稳定。这也是为什么前面提到的UncaughtExceptionHandler那么重要因为如果你不记录异常线程池会静默重建线程异常信息直接消失。如果任务经常抛出异常线程池就反复创建新线程这个过程本身是有性能开销的线程数表面上看着稳定实际线程对象在不停更换。这种“表面稳定、内在动荡”的状态你在jstack里能看到线程名的变化但业务上往往感知不到。真正的业务代码里最稳妥的做法是在任务的最外层包一层try-catch把异常捕获、记录、兜底不让异常冒泡到线程池内部。这也正好回答了面试题“如果任务抛出异常线程池会怎么处理”——正确做法是不要让异常冒泡出去自己内部消化掉因为线程池本身并不会帮你做任何任务级的失败重试或补偿。线程池状态流转还有一个细节线程池从TIDYING变成TERMINATED依赖terminated()方法执行完成。如果你重写了terminated()并且在里面做了一些耗时操作线程池的终止就会被延长。这个钩子方法很冷门但确实是源码层面的隐藏逻辑面试时能主动提出来说明你真的读过ThreadPoolExecutor的源码。5. 核心线程数到底配多少公式只是起点动态调优才是经验核心线程数配置网上流行一套“标准答案”CPU密集型配CPU核数1IO密集型配CPU核数*2。这套说法在面试里能过关但实际生产里大多数线程池根本不需要你去精确计算线程数因为瓶颈往往不在线程数量上而在下游系统的承载能力上。CPU密集型的本质是线程几乎不做等待一直占用CPU计算所以线程数超过CPU核数后多出来的线程只会增加上下文切换的开销。CPU核数1这个1是为了在一两个线程偶发缺页中断或IO等待时还有一个线程能顶上保证CPU不空转。IO密集型的本质是线程大量时间在等待IO数据库查询、远程调用、磁盘读写这段时间CPU是空闲的所以可以创建更多的线程去占用这些空闲的CPU时间片。CPU核数*2只是一个粗略的估计真正的IO密集型线程数应该根据“CPU耗时/IO耗时”的比值来计算。公式是最优线程数 CPU核数 * (1 IO耗时 / CPU耗时)。这个公式才是很多面试官想听但不好意思主动说出来的东西。比如一个任务CPU计算耗时20毫秒IO等待耗时80毫秒那IO耗时/CPU耗时就是4假设机器是8核最优线程数就是8*(14)40。但注意这个公式算出来的只是一个理论参考值它假设所有任务均匀分布、IO等待完全独立实际生产里很难精确满足。所以更靠谱的做法是先按公式估算一个初始值然后压测观察吞吐量、CPU使用率、任务堆积情况再做上下调整。我比较推荐的做法是给线程池做一个动态调整能力。ThreadPoolExecutor提供了setCorePoolSize和setMaximumPoolSize方法可以在运行期调整。加上Spring的Scheduled定时任务或监控组件就能实现一个简易的动态线程池。比如每天凌晨业务低峰期你可以定时把核心线程数降到最小白天高峰期再上调。这种动态调整的价值在于你的业务流量不可能一天到晚恒定固定线程数必然在某个时段是浪费的在另一个时段是不够用的。面试中还有一个高频题目“如果我没设置核心线程数线程池会提前创建线程吗”答案是不会。ThreadPoolExecutor默认是懒创建线程任务来了才创建。如果你想让核心线程提前创建好可以通过prestartAllCoreThreads()强制预热。这个方法在抢购、秒杀这类瞬时流量场景中非常有用。如果你没有预热瞬时流量打过来的一瞬间线程池还在忙着创建新线程这个创建过程是有开销的可能会导致部分任务处理延迟。还有被很多面试者忽略的一点核心线程数不是配置得越大越好。上下文切换的开销是实打实的线程从运行状态切换到等待状态需要保存寄存器、程序计数器等状态信息这个开销在频繁切换时比任务本身执行还昂贵。所以当你的任务非常轻量比如只是做一次内存计算线程数反而应该少一点。线程池的本质是用少量线程复用大量任务不是创建一堆线程去并发跑轻量任务。这个问题在“线程数配多少”的追问下面试官很容易挖出来。6. 线程池状态监控和故障排查生产环境里真正救命的三个工具八股文背得再熟练线上出了故障搞不定面试官依然会给你打低分。线程池在生产环境里真正有用的是监控和排查的手段。这部分的实战经验比背参数、背状态要有价值得多。线程池的监控最直接的手段就是看ThreadPoolExecutor提供的几个方法getPoolSize()获取当前线程数、getActiveCount()获取活跃线程数、getCorePoolSize()获取核心线程数、getQueue().size()获取等待队列长度、getTaskCount()获取总任务数、getCompletedTaskCount()获取已完成任务数。这些方法组合起来能算出非常有价值的指标。队列深度是最关键的一个指标。如果队列深度持续上涨说明任务消费速度赶不上生产速度这通常是下游变慢或者线程数不足的信号。如果队列深度一直为0说明任务都是即来即处理线程池大概率处于空闲或满负荷状态需要结合活跃线程数来判断。如果活跃线程数持续约等于最大线程数而队列还在不断增长那基本可以断定线程池已经饱和此时要考虑扩容、削峰或者拒绝策略降级。有了指标之后第二步是让指标可视化。你可以在每次任务提交或者任务完成时用ThreadPoolExecutor的钩子方法记录耗时。ThreadPoolExecutor提供了beforeExecute(Thread t, Runnable r)、afterExecute(Runnable r, Throwable t)和terminated()三个钩子方法子类重写这些方法就能在任务执行前后拿到执行时间、线程信息、异常信息。利用afterExecute的Throwable参数可以统一捕获任务异常不需要每个任务自己写try-catch这比依赖UncaughtExceptionHandler更可靠因为afterExecute能捕获Runnable运行中抛出的所有异常包括FutureTask包装后的异常。第三招是jstack。线上线程池卡住的时候jstack是排查线程池问题的最佳工具。你可以通过jstack拿到线程dump然后按照线程池的命名前缀比如“biz-thread-1”去筛选线程状态。如果大量工作线程处于WAITING状态说明它们都在等待队列任务线程池可能空闲如果大量工作线程处于RUNNABLE状态但任务完成率很低可能是有任务在死循环或者锁等待如果线程处于BLOCKED状态说明在等锁需要考虑死锁或锁竞争问题。还有一个排查思路不得不提定位到线程名是哪个线程池是排查的第一步也是最重要的一步。所以自定义ThreadFactory给线程起一个有意义的名字不是在装门面而是在线上故障时节省你几个小时的生命。我见过太多线上线程池所有线程都叫“pool-1-thread-1”查问题的时候根本不知道是哪个业务线程池只能靠堆内存里的对象引用链去猜。自定义ThreadFactory这一行代码是所有线程池生产实践里最简单、最便宜、回报最高的动作。最后说一个很多人不知道的隐藏参数ThreadPoolExecutor还有一个RejectedExecutionHandler与队列深度绑定的设计思路。当线程池拒绝任务时你可以自定义一个Reporter把被拒绝的任务信息、线程池状态、队列深度全部快照到日志而不是简单地抛出异常。这样即便你最终选择了CallerRunsPolicy或者AbortPolicy至少你能知道什么时候开始拒绝、拒绝了多少。建立一个“拒绝任务监控大盘”比事后翻日志高效得多。我在实际项目里用过这个方案效果很好——线程池什么时候开始饱和、哪些任务被拒绝、堆积量多大一目了然。线程池这个东西八股文能背出来的是骨架真正拉开差距的是你对参数联动、异常链路、状态流转、监控调优的理解深度。把这些内容串成一个完整的认知体系面试时不管面试官从哪个角度切入你都能把话题引向自己熟悉的方向。这就是“背八股”和“真正懂线程池”之间的区别。