Java面试经常会问到的线程池,你搞清楚了吗?

版权声明:本文为博主原创文章,遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://junmoxiao.blog.csdn.net/article/details/98839996

为什么要用线程池?

Java中的线程池是运用场景最多的并发框架,几乎所有需要异步或并发执行任务的程序都可以使用线程池。在开发过程中,合理地使用线程池能够带来3个好处。

  1. 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  2. 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。假设一个服务器完成一项任务所需时间为:T1 创建线程时间,T2 在线程中执行任务的时间,T3 销毁线程时间。 如果:T1 + T3 远大于 T2,则可以采用线程池,以提高服务器性能。线程池技术正是关注如何缩短或调整T1,T3时间的技术,从而提高服务器程序性能的。它把T1,T3分别安排在服务器程序的启动和结束的时间段或者一些空闲的时间段,这样在服务器程序处理客户请求时,不会有T1,T3的开销了。
  3. 提高线程的可管理性。线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控。

点击这里查看自己实现的一个简单线程池。

Executor继承关系

在这里插入图片描述

构造函数各个参数含义

在这里插入图片描述
在这里插入图片描述

int corePoolSize

线程池中的核心线程数,当提交一个任务时,线程池创建一个新线程执行任务,直到当前线程数等于corePoolSize;
如果当前线程数为corePoolSize,继续提交的任务被保存到阻塞队列中,等待被执行;
如果执行了线程池的prestartAllCoreThreads()方法,线程池会提前创建并启动所有核心线程。

int maximumPoolSize

线程池中允许的最大线程数。如果当前阻塞队列满了,且继续提交任务,则创建新的线程执行任务,前提是当前线程数小于maximumPoolSize

long keepAliveTime

线程空闲时的存活时间,即当线程没有任务执行时,继续存活的时间。默认情况下,该参数只在线程数大于corePoolSize时才有用,且只对corePool以外的线程有用。

TimeUnit unit

keepAliveTime的时间单位

BlockingQueue< Runnable > workQueue

用于保存等待执行的任务的阻塞队列。当线程池中的线程数超过它的corePoolSize的时候,线程会进入阻塞队列进行阻塞等待。一般来说,我们应该尽量使用有界队列,因为使用无界队列作为工作队列会对线程池带来如下影响。

  1. 当线程池中的线程数达到corePoolSize后,新任务将在无界队列中等待,因此线程池中的线程数不会超过corePoolSize。
  2. 由于1,使用无界队列时maximumPoolSize将是一个无效参数。
  3. 由于1和2,使用无界队列时keepAliveTime将是一个无效参数。
  4. 更重要的,使用无界queue可能会耗尽系统资源,有界队列则有助于防止资源耗尽,同时即使使用有界队列,也要尽量控制队列的大小在一个合适的范围。所以我们一般会使用,ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue、PriorityBlockingQueue。

ThreadFactory threadFactory

创建线程的工厂,通过自定义的线程工厂可以给每个新建的线程设置一个具有识别度的线程名,当然还可以更加自由的对线程做更多的设置,比如设置所有的线程为守护线程。
Executors静态工厂里默认的threadFactory,线程的命名规则是“pool-数字-thread-数字”。

RejectedExecutionHandler

线程池的饱和策略,当阻塞队列满了,且没有空闲的工作线程,如果继续提交任务,必须采取一种策略处理该任务,线程池提供了4种策略:

  1. AbortPolicy:直接抛出异常,默认策略。
  2. CallerRunsPolicy:用调用者所在的线程来执行任务。
  3. DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务。
  4. DiscardPolicy:直接丢弃任务。

当然也可以根据应用场景实现RejectedExecutionHandler接口,自定义饱和策略,如记录日志或持久化存储不能处理的任务。

线程池的工作机制

  • 如果当前运行的线程少于corePoolSize,则创建新线程来执行任务(注意,执行这一步骤需要获取全局锁)。
  • 如果运行的线程等于或多于corePoolSize,则将任务加入BlockingQueue。
  • 如果无法将任务加入BlockingQueue(队列已满),则创建新的线程来处理任务。
  • 如果创建新线程将使当前运行的线程超出maximumPoolSize,任务将被拒绝,并调用RejectedExecutionHandler.rejectedExecution()方法。

扩展线程池

能扩展线程池的功能吗?比如在任务执行的前后做一点我们自己的业务工作?实际上,JDK 的线程池已经为我们预留的接口,在线程池核心方法中,有3 个方法是空的,就是给我们预留的。

  • beforeExecute():在每个任务执行前执行。
  • afterExecute():在每个任务执行后执行。
  • terminated():在线程池关闭后执行。
    点击这里查看示例。

提交任务

在这里插入图片描述
Executor类只有一个方法:
在这里插入图片描述
ExecutorService类在父类的基础上增加了submit方法:
在这里插入图片描述
execute方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功。
submit方法用于提交需要返回值的任务。线程池会返回一个Future类型的对象,通过这个Future对象可以判断任务是否执行成功,并且可以通过Future的get()方法来获取返回值,get()方法会阻塞当前线程直到任务完成,而使用get(long timeout,TimeUnit unit)方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。

关闭线程池

可以通过调用线程池的shutdown()或shutdownNow()方法来关闭线程池。它们的原理是遍历线程池中的工作线程,然后逐个调用线程的interrupt方法来中断线程,所以无法响应中断的任务可能永远无法终止。但是它们存在一定的区别:

  • shutdown()只是将线程池的状态设置成SHUTDOWN状态,然后中断所有没有正在执行任务的线程。
  • shutdownNow()首先将线程池的状态设置成STOP,然后尝试停止所有的正在执行或暂停任务的线程,并返回等待执行任务的列表。
    在这里插入图片描述
    只要调用了这两个关闭方法中的任意一个,isShutdown()方法就会返回true。当所有的任务都已关闭后,才表示线程池关闭成功,这时调用isTerminaed()方法会返回true。至于应该调用哪一种方法来关闭线程池,应该由提交到线程池的任务特性决定,通常调用shutdown()方法来关闭线程池,如果任务不一定要执行完,则可以调用shutdownNow()方法。

合理地配置线程池

要想合理地配置线程池,就必须首先分析任务特性,可以从以下几个角度来分析。

  • 任务的性质:CPU密集型任务、IO密集型任务和混合型任务。
  • 任务的优先级:高、中和低。
  • 任务的执行时间:长、中和短。
  • 任务的依赖性:是否依赖其他系统资源,如数据库连接。

性质不同的任务可以用不同规模的线程池分开处理:

  • CPU密集型:任务应配置尽可能小的线程,如配置Ncpu+1个线程的线程池,避免频繁地上下文切换。
    +1 的作用:对于页缺失的线程(数据在硬盘上),要等待操作系统将数据从硬盘加载到内存(这时cpu被空出来了),利用这段时间可以执行另一个线程。

  • IO密集型:任务线程并不是一直在执行任务(阻塞),则应配置尽可能多的线程,如2*Ncpu。
    此外,有Doug Lea提供的经验公式Nthreads = NCPU * UCPU * (1 + W/C)
    其中:
    NCPU是处理器的核的数目
    UCPU是期望的CPU利用率(该值应该介于0和1之间),一般是0.9
    W/C是等待时间与计算时间的比率
    等待时间与计算时间我们在Linux下使用相关的vmstat命令或者top命令查看。

  • 混合型的任务:如果可以拆分,将其拆分成一个CPU密集型任务和一个IO密集型任务,只要这两个任务执行的时间相差不是太大,那么分解后执行的吞吐量将高于串行执行的吞吐量。如果这两个任务执行时间相差太大,则没必要进行分解。

  • 优先级不同的任务可以使用优先级队列PriorityBlockingQueue来处理。它可以让优先级高的任务先执行。

  • 执行时间不同的任务可以交给不同规模的线程池来处理,或者可以使用优先级队列,让执行时间短的任务先执行。

  • 依赖数据库连接池的任务,因为线程提交SQL后需要等待数据库返回结果,等待的时间越长,则CPU空闲时间就越长,那么线程数应该设置得越大,这样才能更好地利用CPU。

建议使用有界队列。有界队列能增加系统的稳定性和预警能力,可以根据需要设大一点儿,比如几千。

ScheduledThreadPoolExecutor

ScheduledThreadPoolExecutor是一个使用线程池执行定时任务的类。
与Timer类比较如下:
在这里插入图片描述

提交定时任务

public ScheduledFuture<?> schedule(Runnable command, long delay, TimeUnit unit)
向定时任务线程池提交一个延时Runnable任务(仅执行一次)
public < V > ScheduledFuture< V > schedule(Callable< V > callable, long delay, TimeUnit unit);
向定时任务线程池提交一个延时的Callable任务(仅执行一次)
public ScheduledFuture<?> scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit)
向定时任务线程池提交一个固定时间间隔执行的任务
public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit)
向定时任务线程池提交一个固定延时间隔执行的任务

固定时间间隔的任务不论每次任务花费多少时间,下次任务开始执行时间从理论上讲是确定的,当然执行任务的时间不能超过执行周期。
固定延时间隔的任务是指每次执行完任务以后都延时一个固定的时间。由于操作系统调度以及每次任务执行的语句可能不同,所以每次任务执行所花费的时间是不确定的,也就导致了每次任务的执行周期存在一定的波动。

定时任务超时问题

scheduleAtFixedRate中,若任务处理时长超出设置的定时频率时长,本次任务执行完才开始下次任务,下次任务已经处于超时状态,会马上开始执行。
若任务处理时长小于定时频率时长,任务执行完后,定时器等待,下次任务会在定时器等待频率时长后执行。
如下例子:
设置定时任务每60s执行一次,那么从理论上应该第一次任务在第0s开始,第二次任务在第60s开始,第三次任务在120s开始,但实际运行时第一次任务时长80s,第二次任务时长30s,第三次任务时长50s,则实际运行结果为:
第一次任务第0s开始,第80s结束;
第二次任务第80s开始,第110s结束(上次任务已超时,本次不会再等待60s,会马上开始);
第三次任务第120s开始,第170s结束.
第四次任务第180s开始…

异常处理

推荐在Runnable.run()里用try-catch处理异常。
点击这里查看示例。

Executors(预定义线程池)

无继承关系,通过调用静态方法返回不同的ExecutorService。

newFixedThreadPool

在这里插入图片描述
适用于为了满足资源管理的需求,而需要限制当前线程数量的应用场景,它适用于负载比较重的服务器。

  • corePoolSize和maximumPoolSize都被设置为创建FixedThreadPool时指定的参数nThreads。
  • keepAliveTime设置为0L,意味着多余的空闲线程会被立即终止。
  • FixedThreadPool使用有界队列LinkedBlockingQueue作为线程池的工作队列,队列的容量为Integer.MAX_VALUE(太大,很有可能把内存和CPU撑爆)。

newSingleThreadExecutor

在这里插入图片描述
适用于于需要保证顺序地执行各个任务,并且在任意时间点,不会有多个线程是活动的应用场景。

  • corePoolSize和maximumPoolSize被设置为1。
  • 其他参数与FixedThreadPool相同。

newCachedThreadPool

在这里插入图片描述
适用于执行很多的短期异步任务的小程序,或者是负载较轻的服务器。

  • corePoolSize被设置为0,即corePool为空
  • maximumPoolSize被设置为Integer.MAX_VALUE。
  • keepAliveTime设置为60L,意味着CachedThreadPool中的空闲线程等待新任务的最长时间为60秒,空闲线程超过60秒后将会被终止。
  • CachedThreadPool使用没有容量的SynchronousQueue作为线程池的工作队列。如果主线程提交任务的速度高于maximumPool中线程处理任务的速度时,CachedThreadPool会不断创建新线程。极端情况下,会因为创建过多线程而耗尽CPU和内存资源。

newWorkStealingPool

在这里插入图片描述
利用所有运行的处理器数目来创建一个工作窃取的线程池,使用forkjoin实现。

newScheduledThreadPool

在这里插入图片描述
适用于需要多个后台线程执行周期任务,同时为了满足资源管理的需求而需要限制后台线程的数量的应用场景。
实际上是使用了ScheduledThreadPoolExecutor类。

newSingleThreadScheduledExecutor

在这里插入图片描述
适用于需要单个后台线程执行周期任务,同时需要保证顺序地执行各个任务的应用场景。
实际上是使用了ScheduledThreadPoolExecutor类。

CompletionService

CompletionService对ExecutorService进行了包装,内部维护一个保存Future对象的BlockingQueue。只有当这个Future对象状态是结束的时候,才会加入到这个Queue中,take()方法其实就是Producer-Consumer中的Consumer。它会从Queue中取出Future对象,如果Queue是空的,就会阻塞在那里,直到有完成的Future对象加入到Queue中。所以,先完成的必定先被取出。这样就减少了不必要的等待时间。
在这里插入图片描述
当我想要拿到线程池的返回结果时有2个办法:

  1. 自己创建一个阻塞队列来保存Future< V >,获取时循环调用队列的take()方法。主线程并不能保证首先获得的是最先完成任务的线程返回值。它只是按加入线程池的顺序返回。因为take方法是阻塞方法,后面的任务完成了,前面的任务却没有完成,主程序就那样等待在那儿,只到前面的完成了,它才知道原来后面的也完成了。
  2. 使用CompletionService< V >来BlockingQueue<Future< V >>,主线程总是能够拿到最先完成的任务的返回值,而不管它们加入线程池的顺序。

点击这里查看示例。
在这里插入图片描述
CompletionService实际上可以看做是Executor和BlockingQueue的结合体。CompletionService在接收到要执行的任务时,通过类似BlockingQueue的take获得任务执行的结果。

CompletionService的一个实现是ExecutorCompletionService,ExecutorCompletionService把具体的计算任务交给Executor完成。
在这里插入图片描述
在实现上,ExecutorCompletionService(Executor)中的阻塞队列是LinkedBlockingQueue。

当提交一个任务到ExecutorCompletionService时,首先将任务包装成QueueingFuture,它是FutureTask的一个子类,然后改写FutureTask的done方法,之后把Executor执行的计算结果放入BlockingQueue中。

与ExecutorService最主要的区别在于submit的task不一定是按照加入时的顺序完成的。

参考:Mark—笔记_Java并发编程

展开阅读全文

没有更多推荐了,返回首页