你好,我是yes。java
这两面试题是基友朋友最近去面滴滴遇到的,今天就借着这两面试真题来深刻一波线程池吧,这篇文章力求把线程池核心点和常问的面试点一网打尽,固然我的能力有限,可能会有遗漏,欢迎留言补充!面试
先把问题列出来,若是你都答得出来,那不必看下去:算法
-
为何会有线程池?缓存
-
简单手写一个线程池?安全
-
为何要把任务先放在任务队列里面,而不是把线程先拉满到最大线程数?微信
-
线程池如何动态修改核心线程数和最大线程数?多线程
-
若是你是 JDK 设计者,如何设计?并发
-
若是要让你设计一个线程池,你要怎么设计?ide
-
你是如何理解核心线程的?函数
-
你是怎么理解 KeepAliveTime 的?
-
那 workQueue 有什么用?
-
你是如何理解拒绝策略的?
-
你说你看过源码,那你确定知道线程池里的 ctl 是干吗的咯?
-
你知道线程池有几种状态吗?
-
你知道线程池的状态是如何变迁的吗?
-
如何修改原生线程池,使得能够先拉满线程数再入任务队列排队?
-
Tomcat 中的定制化线程池实现 若是线程池中的线程在执行任务的时候,抛异常了,会怎么样?
-
原生线程池的核心线程必定伴随着任务慢慢建立的吗?
-
线程池的核心线程在空闲的时候必定不会被回收吗?
接得住吗?话很少说,发车!
为何会有线程池?
想要深刻理解线程池的原理得先知道为何须要线程池。
首先你要明白,线程是一个重资源,JVM 中的线程与操做系统的线程是一对一的关系,因此在 JVM 中每建立一个线程就须要调用操做系统提供的 API 建立线程,赋予资源,而且销毁线程一样也须要系统调用。
而系统调用就意味着上下文切换等开销,而且线程也是须要占用内存的,而内存也是珍贵的资源。
所以线程的建立和销毁是一个重操做,而且线程自己也占用资源。
而后你还须要知道,线程数并非越多越好。
咱们都知道线程是 CPU 调度的最小单位,在单核时代,若是是纯运算的操做是不须要多线程的,一个线程一直执行运算便可。但若是这个线程正在等待 I/O 操做,此时 CPU 就处于空闲状态,这就浪费了 CPU 的算力,所以有了多线程,在某线程等待 I/O 等操做的时候,另外一个线程顶上,充分利用 CPU,提升处理效率。
此时的多线程主要是为了提升 CPU 的利用率而提出。
而随着 CPU 的发展,核心数愈来愈多,能同时运行的线程数也提高了,此时的多线程不只是为了提升单核 CPU 的利用率,也是为了充分利用多个核心。
至此想必应该明白了为何会有多线程,无非就是为了充分利用 CPU 空闲的时间,一刻也不想让他停下来。
但 CPU 的核心数有限,同时能运行的线程数有限,因此须要根据调度算法切换执行的线程,而线程的切换须要开销,好比替换寄存器的内容、高速缓存的失效等等。
若是线程数太多,切换的频率就变高,可能使得多线程带来的好处抵不过线程切换带来的开销,得不偿失。
所以线程的数量须要得以控制,结合上述的描述可知,线程的数量与 CPU 核心数和 I/O 等待时长息息相关。
小结一下:
-
Java中线程与操做系统线程是一比一的关系。
-
线程的建立和销毁是一个“较重”的操做。
-
多线程的主要是为了提升 CPU 的利用率。
-
线程的切换有开销,线程数的多少须要结合 CPU核心数与 I/O 等待占比。
综上咱们知道了线程的这些特性,因此说它不是一个能够“随意拿捏”的东西,咱们须要重视它,好好规划和管理它,充分利用硬件的能力,从而提高程序执行效率,因此线程池应运而生。
什么是线程池?
那咱们要如何管理好线程呢?
由于线程数太少没法充分利用 CPU ,太多的话因为上下文切换的消耗又得不偿失,因此咱们须要评估系统所要承载的并发量和所执行任务的特性,得出大体须要多少个线程数才能充分利用 CPU,所以须要控制线程数量。
又由于线程的建立和销毁是一个“重”操做,因此咱们须要避免线程频繁地建立与销毁,所以咱们须要缓存一批线程,让它们时刻准备着执行任务。
目标已经很清晰了,弄一个池子,里面存放约定数量的线程,这就是线程池,一种池化技术。
熟悉对象池、链接池的朋友确定对池化技术不陌生,通常池化技术的使用方式是从池子里拿出资源,而后使用,用完了以后归还。
可是线程池的实现不太同样,不是说咱们从线程池里面拿一个线程来执行任务,等任务执行完了以后再归还线程,你能够想一下这样作是否合理。
线程池的常见实现更像是一个黑盒存在,咱们设置好线程池的大小以后,直接往线程池里面丢任务,而后就无论了。
剥开来看,线程池实际上是一个典型的生产者-消费者模式。
线程池内部会有一个队列来存储咱们提交的任务,而内部线程不断地从队列中索取任务来执行,这就是线程池最原始的执行机制。
按照这个思路,咱们能够很容易的实现一个简单版线程池,想必看了下面这个代码实现,对线程池的核心原理就会了然于心。
首先线程池内须要定义两个成员变量,分别是阻塞队列和线程列表,而后自定义线程使它的任务就是不断的从阻塞队列中拿任务而后执行。
`@Slf4j
public class YesThreadPool {
BlockingQueue<Runnable> taskQueue; //存听任务的阻塞队列
List<YesThread> threads; //线程列表
YesThreadPool(BlockingQueue<Runnable> taskQueue, int threadSize) {
this.taskQueue = taskQueue;
threads = new ArrayList<>(threadSize);
// 初始化线程,并定义名称
IntStream.rangeClosed(1, threadSize).forEach((i)-> {
YesThread thread = new YesThread("yes-task-thread-" + i);
thread.start();
threads.add(thread);
});
}
//提交任务只是往任务队列里面塞任务
public void execute(Runnable task) throws InterruptedException {
taskQueue.put(task);
}
class YesThread extends Thread { //自定义一个线程
public YesThread(String name) {
super(name);
}
@Override
public void run() {
while (true) { //死循环
Runnable task = null;
try {
task = taskQueue.take(); //不断从任务队列获取任务
} catch (InterruptedException e) {
logger.error("记录点东西.....", e);
}
task.run(); //执行
}
}
}
}
`
一个简单版线程池就完成了,简单吧!
再写个 main 方法用一用,丝滑,很是丝滑。
public static void main(String[] args) { YesThreadPool pool = new YesThreadPool(new LinkedBlockingQueue<>(10), 3); IntStream.rangeClosed(1, 5).forEach((i)-> { try { pool.execute(()-> { System.out.println(Thread.currentThread().getName() + " 公众号:yes的练级攻略"); }); } catch (InterruptedException e) { logger.error("记录点东西.....", e); } }); }
运行结果以下:
下次面试官让你手写线程池,直接上这个简单版,而后他会开始让你优化,好比什么线程一开始都 start 了很差,想懒加载,而后xxxx...最终其实就是想往李老爷实现的 ThreadPoolExecutor 上面靠。
那就来嘛。
ThreadPoolExecutor 剖析
这玩意就是常被问的线程池的实现类了,先来看下构造函数:
核心原理其实和我们上面实现的差很少,只是生产级别的那确定是要考虑的更多,接下来咱们就来看看此线程池的工做原理。
先来一张图:
简单来讲线程池把任务的提交和任务的执行剥离开来,当一个任务被提交到线程池以后:
-
若是此时线程数 小于核心线程数,那么就会新起一个线程来执行当前的任务。
-
若是此时线程数 大于核心线程数,那么就会将任务塞入阻塞队列中,等待被执行。
-
若是阻塞队列满了,而且此时线程数 小于最大线程数,那么会建立新线程来执行当前任务。
-
若是阻塞队列满了,而且此时线程数 大于最大线程数,那么会采起拒绝策略。
以上就是任务提交给线程池后各类情况汇总,一个很容易出现理解错误的地方就是当线程数达到核心数的时候,任务是先入队,而不是先建立最大线程数。
从上述可知,线程池里的线程不是一开始就直接拉满的,是根据任务量开始慢慢增多的,这就算一种懒加载,到用的时候再建立线程,节省资源。
来先吃我几问。
此时线程数小于核心线程数,而且线程都处于空闲状态,现提交一个任务,是新起一个线程仍是给以前建立的线程?
李总是这样说的:If fewer than corePoolSize threads are running, try to start a new thread with the given command as its first task.
我以为把 threads are running
去了,更合理一些,此时线程池会新起一个线程来执行这个新任务,无论老线程是否空闲。
你是如何理解核心线程的 ?
从上一个问题能够看出,线程池虽然说默认是懒建立线程,可是它实际是想要快速拥有核心线程数的线程。核心线程指的是线程池承载平常任务的中坚力量,也就是说本质上线程池是须要这么些数量的线程来处理任务的,因此在懒中又急着建立它。
而最大线程数实际上是为了应付突发情况。
举个装修的例子,正常状况下施工队只要 5 我的去干活,这 5 人其实就是核心线程,可是因为工头接的活太多了,致使 5 我的在约定工期内干不完,因此工头又去找了 2 我的来一块儿干,因此 5 是核心线程数,7 是最大线程数。
平时就是 5 我的干活,特别忙的时候就找 7 个,等闲下来就会把多余的 2 个辞了。
看到这里你可能会以为核心线程在线程池里面会有特殊标记?
并无,不管是核心仍是非核心线程,在线程池里面都是一视同仁,当淘汰的时候不会管是哪些线程,反正留下核心线程数个线程便可,下文会做详解。
你是怎么理解 KeepAliveTime 的?
这就是上面提到的,线程池其实想要的只是核心线程数个线程,可是又预留了一些数量来预防突发情况,当突发情况过去以后,线程池但愿只维持核心线程数的线程,因此就弄了个 KeepAliveTime,当线程数大于核心数以后,若是线程空闲了一段时间(KeepAliveTime),就回收线程,直到数量与核心数持平。
那 workQueue 有什么用?
缓存任务供线程获取,这里要注意限制工做队列的大小。队列长了,堆积的任务就多,堆积的任务多,后面任务等待的时长就长。
想一想你点击一个按钮是一直转圈等半天没反应舒服,仍是直接报错舒服,因此有时心是好的,想尽可能完成提交的任务,可是用户体验不如直接拒绝。更有可能因为容许囤积的任务过多,致使资源耗尽而系统崩溃。
因此工做队列起到一个缓冲做用,具体队列长度须要结合线程数,任务的执行时长,能承受的等待时间等。
你是如何理解拒绝策略的?
线程数总有拉满的一天,工做队列也是同样,若是二者都满了,此时的提交任务就须要拒绝,默认实现是 AbortPolicy 直接抛出异常。
剩下的拒绝策略有直接丢弃任务一声不吭的、让提交任务的线程本身运行的、淘汰老的未执行的任务而空出位置的,具体用哪一个策略,根据场景选择。固然也能够自定义拒绝策略,实现 RejectedExecutionHandler
这个接口便可。
因此线程池尽量只维护核心数量的线程,提供任务队列暂存任务,并提供拒绝策略来应对过载的任务。
这里还有个细节,若是线程数已经达到核心线程数,那么新增长的任务只会往任务队列里面塞,不会直接给予某个线程,若是任务队列也满了,新增最大线程数的线程时,任务是能够直接给予新建的线程执行的,而不是入队。
感受已经会了?那再来看几道面试题:
你说你看过源码,那你确定知道线程池里的 ctl 是干吗的咯?
其实看下注释就很清楚了,ctl 是一个涵盖了两个概念的原子整数类,它将工做线程数和线程池状态结合在一块儿维护,低 29 位存放 workerCount,高 3 位存放 runState。
其实并发包中有不少实现都是一个字段存多个值的,好比读写锁的高 16 位存放读锁,低 16 位存放写锁,这种一个字段存放多个值能够更容易的维护多个值之间的一致性,也算是极简主义。
你知道线程池有几种状态吗?
注解说的很明白,我再翻译一下:
-
RUNNING:能接受新任务,并处理阻塞队列中的任务
-
SHUTDOWN:不接受新任务,可是能够处理阻塞队列中的任务
-
STOP:不接受新任务,而且不处理阻塞队列中的任务,而且还打断正在运行任务的线程,就是直接撂担子不干了!
-
TIDYING:全部任务都终止,而且工做线程也为0,处于关闭以前的状态
-
TERMINATED:已关闭。
你知道线程池的状态是如何变迁的吗?
注释里面也写的很清楚,我再画个图
为何要把任务先放在任务队列里面,而不是把线程先拉满到最大线程数?
我说下个人我的理解。
其实通过上面的分析能够得知,线程池本意只是让核心数量的线程工做着,不管是 core 的取名,仍是 keepalive 的设定,因此你能够直接把 core 的数量设为你想要线程池工做的线程数,而任务队列起到一个缓冲的做用。最大线程数这个参数更像是无奈之举,在最坏的状况下作最后的努力,去新建线程去帮助消化任务。
因此我我的以为没有为何,就是这样设计的,而且这样的设定挺合理。
固然若是你想要扯一扯 CPU 密集和 I/O 密集,那能够扯一扯。
原生版线程池的实现能够认为是偏向 CPU 密集的,也就是当任务过多的时候不是先去建立更多的线程,而是先缓存任务,让核心线程去消化,从上面的分析咱们能够知道,当处理 CPU 密集型任务的时,线程太多反而会因为线程频繁切换的开销而得不偿失,因此优先堆积任务而不是建立新的线程。
而像 Tomcat 这种业务场景,大部分状况下是须要大量 I/O 处理的状况就作了一些定制,修改了原生线程池的实现,使得在队列没满的时候,能够建立线程至最大线程数。
如何修改原生线程池,使得能够先拉满线程数再入任务队列排队?
若是了解线程池的原理,很轻松的就知道关键点在哪,就是队列的 offer 方法。
execute 方法想必你们都不陌生,就是给线程池提交任务的方法。在这个方法中能够看到只要在 offer 方法内部判断此时线程数还小于最大线程数的时候返回 false,便可走下面 else if
中 addWorker
(新增线程)的逻辑,若是数量已经达到最大线程数,直接入队便可。
详细的咱们能够看看 Tomcat 中是如何定制线程的。
Tomcat 中的定制化线程池实现
public class ThreadPoolExecutor extends java.util.concurrent.ThreadPoolExecutor {}
能够看到先继承了 JUC 的线程池,而后咱们重点关注一下 execute 这个方法
这里能够看到,Tomcat 维护了一个 submittedCount 变量,这个变量的含义是统计已经提交的可是还未完成的任务数量(记住这个变量,很关键),因此只要提交一个任务,这个数就加一,而且捕获了拒绝异常,再次尝试将任务入队,这个操做实际上是为了尽量的挽救回一些任务,由于这么点时间差可能已经执行完不少任务,队列腾出了空位,这样就不须要丢弃任务。
而后咱们再来看下代码里出现的 TaskQueue,这个就是上面提到的定制关键点了。
public class TaskQueue extends LinkedBlockingQueue<Runnable> { private transient volatile ThreadPoolExecutor parent = null; ........ }
能够看到这个任务队列继承了 LinkedBlockingQueue,而且有个 ThreadPoolExecutor 类型的成员变量 parent ,咱们再来看下 offer 方法的实现,这里就是修改原来线程池任务提交与线程建立逻辑的核心了。
从上面的逻辑能够看出是有机会在队列还未满的时候,先建立线程至最大线程数的!
再补充一下,若是对直接返回 false 就能建立线程感到疑惑的话,往上翻一翻,上面贴了原生线程池 execute 的逻辑。
而后上面的代码其实只看到 submittedCount 的增长,正常的减小在 afterExecute
里实现了。
而这个 afterExecute
在任务执行完毕以后就会调用,与之对应的还有个 beforeExecute
,在任务执行以前调用。
至此,想必 Tomcat 中的定制化线程池的逻辑已经明白了。
若是线程池中的线程在执行任务的时候,抛异常了,会怎么样?
嘿嘿,细心的同窗想必已经瞄到了上面的代码,task.run() 被 try catch finally
包裹,异常被扔到了 afterExecute
中,而且也继续被抛了出来。
而这一层外面,还有个try finally
,因此异常的抛出打破了 while 循环,最终会执行 `processWorkerExit方法
咱们来看下这个方法,其实逻辑很简单,把这个线程废了,而后新建一个线程替换之。
移除了引用等于销毁了,这事儿 GC 会作的。
因此若是一个任务执行一半就抛出异常,而且你没有自行处理这个异常,那么这个任务就这样戛然而止了,后面也不会有线程继续执行剩下的逻辑,因此要自行捕获和处理业务异常。
addWorker 的逻辑就不分析了,就是新建一个线程,而后塞到 workers 里面,而后调用 start()
让它跑起来。
原生线程池的核心线程必定伴随着任务慢慢建立的吗?
并非,线程池提供了两个方法:
-
prestartCoreThread:启动一个核心线程
-
prestartAllCoreThreads :启动全部核心线程
不要小看这个预建立方法,预热很重要,否则刚重启的一些服务有时是顶不住瞬时请求的,就立马崩了,因此有预热线程、缓存等等操做。
线程池的核心线程在空闲的时候必定不会被回收吗?
有个 allowCoreThreadTimeOut 方法,把它设置为 true ,则全部线程都会超时,不会有核心数那条线的存在。
具体是会调用 interruptIdleWorkers
这个方法。
这里须要讲一下的是 w.tryLock() 这个方法,有些人可能会奇怪,Worker 怎么还能 lock。
Worker 是属于工做线程的封装类,它不只实现了 Runnable 接口,还继承了 AQS。
之因此要继承 AQS 就是为了用上 lock 的状态,执行任务的时候上锁,任务执行完了以后解锁,这样执行关闭线程池等操做的时候能够经过 tryLock 来判断此时线程是否在干活,若是 tryLock 成功说明此时线程是空闲的,能够安全的回收。
与interruptIdleWorkers
对应的还有一个 interruptWorkers
方法,从名字就能看出差异,不空闲的 worker 也直接给打断了。
根据这两个方法,又能够扯到 shutdown 和 shutdownNow,就是关闭线程池的方法,一个是安全的关闭线程池,会等待任务都执行完毕,一个是粗暴的直接咔嚓了全部线程,管你在不在运行,两个方法分别调用的就是 interruptIdleWorkers() 和 interruptWorkers() 来中断线程。
这又能够引伸出一个问题,shutdownNow 了以后还在任务队列中的任务咋办?眼尖的小伙伴应该已经看到了,线程池还算负责,把未执行的任务拖拽到了一个列表中而后返回,至于怎么处理,就交给调用者了!
详细就是上面的 drainQueue 方法。
这里可能又会有同窗有疑问,都 drainTo 了,为何还要判断一下队列是否为空,而后进行循环?
那是由于若是队列是 DelayQueue 或任何其余类型的队列,其中 poll 或 drainTo 可能没法删除某些元素,因此须要遍历,逐个删除它们。
回到最开始的面试题
线程池如何动态修改核心线程数和最大线程数?
其实之因此会有这样的需求是由于线程数是真的很差配置。
你可能会在网上或者书上看到不少配置公式,好比:
-
CPU 密集型的话,核心线程数设置为 CPU核数+1
-
I/O 密集型的话,核心线程数设置为 2*CPU核数
好比:
线程数=CPU核数 *(1+线程等待时间 / 线程时间运行时间)
这个比上面的更贴合与业务,还有一些理想的公式就不列了。就这个公式而言,这个线程等待时间就很难测,拿 Tomcat 线程池为例,每一个请求的等待时间能知道?不一样的请求不一样的业务,就算相同的业务,不一样的用户数据量也不一样,等待时间也不一样。
因此说线程数真的很难经过一个公式一劳永逸,线程数的设定是一个迭代的过程,须要压测适时调整,以上的公式作个初始值开始调试是 ok 的。
再者,流量的突发性也是没法判断的,举个例子 1 秒内一共有 1000 个请求量,可是若是这 1000 个请求量都是在第一毫秒内瞬时进来的呢?
这就很须要线程池的动态性,也是这个上面这个面试题的需求来源。
原生的线程池核心咱们大体都过了一遍,不过这几个方法一直没提到,先来看看这几个方法:
我就不一一翻译了,大体能够看出线程池其实已经给予方法暴露出内部的一些状态,例如正在执行的线程数、已完成的任务数、队列中的任务数等等。
固然你能够想要更多的数据监控都简单的,像 Tomcat 那种继承线程池以后本身加呗,动态调整的第一步监控就这样搞定了!定时拉取这些数据,而后搞个看板,再结合邮件、短信、钉钉等报警方式,咱们能够很容易的监控线程池的状态!
接着就是动态修改线程池配置了。
能够看到线程池已经提供了诸多修改方法来更改线程池的配置,因此李老都已经考虑到啦!
一样,也能够继承线程池增长一些方法来修改,看具体的业务场景了。一样搞个页面,而后给予负责人员配置修改便可。
因此原生线程池已经提供修改配置的方法,也对外暴露出线程池内部执行状况,因此只要咱们实时监控状况,调用对应的 set 方法,便可动态修改线程池对应配置。
回答面试题的时候必定要提监控,显得你是有的放矢的。
若是你是 JDK 设计者,如何设计?
其实我以为这个是紧接着上一题问的,应该算是同一个问题。
并且 JDK 设计者已经设计好了呀?因此怎么说我也不清楚,不过咱们能够说一说具体的实现逻辑呗。
先来看下设置核心线程数的方法:
随着注释看下来应该没什么问题,就是那个 k 值我说一下,李老以为核心线程数是配置了,可是此时线程池内部是否须要这么多线程是不肯定的,那么就按工做队列里面的任务数来,直接按任务数马上新增线程,当任务队列为空了以后就终止新增。
这其实和李老设计的默认核心线程数增长策略是一致的,都是懒建立线程。
再看看设置最大线程数的方法:
没啥花头,调用的 interruptIdleWorkers
以前都分析过了。
我再贴一下以前写过的线程池设计面试题吧。
若是要让你设计一个线程池,你要怎么设计?
这种设计类问题仍是同样,先说下理解,代表你是知道这个东西的用处和原理的,而后开始 BB。基本上就是按照现有的设计来讲,再添加一些我的看法。
线程池讲白了就是存储线程的一个容器,池内保存以前创建过的线程来重复执行任务,减小建立和销毁线程的开销,提升任务的响应速度,并便于线程的管理。
我我的以为若是要设计一个线程池的话得考虑池内工做线程的管理、任务编排执行、线程池超负荷处理方案、监控。
初始化线程数、核心线程数、最大线程池都暴露出来可配置,包括超过核心线程数的线程空闲消亡配置。
任务的存储结构可配置,能够是无界队列也能够是有界队列,也能够根据配置分多个队列来分配不一样优先级的任务,也能够采用 stealing 的机制来提升线程的利用率。
再提供配置来代表此线程池是 IO 密集仍是 CPU 密集型来改变任务的执行策略。
超负荷的方案能够有多种,包括丢弃任务、拒绝任务并抛出异常、丢弃最旧的任务或自定义等等。
线程池埋好点暴露出用于监控的接口,如已处理任务数、待处理任务数、正在运行的线程数、拒绝的任务数等等信息。
我以为基本上这样答就差很少了,等着面试官的追问就好。
注意不须要跟面试官解释什么叫核心线程数之类的,都懂的不必。
固然这种开放型问题仍是仁者见仁智者见智,我这个不是标准答案,仅供参考。
关于线程池的一点碎碎念
线程池的好处咱们都知道了,可是不是任什么时候刻都上线程池的,我看过一些奇怪的代码,就是为了用线程池而用线程池...
还有须要根据不一样的业务划分不一样的线程池,否则会存在一些耗时的业务影响了另外一个业务致使这个业务崩了,而后都崩了的状况,因此要作好线程池隔离。
最后
好了,有关线程池的知识点和一些常见的一些面试题应该都涉及到了吧,若是还有别的啥角度刁钻的面试题,欢迎留言提出,我们一块儿研究研究。
相信看了这篇文章以后,关于线程池出去面试能够开始吹了!
若是以为文章不错,来个点赞和在看呗!
欢迎关注个人公众号【yes的练级攻略】,更多硬核文章等你来读。
我是yes,从一点点到亿点点,咱们下篇见。
推荐阅读:
本文分享自微信公众号 - yes的练级攻略(yes_java)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。