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

2022-02-17 08:21:09 浏览数 (1)

我回来了 ... 前一段时间在写一门算法课,总算是上线了,以及面试,所以没什么时间写,接下来的时间,应该会讲讲面试准备,刷题的一些东西,面了很多,通过面试的有平安,涂鸦智能,阿里,腾讯微保,虾皮,华为荣耀,微众,当然也有其他不少挂的,挂也正常,面试是一个双向选择,当然,我也还是个菜鸟。 以前,我总觉得买一件东西,做一件事,或者从某一个时间节点开始,我的生命就会发生转折,一切就会无比顺利,立马变厉害。但是,事实上并不是如此。我不可能马上变厉害,也不可能一口吃成一个胖子。看一篇文章也不能让你从此走上人生巅峰,越来越相信,这是一个长期的过程,只有量变引起质变,纵使缓慢,驰而不息。

  • 三个步骤
    • 线程池是什么?
    • 为什么要用线程池?
    • 需要考虑的点
  • 线程池状态
    • 状态有哪些?如何维护状态?
  • 线程相关
    • 线程怎么封装?线程放在哪个池子里?
    • 线程怎么取得任务?
    • 线程有哪些状态?
    • 线程的数量怎么限制?动态变化?自动伸缩?
    • 线程怎么消亡?如何重复利用?
  • 任务相关
    • 任务少可以直接处理,多的时候,放在哪里?
    • 任务队列满了,怎么办?
    • 用什么队列?

如何设计一个线程池

三个步骤

这是一个常见的问题,如果在比较熟悉线程池运作原理的情况下,这个问题并不难。设计实现一个东西,三步走:是什么?为什么?怎么做?

线程池是什么?

线程池使用了池化技术,将线程存储起来放在一个 "池子"(容器)里面,来了任务可以用已有的空闲的线程进行处理, 处理完成之后,归还到容器,可以复用。如果线程不够,还可以根据规则动态增加,线程多余的时候,亦可以让多余的线程死亡。

为什么要用线程池?

实现线程池有什么好处呢?

  • 降低资源消耗:池化技术可以重复利用已经创建的线程,降低线程创建和销毁的损耗。
  • 提高响应速度:利用已经存在的线程进行处理,少去了创建线程的时间
  • 管理线程可控:线程是稀缺资源,不能无限创建,线程池可以做到统一分配和监控
  • 拓展其他功能:比如定时线程池,可以定时执行任务

需要考虑的点

那线程池设计需要考虑的点:

  • 线程池状态:
    • 有哪些状态?如何维护状态?
  • 线程
    • 线程怎么封装?线程放在哪个池子里?
    • 线程怎么取得任务?
    • 线程有哪些状态?
    • 线程的数量怎么限制?动态变化?自动伸缩?
    • 线程怎么消亡?如何重复利用?
  • 任务
    • 任务少可以直接处理,多的时候,放在哪里?
    • 任务队列满了,怎么办?
    • 用什么队列?

如果从任务的阶段来看,分为以下几个阶段:

  • 如何存任务?
  • 如何取任务?
  • 如何执行任务?
  • 如何拒绝任务?

线程池状态

状态有哪些?如何维护状态?

状态可以设置为以下几种:

  • RUNNING:运行状态,可以接受任务,也可以处理任务
  • SHUTDOWN:不可以接受任务,但是可以处理任务
  • STOP:不可以接受任务,也不可以处理任务,中断当前任务
  • TIDYING:所有线程停止
  • TERMINATED:线程池的最后状态

各种状态之间是不一样的,他们的状态之间变化如下:

而维护状态的话,可以用一个变量单独存储,并且需要保证修改时的原子性,在底层操作系统中,对int的修改是原子的,而在32位的操作系统里面,对double,long这种64位数值的操作不是原子的。除此之外,实际上JDK里面实现的状态和线程池的线程数是同一个变量,高3位表示线程池的状态,而低29位则表示线程的数量。

这样设计的好处是节省空间,并且同时更新的时候有优势。

线程相关

线程怎么封装?线程放在哪个池子里?

线程,即是实现了Runnable接口,执行的时候,调用的是start()方法,但是start()方法内部编译后调用的是 run() 方法,这个方法只能调用一次,调用多次会报错。因此线程池里面的线程跑起来之后,不可能终止再启动,只能一直运行着。既然不可以停止,那么执行完任务之后,没有任务过来,只能是轮询取出任务的过程

线程可以运行任务,因此封装线程的时候,假设封装成为 Worker, Worker里面必定是包含一个 Thread,表示当前线程,除了当前线程之外,封装的线程类还应该持有任务,初始化可能直接给予任务,当前的任务是null的时候才需要去获取任务。

可以考虑使用 HashSet 来存储线程,也就是充当线程池的角色,当然,HashSet 会有线程安全的问题需要考虑,那么我们可以考虑使用一个可重入锁比如 ReentrantLock,凡是增删线程池的线程,都需要锁住。

代码语言:javascript复制
    private final ReentrantLock mainLock = new ReentrantLock();

线程怎么取得任务?

(1)初始化线程的时候可以直接指定任务,譬如Runnable firstTask,将任务封装到 worker 中,然后获取 worker 里面的 threadthread.run()的时候,其实就是 跑的是 worker 本身的 run() 方法,因为 worker 本身就是实现了 Runnable 接口,里面的线程其实就是其本身。因此也可以实现对 ThreadFactory 线程工厂的定制化。

代码语言:javascript复制
    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线程将就绪和运行中统称为可运行
    • 只是有资格执行,不一定会执行
    • start()之后进入就绪状态,sleep()结束或者join()结束,线程获得对象锁等都会进入该状态。
    • CPU时间片结束或者主动调用yield()方法,也会进入该状态
    • Runnable:就绪状态:创建对象后,调用了start()方法,该状态的线程还位于可运行线程池中,等待调度,获取CPU的使用权
    • Running :获取到CPU的使用权(获得CPU时间片),变成运行中
  • BLOCKED :阻塞,线程阻塞于锁,等待监视器锁,一般是Synchronize关键字修饰的方法或者代码块
  • WAITING :进入该状态,需要等待其他线程通知(notify)或者中断,一个线程无限期地等待另一个线程。
  • TIMED_WAITING :超时等待,在指定时间后自动唤醒,返回,不会一直等待
  • TERMINATED :线程执行完毕,已经退出。如果已终止再调用start(),将会抛出java.lang.IllegalThreadStateException异常。

线程的数量怎么限制?动态变化?自动伸缩?

线程池本身,就是为了限制和充分使用线程资源的,因此有了两个概念:核心线程数,最大线程数。

要想让线程数根据任务数量动态变化,那么我们可以考虑以下设计(假设不断有任务):

  • 来一个任务创建一个线程处理,直到线程数达到核心线程数。
  • 达到核心线程数之后且没有空闲线程,来了任务直接放到任务队列。
  • 任务队列如果是无界的,会被撑爆。
  • 任务队列如果是有界的,任务队列满了之后,还有任务过来,会继续创建线程处理,此时线程数大于核心线程数,直到线程数等于最大线程数。
  • 达到最大线程数之后,还有任务不断过来,会触发拒绝策略,根据不同策略进行处理。
  • 如果任务不断处理完成,任务队列空了,线程空闲没任务,会在一定时间内,销毁,让线程数保持在核心线程数即可。

由上面可以看出,主要控制伸缩的参数是核心线程数最大线程数,任务队列,拒绝策略

线程怎么消亡?如何重复利用?

线程不能被重新调用多次start(),因此只能调用一次,也就是线程不可能停下来,再启动。那么就说明线程复用只是在不断的循环罢了。

消亡只是结束了它的run()方法,当线程池数量需要自动缩容的,就会让一部分空闲的线程结束。

而重复利用,其实是执行完任务之后,再去去任务队列取任务,取不到任务会等待,任务队列是一个阻塞队列,这是一个不断循环的过程。

任务相关

任务少可以直接处理,多的时候,放在哪里?

任务少的时候,来了直接创建,赋予线程初始化任务,就可开始执行,任务多的时候,把它放进队列里面,先进先出。

任务队列满了,怎么办?

任务队列满了,会继续增加线程,直到达到最大的线程数。

用什么队列?

一般的队列,只是一个有限长度的缓冲区,要是满了,就不能保存当前的任务,阻塞队列可以通过阻塞,保留出当前需要入队的任务,只是会阻塞等待。同样的,阻塞队列也可以保证任务队列没有任务的时候,阻塞当前获取任务的线程,让它进入wait状态,释放cpu的资源。因此在线程池的场景下,阻塞队列其实是比较有必要的。

以上是我所考虑到的相关知识点,仅供参考。

【作者简介】

秦怀,技术之路不在一时,山高水长,纵使缓慢,驰而不息。个人写作方向:Java源码解析,JDBC,Mybatis,Spring,redis,分布式,剑指Offer,LeetCode等,认真写好每一篇文章,不喜欢标题党,不喜欢花里胡哨,大多写系列文章,不能保证我写的都完全正确,但是我保证所写的均经过实践或者查找资料。遗漏或者错误之处,还望指正。

0 人点赞