线程池全整理(附面试题)

本文总结一下线程池是怎么回事,分以下几个部分,对哪个部分感兴趣,可以直接跳到对应的章节

  第一部分:线程池类的结构介绍

  第二部分:线程池的使用

  第三部分:线程池的创建流程

  第四部分:线程池的应用场景

  第五部分:线程池相关的面试题

  第六部分:线程池几个实际场景的分析

第一部分:线程池类的结构

线程池存在于Java的并发包J.U.C中,线程池可以根据项目灵活控制并发的数目,避免频繁的创建和销毁线程,达到线程对象的重用。

下面是线程池的类图:

1、 接口Executor

接口Executor中,只有一个方法,为execute()

2、 接口ExecutorService,继承自Executor

几个重要的方法:

  (1) 关闭线程池的方法,有两种

      一个ExecutorService(J.U.C)可以关闭,这将导致它拒绝新的任务。 ExecutorService的两种关闭线程池的方式,shutdown和shutdownNow方法:

  ① shutdown():拒收新的任务,立马关闭正在执行的任务,可能会引起报错,需要异常捕获

  ② shutdownNow():拒收新的任务,等待任务执行完毕,要确保任务里不会有永久等待阻塞的逻辑,否则会导致线程关闭不了

  ③ 不是马上关闭,要想等待线程池关闭,还需要调用waitFermination来阻塞等待

  ④ 还有一些业务场景下,需要知道线程池中的任务是否全部执行完成,当我们关闭线程池之后,可以用isTerminated来判断所有的线程是否执行完成,千万不要用isShutdown,  它只是返回你是否调用过shutdown的结果

  (2) submit()方法

     方法submit延伸的方法Executor.execute(Runnable)通过创建并返回一个Future可用于取消执行和/或等待完成。submit()与execute()的一个区别是submit()有返回值,并且能够处理异常

3、 Executors(J.U.C),提供了6个静态方法,分别创建6种不同的线程池,六大静态方法 内部都是直接或间接调用ThreadPoolExecutor类的构造方法创建线程池对象,这六个静态方法本身是没有技术含量的。

  

 

 

 

Executors()

Executors静态方法

实现类

newCachedThreadPool

ThreadPoolExecutor

newFixedThreadPool

ThreadPoolExecutor

newSingleThreadExecutor

ThreadPoolExecutor

newScheduledThreadPool

ScheduledThreadPoolExecutor

newSingleThreadScheduledExecutor

ScheduledThreadPoolExecutor

 

newWorkStealingPool

ForkJoinPool

Executor(接口):只有一个方法execute()

下面介绍常用的四种:

  (1)FixedThreadPool

  FixedThreadPool的特点固定池子中线程的个数。使用静态方法newFixedThreadPool()创建线程池的时候指定线程池个数。

  (2)CachedThreadPool(弹性缓存线程池)

  CachedThreadPool的特点:用newCachedThreadPool()方法创建该线程池对象, 创建之初里面一个线程都没有,当execute方法或submit方法向线程池提交任务时, 会自动新建  线程;如果线程池中有空余线程,则不会新建;这种线程池一般最多情况可 以容纳几万个线程,里面的线程空余60s会被回收。

  (3)SingleThreadPool(单线程线程池)

  SingleThreadPool的特点:池中只有一个线程,如果扔5个任务进来,那么有4个任务将排队;作用是保证任务的顺序执行。

  (4)ScheduledThreadpool(定时器线程池)

  注意:要用ScheduledExecutorService去创建ScheduledThreadpool,如果用Executor去引用,就只能调用Executor接口中定义的方法;如果用ExecutorService接  口去引用,就只能调用ExecutorService接口中定义的方法,无法使用ScheduledExecutorService接口中新增的方法,那么也就失去了这种线程池的意义

第二部分:线程池的使用

第一种方式,构建一个线程池

ExecutorService threadPool = Executors.newFixedThreadPool(10);

第二种方式,使用ThreadPoolExecutor构建一个线程池

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class test {
public static void main(String args[]) {
ExecutorService executorService = new ThreadPoolExecutor(5,10,
10,TimeUnit.SECONDS,new ArrayBlockingQueue<Runnable>(5));
executorService.execute(new Runnable() {
@Override
public void run() {
System.out.println("开始执行线程池中的任务");
}
});
}
}

如果只是简单的想要改变线程名称的前缀的话可以自定义ThreadFactory来实现,在Executors.new…中有一个ThreadFactory的参数,如果没有指定则用的是DefaultThreadFactory。

第三种方式,使用工具来创建线程池,Apache的guava中ThreadFactoryBuilder()来创建线程池,不仅可以避免OOM问题,还可以自定义线程名称,方便出错时溯源

第三部分:线程池的流程梳理

1、线程池的参数

  (1) corePoolSize:核心线程数的大小

  (2) maximumPoolSize:最大线程数的大小

  (3) keepAliveTime:线程的空闲时间

  (4) TimeUnit:空闲时间的单位

  (5) workQueue:阻塞队列

  (6) threadFactory:线程工厂

  (7) Handler:拒绝策略

参数的详细说明:

  (1) corePoolSize:池子里的线程数的大小,设置allowCoreThreadTimeOut(true)使核心线程数内的线程也可以被回收

  (2) maximumPoolSize:当池子里的线程数达到核心线程数的大小,队列也满了,可以继续创建的线程,直到线程数达到maximumPoolSize

  (3) keepAliveTime:线程的空闲时间,是跟核心线程数和最大线程数之间的线程相关,这部分线程,当到达规定的空闲时间还没有获取到任务,则会被回收

  (4) TimeUnit:空闲时间的单位

  (5) workQueue:默认支持4种阻塞队列

    ①ArrayBlockingQueue,基于数组的有界阻塞队列,按FIFO排序。新任务进来后,会放到该队列的队尾,有界的数组可以防止资源耗尽问题。

    ②LinkedBlockingQuene,基于链表的无界阻塞队列(其实最大容量为Interger.MAX),按照FIFO排序。由于该队列的近似无界性,当线程池中线程数量达到corePoolSize      后,再有新任务进来,会一直存入该队列,而不会去创建新线程直到maxPoolSize,因此使用该工作队列时,参数maxPoolSize其实是不起作用的。

    ③SynchronousQuene,一个不缓存任务的阻塞队列,生产者放入一个任务必须等到消费者取出这个任务。也就是说新任务进来时,不会缓存,而是直接被调度执行该任务,     如果没有可用线程,则创建新线程,如果线程数量达到maxPoolSize,则执行拒绝策略。

    ④PriorityBlockingQueue,具有优先级的无界阻塞队列,优先级通过参数Comparator实现。

  (6) threadFactory:线程工厂,用来创建一个新线程时使用的工厂,可以用来设定线程名,是否为daemon线程等

  (7) Handler:拒绝策略

    ①CallerRunsPolicy(直接拒绝任务),该策略下,在调用者线程中直接执行被拒绝任务的run方法,除非线程池已经shutdown,则直接抛弃任务。

    ②AbortPolicy(直接丢弃任务,并抛异常),该策略下,直接丢弃任务,并抛出RejectedExecutionException异常。

    ③DiscardPolicy(直接丢弃任务,什么都不做),该策略下,直接丢弃任务,什么都不做。

    ④DiscardOldestPolicy(尝试添加新任务),该策略下,抛弃进入队列最早的那个任务,然后尝试把这次拒绝的任务放入队列

2、线程池的运行过程

  (1) 刚开始运行时,线程池是空的

  (2) 一个任务进来,检查池中的线程数量,是否达到corePoolSize,如果没有达到,则创建线程,执行任务

  (3) 任务执行完成之后,线程不会销毁,而是阻塞的等待下一个任务

  (4) 又进来一个任务,不是直接使用阻塞的线程,而是检查线程池中的线程数大小,是否达到corePoolSize,如果没有达到,则继续创建新的线程,来执行新的任务,如此往复,  直到线程池中的线程数达到corePoolSize,此时停止创建新的线程

  (5) 此时,又来新的任务,会选择线程池中阻塞等待的线程来执行任务,有一个任务进来,唤醒一个线程来执行这个任务,处理完之后,再次阻塞,尝试在workQueue上获取下一  个任务,如果线程池中没有可唤醒的线程,则任务进入workQueue,排队等待

  (6) 如果队列是无界队列,比如LinkedBlockingQueue,默认最大容量为Integer.MAX,接近于无界,可用无限制的接收任务,如果队列是有界队列,比如ArrayBlockingQueue,可限定队列大小,当线程池中的线程来不及处理,然后,所有的任务都进入队列,队列的任务数也达到限定大小,此时,再来新的任务,就会入队失败,然后,就会再次尝试在线程池里创建线程,直到线程数达到maximumPoolSize,停止创建线程

  (7)此时,队列满了,新的任务无法入队,创建的线程数也达到了maximumPoolSize,无法再创建新的线程,此时,就会reject掉,使用拒绝策略RejectedExecutionHandler,不让继续提交任务,默认的是AbortPolicy策略,拒绝,并抛出异常

  (8) 超出corePoolSize数创建的那部分线程,是跟空闲时间keepAliveTime相关的,如果超过keepAliveTime时间还获取不到任务,线程会被销毁,自动释放掉

第四部分:线程池的应用场景

1、newSingleThreadExecutor:一个单线程的线程池,可以用于需要保证顺序执行的场景,并且只有一个线程在执行。

2、newFixedThreadPool:一个固定大小的线程池,可以用于已知并发压力的情况下,对线程数做限制。

3、newCachedThreadPool:一个可以无限扩大的线程池,比较适合处理执行时间比较小的任务。

4、newScheduledThreadPool:可以延时启动,定时启动的线程池,适用于需要多个后台线程执行周期任务的场景。

5、newWorkStealingPool:一个拥有多个任务队列的线程池,可以减少连接数,创建当前可用cpu数量的线程来并行执行。

 线程池的实际业务场景:线程池适合单系统的大量的异步任务处理,比如发送短信、保存日志。

第五部分:线程池相关的面试题

1、为什么使用线程池,线程池有什么作用?

  线程池,就是一个池子,存放指定数量的线程来执行任务,处理完任务的线程不进行回收,而是阻塞等待下一个任务,避免了频繁的创建和销毁线程,达到了线程的重用。

2、如何创建一个线程池?

  最常用的,使用ThreadPoolExecutor实现类来创建线程池

3、如何关闭一个线程池?

  ExecutorService提供了两种方法来关闭线程池,shutdown()和shutdownNow()

  (1) shutdown:拒收新的任务,立马关闭正在执行的任务,可能会引起报错,需要异常捕获

  (2) shutdownNow:拒收新的任务,等待任务执行完毕,要确保任务里不会有永久等待阻塞的逻辑,否则会导致线程关闭不了

 

  不是马上关闭,要想等待线程池关闭,还需要调用waitFermination来阻塞等待

     还有一些业务场景下,需要知道线程池中的任务是否全部执行完成,当我们关闭线程池之后,可以用isTerminated来判断所有的线程是否执行完成,千万不要用isShutdown,它只  是返回你是否调用过shutdown的结果

4、submit()和execute()方法的区别?

  execute()方法在Executor()接口中,且是接口中唯一的方法

  submit()方法在ExecutorService中,ExecutorService接口继承Executor 接口

  execute()方法,开启线程执行池中的任务

  submit()方法,也可以做到execute()的作用,它还可以返回执行结果,它的 功能是提交指定的任务去执行并且返回Future对象(即执行的结果)

  submit()和execute()的区别:

  (1) 接收的参数不一样

  (2) submit()方法有返回值Future,而execute()方法没有返回值

  (3) submit()方法方便处理Exception异常,意思就是,你在task里会抛出checked或者unchecked exception, 而又希望外面的调用者能够感知这些exception并作出及时的处理,  用 submit,通过捕获Future.get抛出的异常

5、为什么不建议使用Executors创建线程,而使用ThreadPoolExecutor实现类来创建线程?

  Executors中FixedThreadPool使用的是LinkedBlockingQueue队列,近乎于无界,队列大小默认为Integer.MAX_VALUE,几乎可以无限制的放任务到队列中,线程池中数量是固定的,当线程池中线程数量达到corePoolSize,不会再创建新的线程,所有任务都会入队到workQueue中,线程从workQueue中获取任务,但这个队列几乎永远不会满,只要队列不满,就不会再去创建新的线程,就跟maximumPoolSize和keepAliveTime没有关系,此时,如果线程池中的线程处理任务的时间特别长,导致无法处理新的任务,队列中的任务就会不断的积压,这个过程,会导致机器的内存使用不停的飙升,极端情况下会导致JVM OOM,系统就挂了。

  总结:Executors中FixedThreadPool指定使用无界队列LinkedBlockingQueue会导致内存溢出,所以,最好使用ThreadPoolExecutor自定义线程池

  换一种问法:线程池中,无界队列导致的内存飙升问题,同上

6、线程池如何调优

  (1)首先,根据不同的需求选择线程池,如果需要单线程顺序执行,使用SingleThreadExecutor,如果已知并发压力,使用FixedThreadPool,固定线程数的大小,执行时间小的任务,可以使用CachedThreadPool,创建可缓存的线程池,可以无限扩大线程池,可以灵活回收空闲线程,最多可容纳几万个线程,线程空余60s会被回收,需要后台执行周期任务的,可以使用ScheduledThreadPool,可以延时启动和定时启动线程池,

  (2)如何确认线程池的最大线程数目,CPU密集型和IO密集型,如果是CPU密集型或计算密集型,因为CPU的利用率高,核心线程数可设置为n(核数)+1,如果是IO密集型,CPU利用率不高,可多给几个线程数,来进行工作,核心线程数可设置为2n(核数)

第六部分:线程池几个实际场景的使用分析

线程池的使用分析:线程池,就是一个池子,存放大量创建好的线程,快速的执行任务,并且有队列可用存放待处理的任务,那么,我们处理批量任务的时候,需要执行大量的任务,同时我们也希望任务执行的越快越好,这时考虑使用多线程,而线程池帮我们封装好了池子,我们拿过来用就可用,避免了自己去创建回收等管理线程的动作,但是也要注意,线程池的一些参数,对实际的执行影响很大,所以,需要根据具体的场景,调整参数来获得较高的吞吐量,减少上下文切换,并且,线程池执行的情况和任务类型相关性较大,比如IO密集型和CPU密集型的任务运行起来的情况差异非常大,需要格外注意。

 线程池适合单系统的大量的异步任务处理,比如发送短信、保存日志等。

1、几个真实的场景中如何选择线程池?

    (1)  高并发、任务执行时间短,此类任务可用充分利用CPU,尽可能的减少上下 文切换,线程池的线程数可用设置为CPU核数+1

    (2)并发不高、任务执行时间长

      此种类型的任务分两种情况:

      ① IO密集型的任务,业务长时间集中在IO操作上,因为IO操作并不占用 CPU,所以尽可能的不要让所有的CPU闲下来,可用加大线程池中的线 程数目,让CPU处理更多的业务,如设置线程池的线程数为2 * CPU核 数

      ② 计算密集型的任务,业务长时间集中在计算操作上,和(1)一样,线程数 可设置为CPU核数+1,减少一下线程数,以便减少线程的上下文切换

    (3) 并发高、业务执行时间长,这种类型的任务就不单单要关注线程池了,而是要 从整体架构上来考虑,看能否使用中间件对任务进行拆分和解耦,部分数据做 缓存处理,以及增加服务器等

2、续上👆  线程池参数设置的一些分析  (参考:https://blog.csdn.net/qq_17045385/article/details/79820847)

    1) 几个参数:

      tasks:每秒的任务数,假设为500~1000

      taskcost:每个任务花费的时间,假设为0.1s

      responsetime:系统允许容忍的最大响应时间,假设为1s

    2) 做几个计算:

      ① corePoolSize:每秒需要多少个线程处理

      threadcount = tasks/(1/taskcount) = (500~1000)*0.1 = 50~100

      线程数应该设置为大于50个,根据8020原则,如果80%的每秒任务数 小于800,那么corePoolSize设置为80即可

      ② queueCapacity = (coreSizePool/taskcost)*responsetime = 80/0.1*1 = 80

      ③ 注意阻塞队列的大小,LinkedBlockingQueue的大小为

      Integer.MAX_VALUE,接近于无界,会导致内存溢出,因为当任务徒增 时,都会进入队列中,不能开新的线程来执行

      ④ maxPoolSize = (max(tasks) - queueCapacity)/(1/taskcount)=(最大 任务数-队列容量)/每个线程每秒处理能力 = 最大线程数

      计算可得,最大线程数maxPoolSize = (1000-80)/10 = 92

      ⑤ rejectedExecutionHandler:根据具体情况来决定,任务不重 要可丢弃,任务重要则要利用一些缓冲机制来处理

      ⑥ keepAliveTime和allowCoreThreadTimeout:采用默认通常 能满足

3、续上👆  几个具体场景的分析(8核CPU为例) (参考:https://www.jianshu.com/p/71b5e40f94e0)

    (1) 任务数多但资源占用不大,电商平台的消息推送或短信通知,该场景需要被处理的消息对象内容简单占用资源非常少,通常为百字节量级,但在高并发访问下,可能瞬间产生大量的任务数,而此类任务的处理通常效率非常高,因此处理的重点在于控制并发线程数,不要以为大量的线程启用及线程的上下文频繁切换而导致内存使用率过高,CPU的内核态使用率过高等不良情况发生,通常可以在创建线程池时设置较长的任务队列,并以CPU内核数2-4倍(经验值)的值设置核心线程与扩展线程数,合理固定的线程数使得CPU的使用率更加平滑,如:

      BlockingQueue queue = newArrayBlockingQueue<>(4096);

      ThreadPoolExecutor executor = newThreadPoolExecutor(16, 16, 0, TimeUnit.SECONDS, queue);

    (2) 任务数不多但资源占用大,非社交流媒体的使用场景下,该情况多发生于文件流、长文本对象或批量数据加工的处理,如日志收集、图片流压缩或批量订单处理等场景,而此类场景下的单个资源处理,往往会发生较大的资源消耗,因此为使系统达到较强处理能力,同时又可以控制任务资源对内存过大的使用,通常可以在创建线程池时适当加大扩展线程数量,同时设置相对较小的任务队列长度,如此,当遇到任务数突增的情况,可以有更多的并发线程来应对,此外需要合理设置扩展线程空闲回收的等待时长以节省不必要的开销,如:

      BlockingQueue queue = newArrayBlockingQueue<>(512);

      ThreadPoolExecutor executor =newThreadPoolExecutor(16, 64, 30, TimeUnit.SECONDS, queue);

    (3) 极端场景的情况,如遇到任务资源较大,任务数较多,同时处理效率不高的场景,首先需要考虑任务的产生发起需要限流,理论上讲为保障系统的可用性及稳定运行,任务的发起能力应当略小于任务的处理能力,其次,对于类似场景可以采用以时间换取空间的思想,充分利用系统计算资源,当遇到任务处理能力不足的情况,任务发起方的作业将被阻塞,从而充分保护系统的资源开销边界,但可能会导致CPU核心态的使用率高,如:

      BlockingQueue queue = newSynchronousQueue<>();

      ThreadPoolExecutor executor =newThreadPoolExecutor(64, 64, 0, TimeUnit.SECONDS, queue);

4、线程池中的参数,有corePoolSize和maximumPoolSize,假如队列能放下所有的任务,线程池里的线程数只会创建corePoolSize的线程,而Tomcat不同,它里面的线程池的运行过程就是先把最大线程数用完,然后再提交任务到队列里面去的。Tomcat中自带的线程池,其 maxThreads 默认值是 200(假定 BIO 模式),maxThreads 用完了之后,进队列。队列长度(acceptCount)默认是 100,在 BIO 的模式下,Tomcat 的默认配置,最多可以接受到 300 (200+100)个请求。再多就是连接拒绝,connection refused。

 有写的不足的地方,或者需要补充的地方,期待交流,会及时补充。希望听到意见和建议,互相学习,共同进步。

posted @ 2020-11-11 13:53  hll814  阅读(4894)  评论(1编辑  收藏  举报