知根知底线程池

132 阅读6分钟

1.线程池到底可以做啥

我们在学习并发的时候,总是绕不开一个概念——线程池,其实这个东西非常容易理解,举个例子,这个线程池就像一个人力资源市场,它维护了一堆能干各种事情的短期工人,这些工人就类似线程。一旦外部有需求,那么会请求人力资源市场,工人接收到请求以后就会去分析并处理请求。一旦问题解决了,工人又回到人力资源市场等待下一个任务的到来。

2.为什么需要线程池呢

我们应该听过很多池,比如数据库连接池就是一个典型。而这些用到池的,其实都是因为资源的创建和销毁过程太耗费性能了,为了不去频繁创建,那么就需要使用这样一种技术来维护这些大家伙们。

其实,这个思路我们平时也可以学,如果我们要维护一个创建成本过高的对象,然而这个对象还频繁被创建使用,那么我们就可以创建一个池来维护他们。

3.理念总需要实现——JDK对线程池的支持——UML图分析

image.png

以上成员均在java.util.concurrent包中,是JDK并发包的核心类。其中,ThreadPoolExecutor表示一个线程池。Executors类则扮演着线程池工厂的角色,通过Executors可以取得一个拥有特定功能的线程池。从UML图中亦可知,ThreadPoolExecutor类实现了Executor接口,因此通过这个接口,任何Runnable的对象都可以被ThreadPoolExecutor线程池调度。

3.1 jdk线程池入口——Executors

有了上述UML帮助我们认识jdk对线程池的支持,那我们抛开原理的赘述,现在看看整个线程池的入口部分:Executors。这是一个静态工厂,通过使用不同方法产生不同线程池。

  • newFixedThreadPool()方法:该方法返回一个固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理任务队列中的任务。

  • newSingleThreadExecutor()方法:该方法返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。

  • newCachedThreadPool()方法:该方法返回一个可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。

  • newSingleThreadScheduledExecutor()方法:该方法返回一个ScheduledExecutorService对象,线程池大小为1。ScheduledExecutorService接口在ExecutorService接口之上扩展了在给定时间执行某任务的功能,如在某个固定的延时之后执行,或者周期性执行某个任务。

  • newScheduledThreadPool()方法:该方法也返回一个ScheduledExecutorService对象,但该线程池可以指定线程数量。

3.2 使用案例

这里我们用最简单的固定数量的线程池来作为我们的案例

public class demo{
  public static class MyTask implements Runnable{
    @Override
    public void run(){
      System.out.println(System.currentTimeMills()+ ":Thread ID:"+Thread.currentThread().getId());
    }
  }
  public static void main(String[] args){
    MyTast tast = new MyTask();
    ExecutorService es = Executors.newFixedThreadPool(5);
    for(int i=0; i < 10; i++){
      // 提交两次任务
      es.submit(task)
    }
  }
}

3.3 线程池内部实现

上面介绍了很多线程池,但是,他们其实内部都是提供了一个类——ThreadPoolExecutor,我们来看看这个类的构造方法

image.png

  • corePoolSize:指定了线程池中的线程数量
  • maximumPoolSize:指定了线程池中的最大线程数量
  • keepAliveTime:当线程池线程数量超过corePoolSize时,多余的空闲线程的存活时间,即超过corePoolSize的空闲线程,在多长时间内会被销毁
  • unit:keepAliveTime的单位
  • workQueue:任务队列,被提交但尚未被执行的任务。
  • threadFactory:线程工厂,用于创建线程,一般用默认的即可
  • handler:拒绝策略。当任务太多来不及处理时,如何拒绝任务

这里有几个参数需要详细的说明一下。

  1. corePoolSize & maximumPoolSize

第一个参数是核心线程池大小,这个是线程池初始的时候创建的线程数,而最大线程数是表示在核心线程用完以后可以扩容的最大容量。换句话说,最大线程数就是一个极限值,表示在核心线程用完以后,可以通过创建新线程的方式继续执行任务,直到创建的数量达到这个maximumPoolSize。

  1. workQueue

表示任务队列,这个是在执行任务数量已经超过最大线程数(maximumPoolSize)以后,就会将这些Runnable对象放进这个队列中。我们可以看到这个队列的接口是BlockingQueue,下面介绍几个实现。

a. 直接提交的队列:该功能由SynchronousQueue对象提供。SynchronousQueue是一个特殊的BlockingQueue。SynchronousQueue没有容量,每一个插入操作都要等待一个相应的删除操作,反之,每一个删除操作都要等待对应的插入操作。如果使用SynchronousQueue,则提交的任务不会被真实地保存,而总是将新任务提交给线程执行,如果没有空闲的进程,则尝试创建新的进程,如果进程数量已经达到最大值,则执行拒绝策略。因此,使用SynchronousQueue队列,通常要设置很大的maximumPoolSize值,否则很容易执行拒绝策略。

b. 有界的任务队列:有界的任务队列可以使用ArrayBlockingQueue类实现。ArrayBlockingQueue类的构造函数必须带一个容量参数,表示该队列的最大容量: image.png 当使用有界的任务队列时,若有新的任务需要执行,如果线程池的实际线程数小于corePoolSize,则会优先创建新的线程,若大于corePoolSize,则会将新任务加入等待队列。若等待队列已满,无法加入,则在总线程数不大于maximumPoolSize的前提下,创建新的进程执行任务。若大于maximumPoolSize,则执行拒绝策略。可见,有界队列仅当在任务队列装满时,才可能将线程数提升到corePoolSize以上,换言之,除非系统非常繁忙,否则要确保核心线程数维持在corePoolSize。

c. 无界的任务队列:无界任务队列可以通过LinkedBlockingQueue类实现。与有界队列相比,除非系统资源耗尽,否则无界的任务队列不存在任务入队失败的情况。当有新的任务到来,系统的线程数小于corePoolSize时,线程池会生成新的线程执行任务,但当系统的线程数达到corePoolSize后,就不会继续增加了。若后续仍有新的任务加入,而又没有空闲的线程资源,则任务直接进入队列等待。若任务创建和处理的速度差异很大,无界队列会保持快速增长,直到耗尽系统内存。

d. 优先任务队列:优先任务队列是带有执行优先级的队列。它通过PriorityBlockingQueue类实现,可以控制任务的执行先后顺序。它是一个特殊的无界队列。无论是有界队列ArrayBlockingQueue类,还是未指定大小的无界队列LinkedBlockingQueue类都是按照先进先出算法处理任务的。而PriorityBlockingQueue类则可以根据任务自身的优先级顺序先后执行,在确保系统性能的同时,也能有很好的质量保证(总是确保高优先级的任务先执行)

下面是执行的顺序

image.png

  1. handler : 负载以后的拒绝策略

ThreadPoolExecutor类的最后一个参数指定了拒绝策略。也就是当任务数量超过系统实际承载能力时,就要用到拒绝策略了。拒绝策略可以说是系统超负荷运行时的补救措施,通常由于压力太大而引起的,也就是线程池中的线程已经用完了,无法继续为新任务服务,同时,等待队列中也已经排满了,再也放不下新任务了。这时,我们就需要有一套机制合理地处理这个问题。

jdk内置的拒绝策略

image.png

  • AbortPolicy策略:该策略会直接抛出异常,阻止系统正常工作。

  • CallerRunsPolicy策略:只要线程池未关闭,该策略直接在调用者线程中,运行当前被丢弃的任务。显然这样做不会真的丢弃任务,但是,任务提交线程的性能极有可能会急剧下降。

  • DiscardOldestPolicy策略:该策略将丢弃最老的一个请求,也就是即将被执行的一个任务,并尝试再次提交当前任务。

  • DiscardPolicy策略:该策略默默地丢弃无法处理的任务,不予任何处理。如果允许任务丢失,我觉得这可能是最好的一种方案了吧!

  1. 自定义线程创建:ThreadFactory

其中有newThread方法,我们可以自己实现该方法,通过该方法可以改造我们在线程池中的线程

image.png

3.4 拓展线程池的接口

虽然JDK已经帮我们实现了这个稳定的高性能线程池,但如果我们需要对这个线程池做一些扩展,比如,监控每个任务执行的开始时间和结束时间,或者其他一些自定义的增强功能,这时候应该怎么办呢?

:ThreadPoolExecutor是一个可以扩展的线程池。它提供了beforeExecute()、afterExecute()和terminated()三个接口用来对线程池进行控制。通过实现他们,就可以拓展该线程。

image.png

通过实现这些方法,我们可以在线程执行的时候执行这些方法

4 优化线程池线程数量

线程池的大小对系统的性能有一定的影响。过大或者过小的线程数量都无法发挥最优的系统性能,但是线程池大小的确定也不需要做得非常精确,因为只要避免极大和极小两种情况,线程池的大小对系统的性能并不会影响太大。一般来说,确定线程池的大小需要考虑CPU数量、内存大小等因素。在Java Concurrency inPractice一书中给出了估算线程池大小的公式:

Ncpu表示cpu的数量 Ucpu表示cpu使用率 W/C表示等待时间与计算时间的比率 Nthread = Ncpu × Ucpu × (1+W/C)

为保持处理器达到期望的使用率,最优的线程池的大小等于:在Java中,可以通过如下代码取得可用的CPU数量:

image.png

5. 分治思想:使用Fork/Join

“分而治之”一直是一个非常有效地处理大量数据的方法。著名的MapReduce也是采取了分而治之的思想。简单地说,就是如果你要处理1000个数据,但是你并不具备处理1000个数据的能力,那么你可以只处理其中的10个,然后分阶段处理100次,将100次的结果进行合成,就是最终想要的对原始1000个数据的处理结果。

在实际使用中,如果毫无顾忌地使用fork()方法开启线程进行处理,那么很有可能导致系统开启过多的线程而严重影响性能。所以,在JDK中,给出了一个ForkJoinPool线程池,对于fork()方法并不急着开启线程,而是提交给ForkJoinPool线程池进行处理,以节省系统资源。使用Fork/Join框架进行数据处理时的总体结构如图所示。

image.png

由于线程池的优化,提交的任务和线程数量并不是一对一的关系。在绝大多数情况下,一个物理线程实际上是需要处理多个逻辑任务的。因此,每个线程必然需要拥有一个任务队列。因此,在实际执行过程中,可能遇到这么一种情况:线程A已经把自己的任务都执行完了,而线程B还有一堆任务等着处理,此时,线程A就会“帮助”线程B,从线程B的任务队列中拿一个任务过来处理,尽可能地达到平衡。下图显示了这种互相帮助的精神。一个值得注意的地方是,当一个线程试图“帮助”其他线程时,总是从任务队列的底部开始获取数据,而线程试图执行自己的任务时,则是从相反的顶部开始获取数据。因此这种行为也十分有利于避免数据竞争。

image.png

5.1 如何使用ForkJoinPool

首先,看一下该线程池的submit方法

image.png

这时,我们可以看到,这里提交的内容不再是一个Runnable接口,而是ForkJoinTask接口,那么顺着这个思路,我们继续看看这个接口是什么

ForkJoinTask任务有两个重要的子类,RecursiveAction类和RecursiveTask类。它们分别表示没有返回值的任务和可以携带返回值的任务

image.png

6. Guava中对线程池的扩展

文章参考:Guava中文文档