尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

线程池面试八股全解析:七参数、阻塞队列与拒绝策略

线程池面试八股全解析:七参数、阻塞队列与拒绝策略 最近帮一个学弟做模拟面试我让他先讲讲线程池的七个参数他背到第四个就卡住了。其实不怪他线程池这块的八股文确实又多又杂网上随便一搜就是几十篇文章但大部分都是抄来抄去没有一个能让人真正打通任督二脉。我自己面试别人也快五年了发现面试官问线程池翻来覆去其实就那么几个点七个参数、执行流程、阻塞队列怎么选、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核心线程数即使空闲也默认存活除非设置了allowCoreThreadTimeOutmaximumPoolSize最大线程数线程池允许创建的线程上限包含核心非核心keepAliveTime非核心线程空闲存活时间超过这个时间没有任务非核心线程被回收unitkeepAliveTime的时间单位秒、毫秒、微秒等workQueue任务队列核心线程忙时任务先塞给队列threadFactory线程工厂用来创建新线程可以自定义线程名、优先级handler拒绝策略线程数和队列都满时怎么处理新任务这里有几个八股文很少讲但面试很容易追问的细节。第一核心线程数是懒加载的线程池刚创建时并不会立刻创建核心线程而是等第一个任务提交时才创建除非手动调用prestartAllCoreThreads()预热这个细节在需要控制启动耗时或者提前验证线程池配置的时候很实用。第二keepAliveTime默认只对非核心线程生效但你可以通过allowCoreThreadTimeOut(true)让核心线程空闲超时后也被回收这在核心线程数配多了、想动态降资源的时候有用。第三threadFactory强烈建议自定义至少要把线程名设置成有业务含义的比如order-async-thread-1否则线上排查问题时一看到pool-3-thread-1这种默认名字你根本不知道是哪个业务在跑定位问题的成本会高很多。2.2 提交一个任务线程池内部到底干了什么这是面试问的概率最高的题也是很多人都背反了的一道题。先记住结论优先用核心线程核心不够就进队列队列满了才创建非核心线程线程数到上限就触发拒绝策略。很多人的错误认知是核心线程满了先创建新线程新线程也满了再进队列这是完全错误的。我用一个具体例子把流程走一遍。假设线程池配置为corePoolSize2、maximumPoolSize5、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任务全部清空线程数归零不能不能TERMINATEDterminated()执行完毕不能不能状态流转的触发点shutdown()从 RUNNING 到 SHUTDOWNshutdownNow()从 RUNNING 到 STOPSHUTDOWN/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或CallableT返回FutureT可以用来获取执行结果。第二个层次异常处理这才是这个题的精髓。用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就自定义RejectedExecutionHandlerpublic 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 线程池SSEServer-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只要触发不关心结果用executesubmit必须get否则异常会丢怎么给线程池的线程命名自定义threadFactory设置带业务含义的线程名线程池会提前创建线程吗默认不会懒加载prestartAllCoreThreads() 可以预热所有核心线程怎么监控线程池状态getPoolSize、getActiveCount、getQueue().size()、getCompletedTaskCount 等指标接入监控系统我个人在实际排查里还发现一个实用的技巧面试八股和真实调优最大的差距在于代码里的参数会告诉你怎么配但不会告诉你为什么这么配。如果你时间有限与其背十篇八股不如写个demo把corePoolSize2, maxPoolSize5, queue3的线程池跑起来提交十几个任务打印每个任务的执行线程名和线程池的activeCount、queueSize变化。这个demo跑一遍你就能亲眼看到任务是怎么从核心线程走到队列、从队列走到非核心线程、最后被拒绝的。比死记硬背执行流程有效十倍。面试官问到你线程池熟悉吗你把这些链路串起来讲一遍再补一句这个组合的线上表现我实际验证过基本就过关了。
返回列表