【重难点】【JUC 06】 线程池七大参数、六种常见的线程池、线程池怎么合理地设置参数、线程池使用时要注意哪些问题
文章目录
一、线程池七大参数
1.corePoolSize 和 maximumPoolSize
coreSize 指的是核心线程数,线程池初始化时线程数默认为 0,当有新的任务提交后,会创建新线程执行任务,如果不做特殊设置,此后线程数通常不会再小于 corePoolSize,因为它们是核心线程,即便未来可能没有可执行的任务也不会被销毁。随着任务量的增加,在任务队列满了之后,线程池会进一步创建新线程,最多可以达到 maximumPoolSize 来应对任务多的场景,如果未来线程有空闲,大于 corePoolSize 的线程会被合理回收。所以正常情况下,线程池中的线程数量会处在 corePoolSize 与 maximumPoolSize 的闭区间内
- 线程池希望保持较少的线程数,并且只有在负载变得很大时才增加线程
- 线程池只有在任务队列填满时才创建多于 corePoolSize 的线程,如果使用的是无界队列,那么由于队列不会满,所以线程数不会超过 corePoolSize
- 通过设置 corePoolSize 和 maximumPoolSize 为相同的值,就可以创建固定大小的线程池
- 通过设置 maximumPooleSize 为很高的值,例如 Integer.MAX_VALUE,就可以允许线程池创建任意多的线程
2.keepAliveTime 和 unit
当线程池中线程数量多于核心线程数时,而此时有没有任务可做,线程池就会检测线程的 keepAliveTime,如果超过规定的时间,无事可做的线程就会被销毁,以便减少内存的占用和资源消耗。如果后期任务又多了起来,线程池也会根据规则重新创建线程,所以这是一个可伸缩的过程,比较灵活,我们也可以用 setKeepAliveTime 方法动态改变 keepAliveTime 的参数值。至于 unit,只是 keepAliveTime 的时间单位
3.threadFactory
threadFactory 实际上是一个线程工厂,它的作用是生产线程以便执行任务。我们可以选择使用默认的线程工厂,创建的线程都会在同一个线程组,并拥有一样的优先级,且都不是守护线程,我们也可以选择自己定制线程工厂,以便给线程自定义命名,不同的线程池内的线程通常会根据具体业务来定制不同的线程名
4.workQueue
LinkedBlockingQueue
对于 FixedThreadPool 和 SingleThreadExector 而言,它们使用的阻塞队列是容量为 Integer.MAX_VALUE 的 LinkedBlockingQueue,可以认为是无界队列。由于 FixedThreadPool 线程池的线程数是固定的,所以没有办法增加特别多的线程来处理任务,这时就需要 LinkedBlockingQueue 这样一个没有容量限制的阻塞队列来存放任务
SynchronousQueue
SynchronousQueue 对应的线程池 是 CacheThreadPool。SynchronousQueue 没有容量,是无缓冲等待队列,是一个不存储元素的阻塞队列,会直接将任务交给消费者,必须等队列中的添加元素被消费后才能继续添加新的元素
我们自己创建使用 SynchronousQueue 的线程池时,如果不希望任务被拒绝,那么最大线程数就要尽量设置得大一些,以免发生任务数大于最大线程数时,无法将任务放到队列中,也没有足够线程来执行任务的情况
DelayedWorkQueue
DelayedWorkQueue 对应的线程池分别是 ScheduledThreadPool 和 SingleThreadScheduledExecutor,这两种线程池的最大特点就是可以延迟执行任务,比如说一定时间后执行任务或是每隔一定的时间执行一次任务。DelayedWorkQueue 的特点是内部元素并不是按照放入的时间顺序,而是会按照延迟的时间长短对任务进行排序,内部采用的是 “堆” 的数据结构
5.Handler
ThreadPoolExecutor 类中为我们提供了 4 种默认的拒绝策略来应对不同的场景,它们都实现了 RejectedExecutionHandler 接口
- 第一种拒绝策略是 AbortPolicy,这种拒绝策略在拒绝任务时会直接抛出一个类型为 RejectedExecutionException 的 RuntimeException,让你感知到任务被拒绝了,于是你便可以根据业务逻辑选择重试或者放弃提交等策略
- 第二种拒绝策略是 DiscardPolicy,这种拒绝策略正如它的名字所描述的一样,当新任务被提交后直接被丢弃掉,也不会给你任何的通知,相对而言存在一定的风险,因为我们提交的时候根本不知道这个任务会被丢弃,可能造成数据丢失
- 第三种拒绝策略是 DiscardOldest Policy,如果线程池没被关闭且没有能力执行,则会丢弃任务队列中的头结点,通常是存活时间最长的任务,这种策略与第二种的不同之处在于它丢弃的不是最新提交的,而是队列中存活时间最长的任务,这样就可以腾出空间给新提交的任务,但同理它也存在一定的数据丢失风险
- 第四种拒绝策略是 CallerRunsPolicy,相对而言它就比较完善了,当有新任务提交后,如果线程池没被关闭且没有能力执行,则把这个任务交给提交任务的线程执行,也就是谁提交任务,谁就负责执行任务。这样做有两点好处:
- 新提交的任务不会被丢弃
- 由于谁提交任务谁就要负责执行任务,这样提交任务的线程就得负责执行任务,而执行任务又是比较耗时的,在这段时间,提交任务的线程被占用,也就无法再提交新的任务,减缓了任务提交的速度,相当于是一个负反馈。同时,线程池中的线程也可以充分利用这段时间来执行掉一部分任务、腾出空间
二、六种常见的线程池
1.FixedThreadPool
FixedThreadPool 的核心线程数和最大线程数是一样的,所以可以把它看作是固定线程数的线程池,它的特点是线程池中的线程数除了初始阶段需要从 0 开始增加外,之后的线程数量就是固定的,就算任务数超过线程数,线程池也不会再创建更多的线程来处理任务,而是会把超出线程处理能力的任务放到任务队列中进行等待。而且就算任务队列满了,到了本该继续增加线程数的时候,由于它的最大线程数和核心线程数一致,所以也无法再增加新的线程
2.CachedThreadPool
CachedThreadPool 可以称作可缓存线程池,它的特点在于线程数几乎是可以无限增加的(实际最大可以达到 Integer.MAX_VALUE),而当线程闲置时还可以堆线程进行回收。也就是说,该线程的线程数量不是固定不变的,当然它也有一个用于存储提交任务的队列,但这个队列是 SynchronousQueue,队列的容量为 0,实际不存储任何任务,它只负责对任务进行中转和传递,所以效率比较高
3.ScheduledThreadPool
ScheduledThreadPool 支持定时或周期性执行任务,比如每隔 10 秒执行一次任务,实现这种功能的方法主要有 3 种
ScheduledExecutorService service = Executors.newScheduledThreadPool(10);
service.schedule(new Task(), 10, TimeUnit.SECONDS);
service.scheduleAtFixedRate(new Task(), 10, 10, TimeUnit.SECONDS);
service.scheduleWithFixedDelay(new Task(), 10, 10, TimeUnit.SECONDS);
- 第一种方法 schedule 比较简单,表示延迟指定时间后执行一次任务,如果任务中设置参数为 10 秒,也就是 10 秒后执行一次任务后就结束
- 第二种方法 scheduleAtFixedRate 表示以固定的频率执行任务,它的第二个参数 initialDelay 表示第一次延时时间,第三个参数 period 表示周期,也就是第一次延时后每次延时多长时间执行一次任务
- 第三种方法 scheduleWithFixedDelay 与第二种方法类似,也是周期执行任务,区别在于对周期的定义,第二种方法是以任务开始的时间为时间起点开始计时,时间到就开始执行第二次任务,而不管任务需要花多久执行。而 scheduleWithFixedDelay 方法以任务结束的时间为下一次循环的时间起点开始计时
4.SingleThreadExecutor
SingleThreadExecutor 会使用唯一的线程去执行任务,原理和 FixedThreadPool 是一样的,只不过 SingleThreadExecutor 的线程只有一个,如果线程在执行任务的过程中发生异常,线程池也会重新创建一个线程来执行后续的任务。这种线程池由于只有一个线程,所以非常适合用于所有任务都需要按被提交的顺序依次执行的场景,而前几种线程池不一定能够保障任务的执行顺序等于被提交的顺序,因为它们是多线程并行执行的
5.SingleThreadScheduledExecutor
SingleThreadScheduledExecutor 和 ScheduledThreadPool 非常相似,SingleThreadScheduleExecutor 的一个特例,内部只有一个线程
6.ForkJoinPool
ForkJoinPool 是 JDK 7 加入的,它的名字 ForkJoin 也描述了它的执行机制,主要用法和之前的线程池是相同的,也是把任务交给线程池去执行,线程池中也有任务队列来存放任务。但是 F欧瑞康JoinPool 和之前的线程池有两点区别
第一点,它非常适合执行可以产生子任务的任务,比如说主任务需要执行非常繁重的计算任务,我们就可以把计算拆分成三个部分,这三个部分互不影响、相互独立,这样就可以利用 CPU 的多核优势、并行计算,然后将结果进行汇总。这里主要涉及两个步骤:拆分(Fork)和汇总(Join)
第二点,之前的线程池所有的线程共用一个队列,但 ForkJoinPool 中每个线程都有自己独立的任务队列,这个队列是一个双端队列。一旦线程中的任务被拆分了,拆分出来的子任务会放入线程自己的队列中,而不是放入公共的任务队列中。如果此时有三个子任务放入线程 A 的队列中,对于线程 A 而言,获取任务的成本就降低了,可以直接在自己的队列中获取任务,而不必去公共队列竞争,也不会发生阻塞
三、线程池怎么合理地设置参数
corePoolSize
计算 corePoolSize 前我们需要知道下面 3 个值
- tasks:每秒的任务数
- taskcost:每个任务需要花费的时间
- responsetime:系统允许容忍的最大响应时间
通过公式 threadcount = tasks/(1/taskcost) 就可以算出核心线程数
我们假设 tasks 为 500~1000;taskcost 为 0.1s;responsetime 为 1s,threadcount = 50~100
再根据帕累托法则:所有变因中,最重要的仅有 20%,虽然剩余的 80%占了多数,影响的幅度却远低于 “关键的少数”。例如:意大利约有80%的土地由20%的人口所有、80%的豌豆产量来自20%的植株等等
在软件的负载测试中,通常的做法是估计 80% 的流量将在总时间段的特定 20% 内发生。因此我们设置 corePoolSize 为 80
maximumPoolSize
在生产环境上我们往往设置成和 corePoolSize 一样,这样可以减少在处理过程中创建线程的开销
keepAliveTime 和 allowCoreThreadTimeout
一般采用默认值即可,特殊情况需要好好考虑
queueCapacity
queueCapacity = (coreSizePool/taskcost)* responsetime
根据上面的假设,结果为 800,也是线程池 1 秒能处理的最大任务数
切记不可设置为 Integer.MAX_VALUE,这样队列会很大,而线程数只会保持在 corePoolSize 大小,当任务陡增时,不能新开线程来执行,响应时间也会随之陡增
rejectedExecutionHandler
根据具体情况来决定,任务不重要可丢弃,任务重要则要利用一些缓冲机制来处理
workQueue
- 无界队列
队列大小无限制,常用 LinkedBlockingQueue,使用该队列作为阻塞队列时要尤其当心,当任务耗时较长时可能会导致大量新任务在队列中堆积,最终导致 OOM - 有界队列
当使用有限的 maximumPoolSizes 时,有界队列有助于防止资源耗尽,但是可能较难调整和控制。常用的有两类,一类是遵循 FIFO 原则的队列(例如 ArrayBlockingQueue),另一类是优先级队列(例如 PriorityBlockingQueue,优先级由任务的 Comparator 决定)。使用有界队列时,队列大小需要和线程池大小互相配合,线程池较小、有界队列较大可减少内存消耗,降低 CPU 使用率和上下文切换,但是可能会限制系统吞吐量 - 同步移交队列
如果不希望任务在队列中等待,而是希望将任务直接移交给工作线程,可使用 SynchronousQueue 作为等待队列。SynchronousQueue 不是一个真正的队列,而是一种线程之间移交的机制。要将一个元素放入 SynchronousQueue 中,必须有另一个线程正在等待接收这个元素。只有在使用无界线程池或者饱和策略时才建议使用该队列
四、线程池使用时要注意哪些问题
1.使用线程池可以在流量突发期间能够平滑地服务降级
很多场景下,应用程序必须能够处理一系列传入请求,简单的处理方式是通过一个线程,顺序地处理这些请求
单线程策略的优势和劣势都非常明显:
- 优势:设计和实现简单
- 劣势:效率低,单线程的处理能力优先,不能发挥多核处理器的优势
在这种场景下我们就需要考虑并发,一个简单的并发策略就是 Thread-Per-Message 模式,即为每个请求使用一个新的线程
Thread-Per-Message 的优势和劣势也非常明显:
- 优势:设计和实现比较简单,能够同时处理多个请求,提升相应响应效率
- 劣势:首先是资源消耗问题,引入了在串行中所没有的开销,包括线程创建和调度、任务处理、资源分配和回收以及频繁上下文切换所需的时间和资源。然后是安全问题,攻击者可以通过大量请求使系统瘫痪并拒绝服务(Denial-of-Service),从而导致系统立即不响应而不是平滑地退出。从安全角度来看,一个组件可能由于连续的错误而耗尽所有资源,因此使所有其它组件无法获得资源
为了解决这两个问题,我们需要使用线程池
采用线程池的策略,线程池通过控制并发执行的工作线程的最大数量来解决 Thread-Per-Message 带来的问题
线程池可以接受一个 Runnable 或 Callable<T> 任务,并将其存储在临时队列中,当有空闲线程时可以从队列中拿到一个任务并执行
2.不要在有界线程池中执行相互依赖的任务
程序不能使用来自有界线程池的线程来执行依赖于线程池中的其他任务
我们来看两个场景:
- 当线程池中正在执行的线程阻塞在依赖于线程池中其他任务的完成上,这样就会出现 “饥饿” 死锁
- “饥饿” 死锁还会发生在当前执行的任务向线程池提交其它任务并等待这些任务完成的时候,然而此时线程池缺乏一次容纳所有任务的能力
要缓解上面两个场景产生的问题有两个办法:
- 扩大线程池中的线程数,以容纳更多的任务
- 将队列改为无界队列,由于系统资源有限,无界队列可以容纳更多任务,但无法从根本上解决问题
3.确保提交到线程池的任务可中断
向线程池提交的任务需要支持中断,从而保证线程池可以关闭。线程池支持 java.util.concurrent.ExcutorService.shutdownNow() 方法,该方法会尝试停止所有正在执行的任务、停止等待任务的处理、返回等待执行的任务列表
但是 shutdownNow() 除了尽力尝试停止处理主动执行的任务之外,不能保证一定能够停止。例如,典型的实现是通过 Thread.interrupt() 来停止,因此任何未能响应中断的任务可能永远不会终止,也就造成线程池无法真正关闭
4.确保在线程池中执行的任务不会悄无声息地失败
线程池中的所有任务必须提供一种机制:如果它们异常终止,需要通知应用程序。也就是说,在线程池中执行的任务,也需要能够抛出异常并被捕获处理
虽然不这样做也不会导致资源泄漏,但是由于线程池中的线程仍然会被重复使用,使得故障排查非常困难
在应用程序级别,处理异常的最好方法是使用异常处理,异常处理可以执行诊断操作、清理和关闭 JVM,或者只是记录故障的详细信息
任务恢复或清除操作可以通过重写 java.util.concurrent.ThreadPoolExcutor 类的 afterExecute() 钩子来执行。当任务由于异常而停止,导致未能执行其 run() 方法中的所有语句并且成功结束任务,将调用此钩子。我们可以通过自定义 ThreadPoolExecuteor 服务来重载 afterExecute() 钩子,还可以通过重载 terminated() 方法来释放线程池获取的资源,就像一个 finally 块
5.确保在使用线程池时重新初始化 ThreadLocal 变量
java.lang.ThreadLocal 类提供线程内的本地变量,根据 Java API
这些变量与其它正常变量不同,每个线程访问(通过其 get 或 set 方法)都有其属于其各自线程的、独立初始化的变量拷贝。ThreadLocal 实例通常是一些希望将状态与线程(例如,用户 ID 或事务 ID)相关联的类中的私有静态字段
ThreadLocal 对象需要关注那些对象被线程池中的多个线程执行的类
线程池缓存技术允许线程重用以减少线程创建的开销,或者当创建无限数量的线程时可以降低系统的可靠性
当 ThreadLocal 对象在一个线程中被修改,随后变得可重用时,在重用的线程上执行的下一个任务将能够看到该线程上一次执行的任务修改的 ThreadLocal 对象的状态。因此,要确保在使用线程池时冲洗初始化 ThreadLocal 变量
解决方案有:try-finally 块和 beforeExecute() 方法