文章来源我自己公众号:如图两道面试题,顺便深入线程池,并连环17问
公众号很多硬核文章,求大家关注下呀~ 下面开始我们本篇文章。
今天就借着这两面试真题来深入一波线程池吧,这篇文章力求把线程池核心点和常问的面试点一网打尽,当然个人能力有限,可能会有遗漏,欢迎留言补充!
先把大部分问题列出来,如果你都答得出来,那没必要看下去:
- 为什么会有线程池?
- 简单手写一个线程池?
- 为什么要把任务先放在任务队列里面,而不是把线程先拉满到最大线程数?
- 线程池如何动态修改核心线程数和最大线程数?
- 如果你是 JDK 设计者,如何设计?
- 如果要让你设计一个线程池,你要怎么设计?
- 你是如何理解核心线程的?
- 你是怎么理解 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 剖析
这玩意就是常被问的线程池的实现类了,先来看下构造函数:
核心原理其实和咱们上面实现的差不多,只是生产级别的那肯定是要考虑的更多,接下来我们就来看看此线程池的工作原理。
先来一张图:
简单来说线程池把任务的提交和任务的执行剥离开来,当一个任务被提交到线程池之后:
- 如果此时线程数小于核心线程数,那么就会新起一个线程来执行当前的任务。
- 如果此时线程数大于核心线程数,那么就会将任务塞入阻塞队列中,等待被执行。
- 如果阻塞队列满了,并且此时线程数小于最大线程数,那么会创建新线程来执行当前任务。
- 如果阻塞队列满了,并且此时线程数大于最大线程数,那么会采取拒绝策略。
以上就是任务提交给线程池后各种状况汇总,一个很容易出现理解错误的地方就是当线程数达到核心数的时候,任务是先入队,而不是先创建最大线程数。
从上述可知,线程池里的线程不是一开始就直接拉满的,是根据任务量开始慢慢增多的,这就算一种懒加载,到用的时候再创建线程,节省资源。