• 线程池底层队列详解


    背景

      这篇博文是接着上一篇 线程池专题 的一个补充,是针对线程池底层队列的种类做一个进一步的深入详解,上一篇博文主要针对一线大厂针对线程池的灵魂 5 问展开的,而这一篇也是综合了另外面试经验,把底层的一些内容再深入剖析一下。

    线程池任务处理

      如果运行的线程数 < corePoolSize,则 Executor 始终首选添加新的线程,而不进行排队。即任务根本不会存入queue中,而是直接运行

      如果运行的线程数 >= corePoolSize,则 Executor 始终首选将请求加入队列,而不添加新的线程。 

      如果无法将请求加入队列,则创建新的线程,除非创建此线程超出 maximumPoolSize,在这种情况下,任务将被拒绝。

    线程池队列详解

      针对 BlockingQueue <Runnable> workQueue 这个缓冲队列,在JDK中,其实已经说得很清楚了,一共有三种类型的queue,分别是:

    1. 直接提交 SynchronousQueue
    2. 无界队列 LinkedBlockingQueue
    3. 有界队列 ArrayBlockingQueue

      由上一篇 线程池专题 中源码部分分析可得知,其中

      FixedThreadPool 和 SingleThreadExecutor 是使用的无界队列 LinkedBlockingQueue

      CachedThreadPool 是使用的 直接提交队列 SynchronousQueue

      而 ScheduledThreadPool 使用的是 DelayedWorkQueue,这种队列的内部元素会按照延迟时间的长短对任务进行排序,延时时间越短地就排在队列的前面,越先被执行,他的内部采用的是“堆”的数据结构。ScheduledThreadPool 这种类型的线程池就用到该队列。这种线程池要的效果是可以延迟的执行任务,是以时间为单位来决定任务的执行顺序的,刚好 DelayedWorkQueue 队列就有把任务按时间进行排序的能力,所以一拍即合,这两种线程池就使用 DelayedWorkQueue 队列了。

     1. 直接提交

      SynchronousQueue 这个队列是一个不存储元素的队列,它只负责传递消息。那么 CachedThreadPool 为什么会采用这种类型的队列呢,由上一篇 线程池专题 中相关源码可知,CachedThreadPool 默认的核心线程数是 0,它的最大线程数为 Integer.MAX_VALUE,可以认为是无限大,所以一旦有任务需要处理,这种类型的线程池就会直接创建一个线程去执行,并不会把任务存储在队列中,因为线程数完全能够支持执行所有的任务,那么就无需存储在队列里,队列只起传递消息的效果。 此策略可以避免在处理可能具有内部依赖性的请求集时出现锁。

     2. 无界队列

      LinkedBlockingQueue 这个队列,是无界队列,也可以理解成队列是无限大的,使用它的线程池有 FixedThreadPool 和 SingleThreadExecutor,由上一篇 线程池专题 中相关源码可以发现,FixedThreadPool 以及 SingleThreadExecutor 两种类型的线程池,他们的核心线程数和最大线程数是一样的,这样是不是可以理解成,哪怕任务再多,也只有核心线程数目的线程在执行任务,那多出来的任务怎么办,只能存在队列了,但问题是,任务那么多,又没有新的线程来帮忙,放在有界的队列会不会根本不够,所以只好选择无界队列 LinkedBlockingQueue 来配合这两个类型的线程池了。
      例如,在 Web页服务器中。这种队列可用于处理瞬态突发请求,当命令以超过队列所能处理的平均数连续到达时,此策略允许无界线程具有增长的可能性。

     3. 有界队列

      当使用有限的 maximumPoolSizes时,有界队列(如 ArrayBlockingQueue)有助于防止资源耗尽,但是可能较难调整和控制。

      队列大小 和 最大池大小 可能需要相互折衷:使用大型队列 和 小型池可以最大限度地降低 CPU 使用率、操作系统资源 和 上下文切换开销,但是可能导致人工降低吞吐量。

      如果任务频繁阻塞(例如,如果它们是 I/O 边界),则系统可能为超过许可的更多线程安排时间。

      使用小型队列通常要求较大的池大小,CPU使用率较高,但是可能遇到不可接受的调度开销,这样也会降低吞吐量。

    BlockingQueue 的选择 

    一、使用直接提交策略,也即SynchronousQueue

      首先 SynchronousQueue 是无界的,也就是说他存储任务的能力是没有限制的,但是由于该Queue本身的特性,在某次添加元素后必须等待其他线程取走后才能继续添加。在这里不是核心线程便是新创建的线程,但是我们试想一样下,下面的场景。

      我们使用一下参数构造 ThreadPoolExecutor:

    new ThreadPoolExecutor(   
              2, 3, 30, TimeUnit.SECONDS,    
              new SynchronousQueue<Runnable>(),    
              new RecorderThreadFactory("recorderPoolTest"),    
              new ThreadPoolExecutor.CallerRunsPolicy()
    ); 

     当核心线程已经有2个正在运行.

    1. 此时继续来了一个 [任务A],根据前面介绍的 “如果运行的线程 >= corePoolSize,则 Executor 始终首选将请求加入队列,而不添加新的线程。”,所以 [任务A] 被添加到 queue 中。
    2. 又来了一个 [任务B],且核心2个线程还没有跑完,OK,接下来首先尝试 1 中描述,但是由于使用的 SynchronousQueue,所以一定无法加入进去。
    3. 此时便满足了上面提到的 “如果无法将请求加入队列,则创建新的线程,除非创建此线程超出 maximumPoolSize,在这种情况下,任务将被拒绝。”,所以必然会新建一个线程来运行这个任务。
    4. 暂时还可以,但是如果这三个任务都还没完成,连续来了两个任务,第一个添加入 queue 中,后一个呢?queue中无法插入,而线程数达到了maximumPoolSize,所以只好执行异常策略了。

      所以在使用 SynchronousQueue 通常要求 maximumPoolSize 是无界的,这样就可以避免上述情况发生(如果希望限制就直接使用有界队列)

      对于使用 SynchronousQueue 的作用jdk中写的很清楚:此策略可以避免在处理可能具有内部依赖性的请求集时出现锁。

      什么意思?

      如果你的 [任务A1] 和 [任务A2] 有内部关联,[任务A1] 需要先运行,那么先提交[任务A1],再提交 [任务A2],当使用 SynchronousQueue 我们可以保证,[任务A1] 必定先被执行,在 [任务A1] 没有被执行前,[任务A2] 不可能添加入 queue 中。

    二、使用无界队列策略,即LinkedBlockingQueue

      这个就拿 newFixedThreadPool 来说,根据上一篇 线程池专题 中提到的规则:

      如果运行的线程数 < corePoolSize,则 Executor 始终首选添加新的线程,而不进行排队。那么当任务继续增加,会发生什么呢?

      如果运行的线程数 >= corePoolSize,则 Executor 始终首选将请求加入队列,而不添加新的线程。OK,此时任务变加入队列之中了,那什么时候才会添加新线程呢?

      如果无法将请求加入队列,则创建新的线程,除非创建此线程超出 maximumPoolSize,在这种情况下,任务将被拒绝。

      这里就很有意思了,可能会出现无法加入队列吗?

      不像 SynchronousQueue 那样有其自身的特点,对于无界队列来说,总是可以加入的(资源耗尽,当然另当别论)。换句说,永远也不会触发产生新的线程!

      corePoolSize 大小的线程数会一直运行,跑完当前的,就从队列中拿任务开始运行。所以要防止任务疯长,比如任务运行的时间比较长,而添加任务的速度远远超过处理任务的时间,而且还不断增加,不一会儿就爆了。

    三、有界队列,使用ArrayBlockingQueue

      这个是最为复杂的使用,所以JDK不推荐使用也有些道理。与上面的相比,最大的特点便是可以防止资源耗尽的情况发生。

      举例来说,请看如下构造方法:

    new ThreadPoolExecutor(   
              2, 3, 30, TimeUnit.SECONDS,    
              new ArrayBlockingQueue<Runnable>(2),    
              new RecorderThreadFactory("recorderPoolTest"),    
              new ThreadPoolExecutor.CallerRunsPolicy()
    ); 

      假设,所有的任务都永远无法执行完。

      对于首先来的 [任务A] 和 [任务B] 来说直接运行,接下来,如果来了 [任务C] 和 [任务D],他们会被放到 queue 中,如果接下来再来 [任务E] 和 [任务F],则增加线程运行 [任务E] 和 [任务F]。但是如果再来任务,队列无法再接受了,线程数也到达最大的限制了,所以就会使用拒绝策略来处理。

    keepAliveTime

      jdk中的解释是:当线程数 > 核心指定数量时,keepAliveTime 为终止前多余的空闲线程等待新任务的最长时间。

      有点拗口,其实这个不难理解,在使用了“池”的应用中,大多都有类似的参数需要配置。比如数据库连接池,DBCP中的maxIdle,minIdle参数。

      什么意思?

      比如:老板派来的工人是我们苦口婆心“借来的”,俗话说“有借就有还”,但这里的问题就是什么时候还了,如果借来的工人刚完成一个任务就还回去,后来发现任务还有,那岂不是又要去借?这一来一往,老板肯定头也大死了。

      合理的策略:既然借了,那就多借一会儿。直到“某一段”时间后,发现再也用不到这些工人时,便可以还回去了。这里的某一段时间便是 keepAliveTime 的含义,TimeUnit 为 keepAliveTime 值的度量。

      另一种情况便是,即使向老板借了工人,但是任务还是继续过来,还是忙不过来,这时整个队伍只好拒绝接受了。这里就涉及到拒绝策略即 RejectedExecutionHandler,详情可以参考上一篇 线程池专题,这里就不做多余的阐述了。

  • 相关阅读:
    21 Python 3 GUI Programming (Tkinter)
    Python 3 Mock Test III
    Sring Boot 使用Spring Initializr创建项目(IDEA 2021)
    小学数学奥数题
    22 Python 3 Turtle
    Python 3 Questions
    Python 爬虫入门
    Python 3 Mock Test II
    Spring 一个简单的Spring程序
    14 Python 3 Sets
  • 原文地址:https://www.cnblogs.com/liang1101/p/12791099.html
Copyright © 2020-2023  润新知