面试题 -- 如何设计一个线程池

万事须己运,他得非我贤。这篇文章主要讲述面试题 -- 如何设计一个线程池相关的知识,希望能为你提供帮助。
[TOC]
如何设计一个线程池? 三个步骤这是一个常见的问题,如果在比较熟悉线程池运作原理的情况下,这个问题并不难。设计实现一个东西,三步走:是什么?为什么?怎么做?
线程池是什么?
线程池使用了池化技术,将线程存储起来放在一个 " 池子" (容器)里面,来了任务可以用已有的空闲的线程进行处理, 处理完成之后,归还到容器,可以复用。如果线程不够,还可以根据规则动态增加,线程多余的时候,亦可以让多余的线程死亡。
为什么要用线程池?
实现线程池有什么好处呢?

  • 降低资源消耗:池化技术可以重复利用已经创建的线程,降低线程创建和销毁的损耗。
  • 提高响应速度:利用已经存在的线程进行处理,少去了创建线程的时间
  • 管理线程可控:线程是稀缺资源,不能无限创建,线程池可以做到统一分配和监控
  • 拓展其他功能:比如定时线程池,可以定时执行任务
需要考虑的点
那线程池设计需要考虑的点:
  • 线程池状态:
    • 有哪些状态?如何维护状态?
  • 线程
    • 线程怎么封装?线程放在哪个池子里?
    • 线程怎么取得任务?
    • 线程有哪些状态?
    • 线程的数量怎么限制?动态变化?自动伸缩?
    • 线程怎么消亡?如何重复利用?
  • 任务
    • 任务少可以直接处理,多的时候,放在哪里?
    • 任务队列满了,怎么办?
    • 用什么队列?
如果从任务的阶段来看,分为以下几个阶段:
  • 如何存任务?
  • 如何取任务?
  • 如何执行任务?
  • 如何拒绝任务?
线程池状态 状态有哪些?如何维护状态?
状态可以设置为以下几种:
  • 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 里面的 threadthread.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的资源。因此在线程池的场景下,阻塞队列其实是比较有必要的。
【面试题 -- 如何设计一个线程池】【作者简介】:
秦怀,公众号【秦怀杂货店】作者,技术之路不在一时,山高水长,纵使缓慢,驰而不息。这个世界希望一切都很快,更快,但是我希望自己能走好每一步,写好每一篇文章,期待和你们一起交流。如果有帮助,顺手点个赞,对我,是莫大的鼓励和认可。

    推荐阅读