面试题 -- 如何设计一个线程池
以前,我总觉得的买一件东西,做一件事,或者从某一个时间节点开始,我的生命就会发生转折,一切就会无比顺利,立马变厉害。但是,事实上并不是如此。我不可能马上变厉害,也不可能一口吃成一个胖子。看一篇文章也不能让你从此走上人生巅峰,越来越相信,这是一个长期的过程,只有量变引起质变,纵使缓慢,驰而不息。
如何设计一个线程池?
三个步骤
这是一个常见的问题,如果在比较熟悉线程池运作原理的情况下,这个问题并不难。设计实现一个东西,三步走:是什么?为什么?怎么做?
线程池是什么?
线程池使用了池化技术,将线程存储起来放在一个 "池子"(容器)里面,来了任务可以用已有的空闲的线程进行处理, 处理完成之后,归还到容器,可以复用。如果线程不够,还可以根据规则动态增加,线程多余的时候,亦可以让多余的线程死亡。
为什么要用线程池?
实现线程池有什么好处呢?
降低资源消耗:池化技术可以重复利用已经创建的线程,降低线程创建和销毁的损耗。
提高响应速度:利用已经存在的线程进行处理,少去了创建线程的时间
管理线程可控:线程是稀缺资源,不能无限创建,线程池可以做到统一分配和监控
拓展其他功能:比如定时线程池,可以定时执行任务
需要考虑的点
那线程池设计需要考虑的点:
线程池状态:
有哪些状态?如何维护状态?
线程
线程怎么封装?线程放在哪个池子里?
线程怎么取得任务?
线程有哪些状态?
线程的数量怎么限制?动态变化?自动伸缩?
线程怎么消亡?如何重复利用?
任务
任务少可以直接处理,多的时候,放在哪里?
任务队列满了,怎么办?
用什么队列?
如果从任务的阶段来看,分为以下几个阶段:
如何存任务?
如何取任务?
如何执行任务?
如何拒绝任务?
线程池状态
状态有哪些?如何维护状态?
状态可以设置为以下几种:
RUNNING:运行状态,可以接受任务,也可以处理任务
SHUTDOWN:不可以接受任务,但是可以处理任务
STOP:不可以接受任务,也不可以处理任务,中断当前任务
TIDYING:所有线程停止
TERMINATED:线程池的最后状态
各种状态之间是不一样的,他们的状态之间变化如下:
而维护状态的话,可以用一个变量单独存储,并且需要保证修改时的原子性,在底层操作系统中,对 int 的修改是原子的,而在 32 位的操作系统里面,对double
,long
这种 64 位数值的操作不是原子的。除此之外,实际上 JDK 里面实现的状态和线程池的线程数是同一个变量,高 3 位表示线程池的状态,而低 29 位则表示线程的数量。
这样设计的好处是节省空间,并且同时更新的时候有优势。
线程相关
线程怎么封装?线程放在哪个池子里?
线程,即是实现了Runnable
接口,执行的时候,调用的是start()
方法,但是start()
方法内部编译后调用的是 run()
方法,这个方法只能调用一次,调用多次会报错。因此线程池里面的线程跑起来之后,不可能终止再启动,只能一直运行着。既然不可以停止,那么执行完任务之后,没有任务过来,只能是轮询取出任务的过程
线程可以运行任务,因此封装线程的时候,假设封装成为 Worker
, Worker
里面必定是包含一个 Thread
,表示当前线程,除了当前线程之外,封装的线程类还应该持有任务,初始化可能直接给予任务,当前的任务是 null 的时候才需要去获取任务。
可以考虑使用 HashSet
来存储线程,也就是充当线程池的角色,当然,HashSet
会有线程安全的问题需要考虑,那么我们可以考虑使用一个可重入锁比如 ReentrantLock
,凡是增删线程池的线程,都需要锁住。
线程怎么取得任务?
(1)初始化线程的时候可以直接指定任务,譬如Runnable firstTask
,将任务封装到 worker
中,然后获取 worker
里面的 thread
,thread.run()
的时候,其实就是 跑的是 worker
本身的 run()
方法,因为 worker
本身就是实现了 Runnable
接口,里面的线程其实就是其本身。因此也可以实现对 ThreadFactory
线程工厂的定制化。
(2)运行完任务的线程,应该继续取任务,取任务肯定需要从任务队列里面取,要是任务队列里面没有任务,由于是阻塞队列,那么可以等待,如果等待若干时间后,仍没有任务,倘若该线程池的线程数已经超过核心线程数,并且允许线程消亡的话,应该将该线程从线程池中移除,并结束掉该线程。
取任务和执行任务,对于线程池里面的线程而言,就是一个周而复始的工作,除非它会消亡。
线程有哪些状态?
现在我们所说的是Java
中的线程Thread
,一个线程在一个给定的时间点,只能处于一种状态,这些状态都是虚拟机的状态,不能反映任何操作系统的线程状态,一共有六种/七种状态:
NEW
:创建了线程对象,但是还没有调用Start()
方法,还没有启动的线程处于这种状态。Running
:运行状态,其实包含了两种状态,但是Java
线程将就绪和运行中统称为可运行Runnable
:就绪状态:创建对象后,调用了start()
方法,该状态的线程还位于可运行线程池中,等待调度,获取CPU
的使用权只是有资格执行,不一定会执行
start()
之后进入就绪状态,sleep()
结束或者join()
结束,线程获得对象锁等都会进入该状态。CPU
时间片结束或者主动调用yield()
方法,也会进入该状态Running
:获取到CPU
的使用权(获得 CPU 时间片),变成运行中BLOCKED
:阻塞,线程阻塞于锁,等待监视器锁,一般是Synchronize
关键字修饰的方法或者代码块WAITING
:进入该状态,需要等待其他线程通知(notify
)或者中断,一个线程无限期地等待另一个线程。TIMED_WAITING
:超时等待,在指定时间后自动唤醒,返回,不会一直等待TERMINATED
:线程执行完毕,已经退出。如果已终止再调用 start(),将会抛出java.lang.IllegalThreadStateException
异常。
线程的数量怎么限制?动态变化?自动伸缩?
线程池本身,就是为了限制和充分使用线程资的,因此有了两个概念:核心线程数,最大线程数。
要想让线程数根据任务数量动态变化,那么我们可以考虑以下设计(假设不断有任务):
来一个任务创建一个线程处理,直到线程数达到核心线程数。
达到核心线程数之后且没有空闲线程,来了任务直接放到任务队列。
任务队列如果是无界的,会被撑爆。
任务队列如果是有界的,任务队列满了之后,还有任务过来,会继续创建线程处理,此时线程数大于核心线程数,直到线程数等于最大线程数。
达到最大线程数之后,还有任务不断过来,会触发拒绝策略,根据不同策略进行处理。
如果任务不断处理完成,任务队列空了,线程空闲没任务,会在一定时间内,销毁,让线程数保持在核心线程数即可。
由上面可以看出,主要控制伸缩的参数是核心线程数
,最大线程数
,任务队列
,拒绝策略
。
线程怎么消亡?如何重复利用?
线程不能被重新调用多次start()
,因此只能调用一次,也就是线程不可能停下来,再启动。那么就说明线程复用只是在不断的循环罢了。
消亡只是结束了它的run()
方法,当线程池数量需要自动缩容的,就会让一部分空闲的线程结束。
而重复利用,其实是执行完任务之后,再去去任务队列取任务,取不到任务会等待,任务队列是一个阻塞队列,这是一个不断循环
的过程。
任务相关
任务少可以直接处理,多的时候,放在哪里?
任务少的时候,来了直接创建,赋予线程初始化任务,就可开始执行,任务多的时候,把它放进队列里面,先进先出。
任务队列满了,怎么办?
任务队列满了,会继续增加线程,直到达到最大的线程数。
用什么队列?
一般的队列,只是一个有限长度的缓冲区,要是满了,就不能保存当前的任务,阻塞队列可以通过阻塞,保留出当前需要入队的任务,只是会阻塞等待。同样的,阻塞队列也可以保证任务队列没有任务的时候,阻塞当前获取任务的线程,让它进入wait
状态,释放cpu
的资源。因此在线程池的场景下,阻塞队列其实是比较有必要的。
【作者简介】:
秦怀,公众号【秦怀杂货店】作者,技术之路不在一时,山高水长,纵使缓慢,驰而不息。这个世界希望一切都很快,更快,但是我希望自己能走好每一步,写好每一篇文章,期待和你们一起交流。如果有帮助,顺手点个赞,对我,是莫大的鼓励和认可。
版权声明: 本文为 InfoQ 作者【秦怀杂货店】的原创文章。
原文链接:【http://xie.infoq.cn/article/63ce8456cc1b06774d33de621】。文章转载请联系作者。
评论