Loading

Java并发——线程池的使用

本篇博文是Java并发编程实战的笔记。

之前一直在用Executor框架,也使用过一些ThreadPoolExecutor,但是一直没有深入了解过,本章作者讲解了线程池的更多用法和注意事项,以便我们写出更加健壮的并发程序。

隐含耦合

Executor的目标是将任务的提交和执行解耦,任务的提交者无需知道任务是在一个什么样的环境下被执行,但是很多情况下这种耦合并非真的解除了,反而,它们隐藏了起来,导致错误的位置更加隐蔽。

  1. 依赖性任务:如果你要执行的任务之间具有依赖关系,那么你可能需要让它们在特定的Executor下运行才能保证任务的活跃性。关于这一点有点抽象,后面会有类似的例子。
  2. 依赖于单线程环境的任务:如果你使用一个单线程的Executor执行一些需要访问共享对象的任务,那你完全不需要做任何同步,对于这种场景,你的任务和单线程Executor之间产生了隐式耦合,如果将任务放到其它Executor中,可能执行出现问题。
  3. 对时间敏感的任务:如果你将一个或多个运行时间较长的任务提交到只具有少量线程的线程池中,该线程池中的任务响应性可能会降低。
  4. 使用ThreadLocal的任务:使用了ThreadLocal的任务在线程池中运行可能会出问题,因为线程池会复用这些执行任务的线程。

只有当任务都是同类型(运行时间差不多)并且相互独立时,线程池的性能才能达到最佳。

通过上面的例子,我们知道有些任务就是需要特定的执行环境,当遇到这种情况时,请将你的需求写入文档以便将来的代码维护人员不至于陷入到莫名其妙的问题中。

上面举了一些特定的任务在不同的执行策略下可能会受到影响,下面来具体的看看这种影响是怎样产生的。

线程饥饿或死锁

为什么说当任务之间具有依赖的话可能会在特定的Executor下丧失或降低活跃性?

考虑任务A,它需要获得任务B运算过程中得到的一个结果,此时你有一个单线程的线程池,并且A在里面运行,等待B的结果,而B又在线程池外面等待A出来,这样产生了饥饿死锁。一般这种情况只能通过设置适当的线程池大小来规避,后面会讲到如何选择一个合适的线程池大小。

运行时间较长的任务

就像进程调度,一个较长的进程如果先被调度了,那么会导致后面短进程的处理延时变大。由于这些任务本来就比较短,所以一点点的时延所造成的影响往往比把这些时延添加到长任务上更大。这种问题在线程池中仍然存在,这也就是说如果线程池中执行的任务有大有小,那么小的那些任务的服务时间会变长,影响整体的响应性。

可以通过限定任务等待资源的时间来解决这个问题,如果一段时间内任务还是没有拿到资源,它会主动放弃,然后等待下一步操作。

设置线程池大小

设置线程池大小和线程池要执行的任务类型,需要的资源以及数量,系统中有的资源(CPU和内存大小)等有关。所以你看啊,其实虽然Executor框架在代码层面上确实解决了这种耦合,但是为保证任务稳定运行而与特定的执行策略间的隐式的耦合往往依然存在。

首先对于不同类型的差异很大的任务,最好使用不同的线程池

对于CPU密集型的任务,具有\(N_{cpu} + 1\)个线程的线程池往往能很好的工作,这多一个的线程是为了当某个线程由于缺页中断或者其它原因而不得不暂停时的备用,保证CPU处于忙的状态。

对于IO密集型或者经常需要其它阻塞操作的任务,需要权衡任务的等待时间与计算时间的比值来判断需要多少线程,可以通过估算或监控工具来获得该比值。

\[N_{cpu}=CPU个数\\ U_{cpu}=希望的目标CPU利用率,0 \leq U_{cpu} \leq 1 \\ \frac{W}{C}=等待时间W和计算时间C的比值 \]

那么线程池在你期望达到的CPU利用率下的最佳大小是:

\[N_{threads} = N_{cpu} \times U_{cpu} \times (1 + \frac{W}{C}) \]

除了这个公式,你还可以通过监控系统在设置不同线程池大小后的CPU利用率来获得一个不错的线程池大小。

其它资源对线程池大小的限制更好计算,比如有限的数据库连接,你需要用总共的资源数除以每个线程需要的资源数,就得到了线程池大小的上限。注意,就算你设置的比这个大,你的任务执行效率也会受到资源数量的牵制,比如受到数据库连接个数的牵制。

配置ThreadPoolExecutor

我的这篇文章中介绍了线程池核心参数的作用,以及它们是如何相互影响的,建议先去看一下这个,下面我把那文章中的一部分粘贴下来,以方便做一个简单的复习。

一个线程池在一个任务被提交时,会经历如下阶段:

  1. 判断是否当前池中正在运行线程数小于corePoolSize,如果是就新建一个线程
  2. 否则,判断池中是否具有空闲的线程,如果有就让它执行
  3. 否则,判断当前任务排队队列是否未满,如果是则让任务排队
  4. 否则,判断是否当前池中正在运行的线程数小于maximumPoolSize,如果是就创建一个线程
  5. 否则,拒绝该任务

管理队列任务

我们知道,通过将线程池中线程数量做一个限制能够避免瞬间涌入的并发请求突破系统中线程最大数量限制或者因为什么其它原因而耗尽系统资源。

Executors.newFixedThreadPool提供了这种线程池,但是它能避免这种系统资源的耗尽吗?

Executors.newFixedThreadPool使用了LinkedBlockingQueue作为提交任务时任务的排队队列,由于没有长度限制,所以它永远也不会满,永远不会有一个被拒绝的任务。这样当系统中不断涌入大量的并发请求时,一个个的Runnable对象等待在这个队列中,并且队列仍不断在变长,虽然它不及线程对象更占用内存,但是系统资源仍有可能被耗尽,并且响应性也会变得很低。

更好的办法是选择有界的阻塞队列实现。

对于非常大的无界线程池,可以考虑使用SynchronousQueue,它是一个没有容量的队列,当任务到来时如果有线程正可以处理它,那么直接让它处理,否则,如果线程数小于最大线程数限制,则创建一个处理它,否则拒绝该任务。

饱和策略

当线程池需要拒绝一个任务时(满了或者关闭),就会用到饱和策略。JDK提供的包和策略有

  1. AbortPolicy——抛出RejectedExecutionException
  2. CallerRunsPolicy——让调用者线程执行该任务
  3. DiscardPolicy——静默抛弃该任务
  4. DiscardOldestPolicy——静默抛弃队列中的第一个任务(在优先级队列中会抛弃优先级最高的任务)

调用者运行策略会使得调用者线程执行任务,比如在WebServer中,它会导致服务器在一段时间内不能accept新的请求,这样新到达的请求会被缓存到服务器端的TCP接收缓存队列中,而非程序中的队列,当TCP队列满,TCP的流量控制机制会减少客户发送数据的频率,这种逐渐向外蔓延的情况可以实现服务器性能的平缓降低。

下面创建一个固定大小,使用有界排队队列并且使用调用者运行包和策略的线程池

书上还介绍了使用信号量来实现当队列满时阻塞execute方法的代码。

线程工厂

通过指定自定义的线程工厂,可以指定线程池创建线程的策略,你可以控制线程是否是阻塞线程,给它们取名字,维护统计信息和提供未捕获异常处理器。

构造后定制ThreadPoolExecutor

在构造函数后也可以通过调用set方法对ThreadPool进行设置。

使用Executors.unconfigurableExecutorService包装的ExecutorService只暴露出ExecutorService的方法,所以无法进行再配置,当你有类似的需求——即不希望别人重新配置你的线程池时,你也可以用这个方法包装。

扩展ThreadPoolExecutor

通过扩展ThreadPoolExecutor,并重写beforeExecuteafterExecute等生命周期方法,可以对对线程池的任务执行做一些记录。

posted @ 2022-04-13 12:49  yudoge  阅读(143)  评论(0编辑  收藏  举报