如果你是 JDK 设计者,如何设计线程池?我跟面试官大战了三十个回合(上)

简介: 如果你是 JDK 设计者,如何设计线程池?我跟面试官大战了三十个回合(上)

文章来源我自己公众号:如图两道面试题,顺便深入线程池,并连环17问

公众号很多硬核文章,求大家关注下呀~ 下面开始我们本篇文章。

image.png


今天就借着这两面试真题来深入一波线程池吧,这篇文章力求把线程池核心点和常问的面试点一网打尽,当然个人能力有限,可能会有遗漏,欢迎留言补充!

先把大部分问题列出来,如果你都答得出来,那没必要看下去:

  • 为什么会有线程池?
  • 简单手写一个线程池?
  • 为什么要把任务先放在任务队列里面,而不是把线程先拉满到最大线程数?
  • 线程池如何动态修改核心线程数和最大线程数?
  • 如果你是 JDK 设计者,如何设计?
  • 如果要让你设计一个线程池,你要怎么设计?
  • 你是如何理解核心线程的?
  • 你是怎么理解 KeepAliveTime 的?
  • 那 workQueue 有什么用?
  • 你是如何理解拒绝策略的?
  • 你说你看过源码,那你肯定知道线程池里的 ctl 是干嘛的咯?
  • 你知道线程池有几种状态吗?
  • 你知道线程池的状态是如何变迁的吗?
  • 如何修改原生线程池,使得可以先拉满线程数再入任务队列排队?
  • Tomcat 中的定制化线程池实现 如果线程池中的线程在执行任务的时候,抛异常了,会怎么样?
  • 原生线程池的核心线程一定伴随着任务慢慢创建的吗?
  • 线程池的核心线程在空闲的时候一定不会被回收吗?

接得住吗?话不多说,发车!


为什么会有线程池?


想要深入理解线程池的原理得先知道为什么需要线程池。

首先你要明白,线程是一个重资源,JVM 中的线程与操作系统的线程是一对一的关系,所以在 JVM 中每创建一个线程就需要调用操作系统提供的 API 创建线程,赋予资源,并且销毁线程同样也需要系统调用。

而系统调用就意味着上下文切换等开销,并且线程也是需要占用内存的,而内存也是珍贵的资源。

因此线程的创建和销毁是一个重操作,并且线程本身也占用资源。

然后你还需要知道,线程数并不是越多越好

我们都知道线程是 CPU 调度的最小单位,在单核时代,如果是纯运算的操作是不需要多线程的,一个线程一直执行运算即可。但如果这个线程正在等待 I/O 操作,此时 CPU 就处于空闲状态,这就浪费了 CPU 的算力,因此有了多线程,在某线程等待 I/O 等操作的时候,另一个线程顶上,充分利用 CPU,提高处理效率。


image.png


此时的多线程主要是为了提高 CPU 的利用率而提出。

而随着 CPU 的发展,核心数越来越多,能同时运行的线程数也提升了,此时的多线程不仅是为了提高单核 CPU 的利用率,也是为了充分利用多个核心。

至此想必应该明白了为什么会有多线程,无非就是为了充分利用 CPU 空闲的时间,一刻也不想让他停下来。

但 CPU 的核心数有限,同时能运行的线程数有限,所以需要根据调度算法切换执行的线程,而线程的切换需要开销,比如替换寄存器的内容、高速缓存的失效等等。

如果线程数太多,切换的频率就变高,可能使得多线程带来的好处抵不过线程切换带来的开销,得不偿失。

因此线程的数量需要得以控制,结合上述的描述可知,线程的数量与 CPU 核心数和 I/O 等待时长息息相关。

小结一下:

  • Java中线程与操作系统线程是一比一的关系。
  • 线程的创建和销毁是一个“较重”的操作。
  • 多线程的主要是为了提高 CPU 的利用率。
  • 线程的切换有开销,线程数的多少需要结合 CPU核心数与 I/O 等待占比。

综上我们知道了线程的这些特性,所以说它不是一个可以“随意拿捏”的东西,我们需要重视它,好好规划和管理它,充分利用硬件的能力,从而提升程序执行效率,所以线程池应运而生。


什么是线程池?


那我们要如何管理好线程呢?

因为线程数太少无法充分利用 CPU ,太多的话由于上下文切换的消耗又得不偿失,所以我们需要评估系统所要承载的并发量和所执行任务的特性,得出大致需要多少个线程数才能充分利用 CPU,因此需要控制线程数量

又因为线程的创建和销毁是一个“重”操作,所以我们需要避免线程频繁地创建与销毁,因此我们需要缓存一批线程,让它们时刻准备着执行任务。

目标已经很清晰了,弄一个池子,里面存放约定数量的线程,这就是线程池,一种池化技术。

熟悉对象池、连接池的朋友肯定对池化技术不陌生,一般池化技术的使用方式是从池子里拿出资源,然后使用,用完了之后归还。

但是线程池的实现不太一样,不是说我们从线程池里面拿一个线程来执行任务,等任务执行完了之后再归还线程,你可以想一下这样做是否合理。

线程池的常见实现更像是一个黑盒存在,我们设置好线程池的大小之后,直接往线程池里面丢任务,然后就不管了。


image.png


剥开来看,线程池其实是一个典型的生产者-消费者模式

线程池内部会有一个队列来存储我们提交的任务,而内部线程不断地从队列中索取任务来执行,这就是线程池最原始的执行机制。


image.png


按照这个思路,我们可以很容易的实现一个简单版线程池,想必看了下面这个代码实现,对线程池的核心原理就会了然于心。

首先线程池内需要定义两个成员变量,分别是阻塞队列和线程列表,然后自定义线程使它的任务就是不断的从阻塞队列中拿任务然后执行。

@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);
      }
    });
  }

运行结果如下:


image.png


下次面试官让你手写线程池,直接上这个简单版,然后他会开始让你优化,比如什么线程一开始都 start 了不好,想懒加载,然后xxxx...最终其实就是想往李老爷实现的 ThreadPoolExecutor 上面靠。

那就来嘛。


ThreadPoolExecutor 剖析


这玩意就是常被问的线程池的实现类了,先来看下构造函数:


image.png


核心原理其实和咱们上面实现的差不多,只是生产级别的那肯定是要考虑的更多,接下来我们就来看看此线程池的工作原理。

先来一张图:


image.png


简单来说线程池把任务的提交和任务的执行剥离开来,当一个任务被提交到线程池之后:

  • 如果此时线程数小于核心线程数,那么就会新起一个线程来执行当前的任务。
  • 如果此时线程数大于核心线程数,那么就会将任务塞入阻塞队列中,等待被执行。
  • 如果阻塞队列满了,并且此时线程数小于最大线程数,那么会创建新线程来执行当前任务。
  • 如果阻塞队列满了,并且此时线程数大于最大线程数,那么会采取拒绝策略。

以上就是任务提交给线程池后各种状况汇总,一个很容易出现理解错误的地方就是当线程数达到核心数的时候,任务是先入队,而不是先创建最大线程数。

从上述可知,线程池里的线程不是一开始就直接拉满的,是根据任务量开始慢慢增多的,这就算一种懒加载,到用的时候再创建线程,节省资源。





相关文章
|
1月前
|
Dubbo Java 应用服务中间件
剖析Tomcat线程池与JDK线程池的区别和联系!
剖析Tomcat线程池与JDK线程池的区别和联系!
105 0
剖析Tomcat线程池与JDK线程池的区别和联系!
|
2月前
|
存储 缓存 安全
【Java面试题汇总】多线程、JUC、锁篇(2023版)
线程和进程的区别、CAS的ABA问题、AQS、哪些地方使用了CAS、怎么保证线程安全、线程同步方式、synchronized的用法及原理、Lock、volatile、线程的六个状态、ThreadLocal、线程通信方式、创建方式、两种创建线程池的方法、线程池设置合适的线程数、线程安全的集合?ConcurrentHashMap、JUC
【Java面试题汇总】多线程、JUC、锁篇(2023版)
|
24天前
|
监控 数据可视化 Java
如何使用JDK自带的监控工具JConsole来监控线程池的内存使用情况?
如何使用JDK自带的监控工具JConsole来监控线程池的内存使用情况?
|
2月前
|
消息中间件 前端开发 NoSQL
面试官:线程池遇到未处理的异常会崩溃吗?
面试官:线程池遇到未处理的异常会崩溃吗?
75 3
面试官:线程池遇到未处理的异常会崩溃吗?
|
2月前
|
消息中间件 存储 前端开发
面试官:说说停止线程池的执行流程?
面试官:说说停止线程池的执行流程?
51 2
面试官:说说停止线程池的执行流程?
|
2月前
|
消息中间件 前端开发 NoSQL
面试官:如何实现线程池任务编排?
面试官:如何实现线程池任务编排?
33 1
面试官:如何实现线程池任务编排?
|
2月前
|
监控 数据可视化 Java
使用JDK自带的监控工具JConsole来监控线程池的内存使用情况
使用JDK自带的监控工具JConsole来监控线程池的内存使用情况
|
3月前
|
缓存 Java 调度
【Java 并发秘籍】线程池大作战:揭秘 JDK 中的线程池家族!
【8月更文挑战第24天】Java的并发库提供多种线程池以应对不同的多线程编程需求。本文通过实例介绍了四种主要线程池:固定大小线程池、可缓存线程池、单一线程线程池及定时任务线程池。固定大小线程池通过预设线程数管理任务队列;可缓存线程池能根据需要动态调整线程数量;单一线程线程池确保任务顺序执行;定时任务线程池支持周期性或延时任务调度。了解并正确选用这些线程池有助于提高程序效率和资源利用率。
52 2
|
3月前
|
消息中间件 缓存 算法
Java多线程面试题总结(上)
进程和线程是操作系统管理程序执行的基本单位,二者有明显区别: 1. **定义与基本单位**:进程是资源分配的基本单位,拥有独立的内存空间;线程是调度和执行的基本单位,共享所属进程的资源。 2. **独立性与资源共享**:进程间相互独立,通信需显式机制;线程共享进程资源,通信更直接快捷。 3. **管理与调度**:进程管理复杂,线程管理更灵活。 4. **并发与并行**:进程并发执行,提高资源利用率;线程不仅并发还能并行执行,提升执行效率。 5. **健壮性**:进程更健壮,一个进程崩溃不影响其他进程;线程崩溃可能导致整个进程崩溃。
48 2
|
3月前
|
C# Windows 开发者
当WPF遇见OpenGL:一场关于如何在Windows Presentation Foundation中融入高性能跨平台图形处理技术的精彩碰撞——详解集成步骤与实战代码示例
【8月更文挑战第31天】本文详细介绍了如何在Windows Presentation Foundation (WPF) 中集成OpenGL,以实现高性能的跨平台图形处理。通过具体示例代码,展示了使用SharpGL库在WPF应用中创建并渲染OpenGL图形的过程,包括开发环境搭建、OpenGL渲染窗口创建及控件集成等关键步骤,帮助开发者更好地理解和应用OpenGL技术。
246 0