大家好,这篇文章次要跟大家聊下 Java 线程池面试中可能会问到的一些问题。
全程干货,急躁看完,你能轻松应答各种线程池面试。
置信各位 Javaer 在面试中或多或少必定被问到过线程池相干问题吧,线程池是一个绝对比较复杂的体系,基于此能够问出各种各样、形形色色的问题。
若你很相熟线程池,如果能够,齐全能够滔滔不绝跟面试官扯一个小时线程池,个别面试也就一个小时左右,那么这样留给面试官问其余问题的工夫就很少了,或者其余问题可能问的也就不深刻了,那你通过面试的几率是不就更大点了呢。
上面咱们开始列下线程池面试可能会被问到的问题以及该怎么答复,以下只是参考答案,你也能够退出本人的了解。
1. 面试官:日常工作中有用到线程池吗?什么是线程池?为什么要应用线程池?
个别面试官考查你线程池相干常识前,大概率会先问这个问题,如果你说没用过,不理解,ok,那就没以下问题啥事了,预计你的面试后果必定也凶多吉少了。
作为 JUC 包下的门面担当,线程池是货真价实的 JUC 一哥,不理解线程池,那阐明你对 JUC 包其余工具也理解的不咋样吧,对 JUC 没深入研究过,那就是没把握到 Java 的精华,给面试官这样一个印象,那后果可想而知了。
所以说,这一分肯定要吃下,那咱们应该怎么答复好这问题呢?
能够这样说:
计算机倒退到当初,摩尔定律在现有工艺水平下曾经遇到难易冲破的物理瓶颈,通过多核 CPU 并行计算来晋升服务器的性能曾经成为支流,随之呈现了多线程技术。
线程作为操作系统贵重的资源,对它的应用须要进行管制治理,线程池就是采纳池化思维(相似连接池、常量池、对象池等)治理线程的工具。
JUC 给咱们提供了 ThreadPoolExecutor 体系类来帮忙咱们更不便的治理线程、并行执行工作。
下图是 Java 线程池继承体系:
应用线程池能够带来以下益处:
- 升高资源耗费。升高频繁创立、销毁线程带来的额定开销,复用已创立线程
- 升高应用复杂度。将工作的提交和执行进行解耦,咱们只须要创立一个线程池,而后往里面提交工作就行,具体执行流程由线程池本人治理,升高应用复杂度
- 进步线程可管理性。能平安无效的治理线程资源,防止不加限度有限申请造成资源耗尽危险
- 进步响应速度。工作达到后,间接复用已创立好的线程执行
线程池的应用场景简略来说能够有:
- 疾速响应用户申请,响应速度优先。比方一个用户申请,须要通过 RPC 调用好几个服务去获取数据而后聚合返回,此场景就能够用线程池并行调用,响应工夫取决于响应最慢的那个 RPC 接口的耗时;又或者一个注册申请,注册完之后要发送短信、邮件告诉,为了疾速返回给用户,能够将该告诉操作丢到线程池里异步去执行,而后间接返回客户端胜利,进步用户体验。
- 单位工夫解决更多申请,吞吐量优先。比方承受 MQ 音讯,而后去调用第三方接口查问数据,此场景并不谋求疾速响应,次要利用无限的资源在单位工夫内尽可能多的解决工作,能够利用队列进行工作的缓冲
2. 面试官:ThreadPoolExecutor 都有哪些外围参数?
其实个别面试官问你这个问题并不是简略听你说那几个参数,而是想要你形容下线程池执行流程。
青铜答复:
蕴含外围线程数(corePoolSize)、最大线程数(maximumPoolSize),闲暇线程超时工夫(keepAliveTime)、工夫单位(unit)、阻塞队列(workQueue)、回绝策略(handler)、线程工厂(ThreadFactory)这7个参数。
钻石答复:
答复完蕴含这几个参数之后,会再被动形容下线程池的执行流程,也就是 execute() 办法执行流程。
execute()办法执行逻辑如下:
public void execute(Runnable command) { if (command == null) throw new NullPointerException(); int c = ctl.get(); if (workerCountOf(c) < corePoolSize) { if (addWorker(command, true)) return; c = ctl.get(); } if (isRunning(c) && workQueue.offer(command)) { int recheck = ctl.get(); if (! isRunning(recheck) && remove(command)) reject(command); else if (workerCountOf(recheck) == 0) addWorker(null, false); } else if (!addWorker(command, false)) reject(command);}
能够总结出如下次要执行流程,当然看上述代码会有一些异样分支判断,能够本人顺理加到下述执行主流程里
- 判断线程池的状态,如果不是RUNNING状态,间接执行回绝策略
- 如果以后线程数 < 外围线程池,则新建一个线程来解决提交的工作
- 如果以后线程数 > 外围线程数且工作队列没满,则将工作放入阻塞队列期待执行
- 如果 外围线程池 < 以后线程池数 < 最大线程数,且工作队列已满,则创立新的线程执行提交的工作
- 如果以后线程数 > 最大线程数,且队列已满,则执行回绝策略回绝该工作
王者答复:
在答复完蕴含哪些参数及 execute 办法的执行流程后。而后能够说下这个执行流程是 JUC 规范线程池提供的执行流程,次要用在 CPU 密集型场景下。
像 Tomcat、Dubbo 这类框架,他们外部的线程池次要用来解决网络 IO 工作的,所以他们都对 JUC 线程池的执行流程进行了调整来反对 IO 密集型场景应用。
他们提供了阻塞队列 TaskQueue,该队列继承 LinkedBlockingQueue,重写了 offer() 办法来实现执行流程的调整。
@Override public boolean offer(Runnable o) { //we can't do any checks if (parent==null) return super.offer(o); //we are maxed out on threads, simply queue the object if (parent.getPoolSize() == parent.getMaximumPoolSize()) return super.offer(o); //we have idle threads, just add it to the queue if (parent.getSubmittedCount()<=(parent.getPoolSize())) return super.offer(o); //if we have less threads than maximum force creation of a new thread if (parent.getPoolSize()<parent.getMaximumPoolSize()) return false; //if we reached here, we need to add it to the queue return super.offer(o); }
能够看到他在入队之前做了几个判断,这里的 parent 就是所属的线程池对象
1.如果 parent 为 null,间接调用父类 offer 办法入队
2.如果以后线程数等于最大线程数,则间接调用父类 offer()办法入队
3.如果以后未执行的工作数量小于等于以后线程数,认真思考下,是不是阐明有闲暇的线程呢,那么间接调用父类 offer() 入队后就马上有线程去执行它
4.如果以后线程数小于最大线程数量,则间接返回 false,而后回到 JUC 线程池的执行流程回忆下,是不是就去增加新线程去执行工作了呢
5.其余状况都间接入队
具体能够看之前写过的这篇文章
动静线程池(DynamicTp),动静调整Tomcat、Jetty、Undertow线程池参数篇
能够看出当以后线程数大于外围线程数时,JUC 原生线程池首先是把工作放到队列里期待执行,而不是先创立线程执行。
如果 Tomcat 接管的申请数量大于外围线程数,申请就会被放到队列中,期待外围线程解决,这样会升高申请的总体响应速度。
所以 Tomcat并没有应用 JUC 原生线程池,利用 TaskQueue 的 offer() 办法奇妙的批改了 JUC 线程池的执行流程,改写后 Tomcat 线程池执行流程如下:
- 判断如果以后线程数小于外围线程池,则新建一个线程来解决提交的工作
- 如果以后以后线程池数大于外围线程池,小于最大线程数,则创立新的线程执行提交的工作
- 如果以后线程数等于最大线程数,则将工作放入工作队列期待执行
- 如果队列已满,则执行回绝策略
而后还能够再说下线程池的 Worker 线程模型,继承 AQS 实现了锁机制。线程启动后执行 runWorker() 办法,runWorker() 办法中调用 getTask() 办法从阻塞队列中获取工作,获取到工作后先执行 beforeExecute() 钩子函数,再执行工作,而后再执行 afterExecute() 钩子函数。若超时获取不到工作会调用 processWorkerExit() 办法执行 Worker 线程的清理工作。
具体源码解读能够看之前写的文章:
线程池源码解析
3. 面试官:什么是阻塞队列?说说罕用的阻塞队列有哪些?
阻塞队列 BlockingQueue 继承 Queue,是咱们相熟的根本数据结构队列的一种非凡类型。
当从阻塞队列中获取数据时,如果队列为空,则期待直到队列有元素存入。当向阻塞队列中存入元素时,如果队列已满,则期待直到队列中有元素被移除。提供 offer()、put()、take()、poll() 等罕用办法。
JDK 提供的阻塞队列的实现有以下几种:
1)ArrayBlockingQueue:由数组实现的有界阻塞队列,该队列依照 FIFO 对元素进行排序。保护两个整形变量,标识队列头尾在数组中的地位,在生产者放入和消费者获取数据共用一个锁对象,意味着两者无奈真正的并行运行,性能较低。
2)LinkedBlockingQueue:由链表组成的有界阻塞队列,如果不指定大小,默认应用 Integer.MAX_VALUE 作为队列大小,该队列依照 FIFO 对元素进行排序,对生产者和消费者别离保护了独立的锁来控制数据同步,意味着该队列有着更高的并发性能。
3)SynchronousQueue:不存储元素的阻塞队列,无容量,能够设置偏心或非偏心模式,插入操作必须期待获取操作移除元素,反之亦然。
4)PriorityBlockingQueue:反对优先级排序的无界阻塞队列,默认状况下依据天然序排序,也能够指定 Comparator。
5)DelayQueue:反对延时获取元素的无界阻塞队列,创立元素时能够指定多久之后能力从队列中获取元素,罕用于缓存零碎或定时任务调度零碎。
6)LinkedTransferQueue:一个由链表构造组成的无界阻塞队列,与LinkedBlockingQueue相比多了transfer和tryTranfer办法,该办法在有消费者期待接管元素时会立刻将元素传递给消费者。
7)LinkedBlockingDeque:一个由链表构造组成的双端阻塞队列,能够从队列的两端插入和删除元素。
4. 面试官:你刚说到了 Worker 继承 AQS 实现了锁机制,那 ThreadPoolExecutor 都用到了哪些锁?为什么要用锁?
1)mainLock 锁
ThreadPoolExecutor 外部保护了 ReentrantLock 类型锁 mainLock,在拜访 workers 成员变量以及进行相干数据统计记账(比方拜访 largestPoolSize、completedTaskCount)时须要获取该重入锁。
面试官:为什么要有 mainLock?
private final ReentrantLock mainLock = new ReentrantLock(); /** * Set containing all worker threads in pool. Accessed only when * holding mainLock. */ private final HashSet<Worker> workers = new HashSet<Worker>(); /** * Tracks largest attained pool size. Accessed only under * mainLock. */ private int largestPoolSize; /** * Counter for completed tasks. Updated only on termination of * worker threads. Accessed only under mainLock. */ private long completedTaskCount;
能够看到 workers 变量用的 HashSet 是线程不平安的,是不能用于多线程环境的。largestPoolSize、completedTaskCount 也是没用 volatile 润饰,所以须要在锁的爱护下进行拜访。
面试官:为什么不间接用个线程平安容器呢?
其实 Doug 老爷子在 mainLock 变量的正文上解释了,意思就是说事实证明,相比于线程平安容器,此处更适宜用 lock,次要起因之一就是串行化 interruptIdleWorkers() 办法,防止了不必要的中断风暴
面试官:怎么了解这个中断风暴呢?
其实简略了解就是如果不加锁,interruptIdleWorkers() 办法在多线程拜访下就会产生这种状况。一个线程调用interruptIdleWorkers() 办法对 Worker 进行中断,此时该 Worker 出于中断中状态,此时又来一个线程去中断正在中断中的 Worker 线程,这就是所谓的中断风暴。
面试官:那 largestPoolSize、completedTaskCount 变量加个 volatile 关键字润饰是不是就能够不必 mainLock 了?
这个其实 Doug 老爷子也思考到了,其余一些外部变量能用 volatile 的都加了 volatile 润饰了,这两个没加次要就是为了保障这两个参数的准确性,在获取这两个值时,能保障获取到的肯定是批改办法执行实现后的值。如果不加锁,可能在批改办法还没执行实现时,此时来获取该值,获取到的就是批改前的值。
2)Worker 线程锁
刚也说了 Worker 线程继承 AQS,实现了 Runnable 接口,外部持有一个 Thread 变量,一个 firstTask,及 completedTasks 三个成员变量。
基于 AQS 的 acquire()、tryAcquire() 实现了 lock()、tryLock() 办法,类上也有正文,该锁次要是用来保护运行中线程的中断状态。在 runWorker() 办法中以及刚说的 interruptIdleWorkers() 办法中用到了。
面试官:这个保护运行中线程的中断状态怎么了解呢?
protected boolean tryAcquire(int unused) { if (compareAndSetState(0, 1)) { setExclusiveOwnerThread(Thread.currentThread()); return true; } return false; } public void lock() { acquire(1); } public boolean tryLock() { return tryAcquire(1); }
在runWorker() 办法中获取到工作开始执行前,须要先调用 w.lock() 办法,lock() 办法会调用 tryAcquire() 办法,tryAcquire() 实现了一把非重入锁,通过 CAS 实现加锁。
interruptIdleWorkers() 办法会中断那些期待获取工作的线程,会调用 w.tryLock() 办法来加锁,如果一个线程曾经在执行工作中,那么 tryLock() 就获取锁失败,就保障了不能中断运行中的线程了。
所以 Worker 继承 AQS 次要就是为了实现了一把非重入锁,保护线程的中断状态,保障不能中断运行中的线程。
5. 面试官:你在我的项目中是怎么应用线程池的?Executors 理解吗?
这里面试官次要想晓得你日常工作中应用线程池的姿态,当初大多数公司都在遵循阿里巴巴 Java 开发标准,该标准里明确阐明不容许应用
Executors 创立线程池,而是通过 ThreadPoolExecutor 显示指定参数去创立
你能够这样说,晓得 Executors 工具类,很久之前有用过,也踩过坑,Executors 创立的线程池有产生 OOM 的危险。
Executors.newFixedThreadPool 和 Executors.SingleThreadPool 创立的线程池外部应用的是无界(Integer.MAX_VALUE)的 LinkedBlockingQueue 队列,可能会沉积大量申请,导致 OOM
Executors.newCachedThreadPool 和Executors.scheduledThreadPool 创立的线程池最大线程数是用的Integer.MAX_VALUE,可能会创立大量线程,导致 OOM
本人在日常工作中也有封装相似的工具类,然而都是内存平安的,参数须要本人指定适当的值,也有基于 LinkedBlockingQueue 实现了内存平安阻塞队列 MemorySafeLinkedBlockingQueue,当零碎内存达到设置的残余阈值时,就不在往队列里增加工作了,防止产生 OOM
咱们个别都是在 Spring 环境中应用线程池的,间接应用 JUC 原生 ThreadPoolExecutor 有个问题,Spring 容器敞开的时候可能工作队列里的工作还没解决完,有失落工作的危险。
咱们晓得 Spring 中的 Bean 是有生命周期的,如果 Bean 实现了 Spring 相应的生命周期接口(InitializingBean、DisposableBean接口),在 Bean 初始化、容器敞开的时候会调用相应的办法来做相应解决。
所以最好不要间接应用 ThreadPoolExecutor 在 Spring 环境中,能够应用 Spring 提供的 ThreadPoolTaskExecutor,或者 DynamicTp 框架提供的 DtpExecutor 线程池实现。
也会按业务类型进行线程池隔离,各工作执行互不影响,防止共享一个线程池,工作执行参差不齐,相互影响,高耗时工作会占满线程池资源,导致低耗时工作没机会执行;同时如果工作之间存在父子关系,可能会导致死锁的产生,进而引发 OOM。
更多应用姿态参考之前发的文章:
线程池,我是谁?我在哪儿?
6. 面试官:刚你说到了通过 ThreadPoolExecutor 来创立线程池,那外围参数设置多少适合呢?
这个问题该怎么答复呢?
可能很多人都看到过《Java 并发编程事件》这本书里介绍的一个线程数计算公式:
Ncpu = CPU 核数
Ucpu = 指标 CPU 利用率,0 <= Ucpu <= 1
W / C = 等待时间 / 计算工夫的比例
要程序跑到 CPU 的指标利用率,须要的线程数为:
Nthreads = Ncpu Ucpu (1 + W / C)
这公式太偏理论化了,很难理论落地下来,首先很难获取精确的等待时间和计算工夫。再着一个服务中会运行着很多线程,比方 Tomcat 有本人的线程池、Dubbo 有本人的线程池、GC 也有本人的后盾线程,咱们引入的各种框架、中间件都有可能有本人的工作线程,这些线程都会占用 CPU 资源,所以通过此公式计算出来的误差肯定很大。
所以说怎么确定线程池大小呢?
其实没有固定答案,须要通过压测一直的动静调整线程池参数,察看 CPU 利用率、零碎负载、GC、内存、RT、吞吐量 等各种综合指标数据,来找到一个绝对比拟正当的值。
所以不要再问设置多少线程适合了,这个问题没有标准答案,须要联合业务场景,设置一系列数据指标,排除可能的烦扰因素,留神链路依赖(比方连接池限度、三方接口限流),而后通过一直动静调整线程数,测试找到一个绝对适合的值。
7. 面试官:你们线程池是咋监控的?
因为线程池的运行相对而言是个黑盒,它的运行咱们感知不到,该问题次要考查怎么感知线程池的运行状况。
能够这样答复:
咱们本人对线程池 ThreadPoolExecutor 做了一些加强,做了一个线程池治理框架。次要性能有监控告警、动静调参。次要利用了 ThreadPoolExecutor 类提供的一些 set、get办法以及一些钩子函数。
动静调参是基于配置核心实现的,外围参数配置在配置核心,能够随时调整、实时失效,利用了线程池提供的 set 办法。
监控,次要就是利用线程池提供的一些 get 办法来获取一些指标数据,而后采集数据上报到监控零碎进行大盘展现。也提供了 Endpoint 实时查看线程池指标数据。
同时定义了5中告警规定。
- 线程池活跃度告警。活跃度 = activeCount / maximumPoolSize,当活跃度达到配置的阈值时,会进行事先告警。
- 队列容量告警。容量使用率 = queueSize / queueCapacity,当队列容量达到配置的阈值时,会进行事先告警。
- 回绝策略告警。当触发回绝策略时,会进行告警。
- 工作执行超时告警。重写 ThreadPoolExecutor 的 afterExecute() 和 beforeExecute(),依据以后工夫和开始工夫的差值算出工作执行时长,超过配置的阈值会触发告警。
- 工作排队超时告警。重写 ThreadPoolExecutor 的 beforeExecute(),记录提交工作时工夫,依据以后工夫和提交工夫的差值算出工作排队时长,超过配置的阈值会触发告警
通过监控+告警能够让咱们及时感知到咱们业务线程池的执行负载状况,第一工夫做出调整,避免事变的产生。
8. 面试官:你在应用线程池的过程中遇到过哪些坑或者须要留神的中央?
这个问题其实也是在考查你对一些细节的把握水平,就全甩锅给年老刚毕业没教训的本人就行。能够适当多说些,也证实本人对线程池有着丰盛的应用教训。
1)OOM 问题。刚开始应用线程都是通过 Executors 创立的,后面说了,这种形式创立的线程池会有产生 OOM 的危险。
2)工作执行异样失落问题。能够通过下述4种形式解决
- 在工作代码中减少 try、catch 异样解决
- 如果应用的 Future 形式,则可通过 Future 对象的 get 办法接管抛出的异样
- 为工作线程设置 setUncaughtExceptionHandler,在 uncaughtException 办法中解决异样
- 能够重写 afterExecute(Runnable r, Throwable t) 办法,拿到异样 t
3)共享线程池问题。整个服务共享一个全局线程池,导致工作相互影响,耗时长的工作占满资源,短耗时工作得不到执行。同时父子线程间会导致死锁的产生,今儿导致 OOM
4)跟 ThreadLocal 配合应用,导致脏数据问题。咱们晓得 Tomcat 利用线程池来解决收到的申请,会复用线程,如果咱们代码中用到了 ThreadLocal,在申请解决完后没有去 remove,那每个申请就有可能获取到之前申请遗留的脏值。
5)ThreadLocal 在线程池场景下会生效,能够思考用阿里开源的 Ttl 来解决
以上提到的线程池动静调参、告诉告警在开源动静线程池我的项目 DynamicTp 中曾经实现了,能够间接引入到本人我的项目中应用。
对于 DynamicTp
DynamicTp 是一个基于配置核心实现的轻量级动静线程池管理工具,次要性能能够总结为动静调参、告诉报警、运行监控、三方包线程池治理等几大类。
通过多个版本迭代,目前最新版本 v1.0.8 具备以下个性
个性 ✅
- 代码零侵入:所有配置都放在配置核心,对业务代码零侵入
- 轻量简略:基于 springboot 实现,引入 starter,接入只需简略4步就可实现,顺利3分钟搞定
- 高可扩大:框架外围性能都提供 SPI 接口供用户自定义个性化实现(配置核心、配置文件解析、告诉告警、监控数据采集、工作包装等等)
- 线上大规模利用:参考美团线程池实际,美团外部曾经有该实践成熟的利用教训
- 多平台告诉报警:提供多种报警维度(配置变更告诉、活性报警、容量阈值报警、回绝触发报警、工作执行或期待超时报警),已反对企业微信、钉钉、飞书报警,同时提供 SPI 接口可自定义扩大实现
- 监控:定时采集线程池指标数据,反对通过 MicroMeter、JsonLog 日志输入、Endpoint 三种形式,可通过 SPI 接口自定义扩大实现
- 工作加强:提供工作包装性能,实现TaskWrapper接口即可,如 MdcTaskWrapper、TtlTaskWrapper、SwTraceTaskWrapper,能够反对线程池上下文信息传递
- 兼容性:JUC 一般线程池和 Spring 中的 ThreadPoolTaskExecutor 也能够被框架监控,@Bean 定义时加 @DynamicTp 注解即可
- 可靠性:框架提供的线程池实现 Spring 生命周期办法,能够在 Spring 容器敞开前尽可能多的解决队列中的工作
- 多模式:参考Tomcat线程池提供了 IO 密集型场景应用的 EagerDtpExecutor 线程池
- 反对多配置核心:基于支流配置核心实现线程池参数动静调整,实时失效,已反对 Nacos、Apollo、Zookeeper、Consul、Etcd,同时也提供 SPI 接口可自定义扩大实现
- 中间件线程池治理:集成治理罕用第三方组件的线程池,已集成Tomcat、Jetty、Undertow、Dubbo、RocketMq、Hystrix等组件的线程池治理(调参、监控报警)