如何设计一个线程池?
三个步骤
这是一个常见的问题,如果在比较熟悉线程池运作原理的情况下,这个问题并不难。设计实现一个东西,三步走:是什么?为什么?怎么做?
线程池是什么?
线程池使用了池化技术,将线程存储起来放在一个 "池子"(容器)里面,来了任务可以用已有的空闲的线程进行处理, 处理完成之后,归还到容器,可以复用。如果线程不够,还可以根据规则动态增加,线程多余的时候,亦可以让多余的线程死亡。
为什么要用线程池?
实现线程池有什么好处呢?
- 降低资源消耗:池化技术可以重复利用已经创建的线程,降低线程创建和销毁的损耗。
- 提高响应速度:利用已经存在的线程进行处理,少去了创建线程的时间
- 管理线程可控:线程是稀缺资源,不能无限创建,线程池可以做到统一分配和监控
- 拓展其他功能:比如定时线程池,可以定时执行任务
需要考虑的点
那线程池设计需要考虑的点:
-
线程池状态:
- 有哪些状态?如何维护状态?
-
线程
- 线程怎么封装?线程放在哪个池子里?
- 线程怎么取得任务?
- 线程有哪些状态?
- 线程的数量怎么限制?动态变化?自动伸缩?
- 线程怎么消亡?如何重复利用?
-
任务
- 任务少可以直接处理,多的时候,放在哪里?
- 任务队列满了,怎么办?
- 用什么队列?
如果从任务的阶段来看,分为以下几个阶段:
- 如何存任务?
- 如何取任务?
- 如何执行任务?
- 如何拒绝任务?
线程池状态
状态有哪些?如何维护状态?
状态可以设置为以下几种:
- RUNNING:运行状态,可以接受任务,也可以处理任务
- SHUTDOWN:不可以接受任务,但是可以处理任务
- STOP:不可以接受任务,也不可以处理任务,中断当前任务
- TIDYING:所有线程停止
- TERMINATED:线程池的最后状态
各种状态之间是不一样的,他们的状态之间变化如下:
而维护状态的话,可以用一个变量单独存储,并且需要保证修改时的原子性,在底层操作系统中,对int的修改是原子的,而在32位的操作系统里面,对double
,long
这种64位数值的操作不是原子的。除此之外,实际上JDK里面实现的状态和线程池的线程数是同一个变量,高3位表示线程池的状态,而低29位则表示线程的数量。
这样设计的好处是节省空间,并且同时更新的时候有优势。
线程相关
线程怎么封装?线程放在哪个池子里?
线程,即是实现了Runnable
接口,执行的时候,调用的是start()
方法,但是start()
方法内部编译后调用的是 run()
方法,这个方法只能调用一次,调用多次会报错。因此线程池里面的线程跑起来之后,不可能终止再启动,只能一直运行着。既然不可以停止,那么执行完任务之后,没有任务过来,只能是轮询取出任务的过程
线程可以运行任务,因此封装线程的时候,假设封装成为 Worker
, Worker
里面必定是包含一个 Thread
,表示当前线程,除了当前线程之外,封装的线程类还应该持有任务,初始化可能直接给予任务,当前的任务是null的时候才需要去获取任务。
可以考虑使用 HashSet
来存储线程,也就是充当线程池的角色,当然,HashSet
会有线程安全的问题需要考虑,那么我们可以考虑使用一个可重入锁比如 ReentrantLock
,凡是增删线程池的线程,都需要锁住。
private final ReentrantLock mainLock = new ReentrantLock();
线程怎么取得任务?
(1)初始化线程的时候可以直接指定任务,譬如Runnable firstTask
,将任务封装到 worker
中,然后获取 worker
里面的 thread
,thread.run()
的时候,其实就是 跑的是 worker
本身的 run()
方法,因为 worker
本身就是实现了 Runnable
接口,里面的线程其实就是其本身。因此也可以实现对 ThreadFactory
线程工厂的定制化。
private final class Worker
extends AbstractQueuedSynchronizer
implements Runnable
{
final Thread thread;
Runnable firstTask;
...
Worker(Runnable firstTask) {
setState(-1); // inhibit interrupts until runWorker
this.firstTask = firstTask;
// 从线程池创建线程,传入的是其本身
this.thread = getThreadFactory().newThread(this);
}
}
(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
的资源。因此在线程池的场景下,阻塞队列其实是比较有必要的。