高并发研究室01-线程池详解

线程池

三种线程的创建方式

  • 继承Thread类
  • 实现Runnable接口
  • 使用callable与futher方式创建线程,这个是带返回值的。

线程池的好处

  • 第一点,线程池可以解决线程生命周期的系统开销问题,同时还可以加快响应速度。因为线程池中的线程是可以复用的,我们只用少量的线程去执行大量的任务,这就大大减小了线程生命周期的开销。而且线程通常不是等接到任务后再临时创建,而是已经创建好时刻准备执行任务,这样就消除了线程创建所带来的延迟,提升了响应速度,增强了用户体验。
  • 第二点,线程池可以统筹内存和 CPU 的使用,避免资源使用不当。线程池会根据配置和任务数量灵活地控制线程数量,不够的时候就创建,太多的时候就回收,避免线程过多导致内存溢出,或线程太少导致 CPU 资源浪费,达到了一个完美的平衡。
  • 第三点,线程池可以统一管理资源。比如线程池可以统一管理任务队列和线程,可以统一开始或结束任务,比单个线程逐一处理任务要更方便、更易于管理,同时也有利于数据统计,比如我们可以很方便地统计出已经执行过的任务的数量。

线程池创建以及器参数与原理

众所周知,有四大线程池(单线程线程池,定长线程池,定时线程池,可缓存线程池)。 看他们的源码的话,可以看出来,他们都是执行的new ThreadPoolExecutor 来创建线程 所以说我们可以通过搭配不同参数来实现符合我们业务需要的线程池(PS.目前阿里巴巴开发手册中是不推荐我们使用四大线程池,推荐我们自己new一个线程池)

线程池参数

如表所示线程池主要有 6 个参数, 1.corePoolSize and maxPoolSize corePoolSize核心线程数,也就是常驻线程池的线程数量,与它对应的是 maxPoolSize,表示线程池最大线程数量,当我们的任务特别多而 corePoolSize 核心线程数无法满足需求的时候,就会向线程池中增加线程,以便应对任务突增的情况。 们来具体看下这两个参数所代表的含义,以及线程池中创建线程的时机。如上图所示,当提交任务后,线程池首先会检查当前线程数,如果此时线程数小于核心线程数,比如最开始线程数量为 0,则新建线程并执行任务,随着任务的不断增加,线程数会逐渐增加并达到核心线程数,此时如果仍有任务被不断提交,就会被放入 workQueue 任务队列中,等待核心线程执行完当前任务后重新从 workQueue 中提取正在等待被执行的任务。

此时,假设我们的任务特别的多,已经达到了 workQueue 的容量上限,这时线程池就会启动后备力量,也就是 maxPoolSize 最大线程数,线程池会在 corePoolSize 核心线程数的基础上继续创建线程来执行任务,假设任务被不断提交,线程池会持续创建线程直到线程数达到 maxPoolSize 最大线程数,如果依然有任务被提交,这就超过了线程池的最大处理能力,这个时候线程池就会拒绝这些任务,我们可以看到实际上任务进来之后,线程池会逐一判断 corePoolSize 、workQueue 、maxPoolSize ,如果依然不能满足需求,则会拒绝任务

2.keepAliveTime+时间单位 当线程池中线程数量多于核心线程数时,而此时又没有任务可做,线程池就会检测线程的 keepAliveTime,如果超过规定的时间,无事可做的线程就会被销毁,以便减少内存的占用和资源消耗。如果后期任务又多了起来,线程池也会根据规则重新创建线程,所以这是一个可伸缩的过程,比较灵活,我们也可以用 setKeepAliveTime 方法动态改变 keepAliveTime 的参数值。

3.ThreadFactory ThreadFactory 实际上是一个线程工厂,它的作用是生产线程以便执行任务。我们可以选择使用默认的线程工厂,创建的线程都会在同一个线程组,并拥有一样的优先级,且都不是守护线程,我们也可以选择自己定制线程工厂,以方便给线程自定义命名,不同的线程池内的线程通常会根据具体业务来定制不同的线程名。

4.handler(拒绝策略) 这个参数使用指定任务拒绝策略的,线程池为了稳定安全,会有拒绝任务的策略。触发的拒绝的两种情况

  • 当我们调用 shutdown 等方法关闭线程池后,即便此时可能线程池内部依然有没执行完的任务正在执行,但是由于线程池已经关闭,此时如果再向线程池内提交任务,就会遭到拒绝。
  • 线程池没有能力继续处理新提交的任务,也就是工作已经非常饱和的时候。

举例

 ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(5, 10, 5, TimeUnit.SECONDS, new ArrayBlockingQueue<> (10), new ThreadPoolExecutor.DiscardOldestPolicy());

上述代码自定义了一个核心线程数为5,最大线程数为10,使用容量上限为 10 的 ArrayBlockingQueue 作为任务队列。在这种情况下,线程池会首先创建核心数量的线程,也就是5个线程来执行任务,然后往队列里去放任务,队列的 10 个容量被放满了之后,会继续创建新线程,直到达到最大线程数 10。此时线程池中一共有 20 个任务,其中 10 个任务正在被 10 个线程执行,还有 10 个任务在任务队列中等待,而且由于线程池的最大线程数量就是 10,所以已经不能再增加更多的线程来帮忙处理任务了,这就意味着此时线程池工作饱和,这个时候再提交新任务时就会被拒绝。

我们了解了线程池拒绝任务的时机,那么我们如何正确地选择拒绝策略呢?Java 在 ThreadPoolExecutor 类中为我们提供了 4 种默认的拒绝策略来应对不同的场景,都实现了 RejectedExecutionHandler 接口,如图所示:

接下来,我们将具体讲解这 4 种拒绝策略。

  • 第一种拒绝策略是 AbortPolicy,这种拒绝策略在拒绝任务时,会直接抛出一个类型为 RejectedExecutionException 的 RuntimeException,让你感知到任务被拒绝了,于是你便可以根据业务逻辑选择重试或者放弃提交等策略。
  • 第二种拒绝策略是 DiscardPolicy,这种拒绝策略正如它的名字所描述的一样,当新任务被提交后直接被丢弃掉,也不会给你任何的通知,相对而言存在一定的风险,因为我们提交的时候根本不知道这个任务会被丢弃,可能造成数据丢失。
  • 第三种拒绝策略是 DiscardOldestPolicy,如果线程池没被关闭且没有能力执行,则会丢弃任务队列中的头结点,通常是存活时间最长的任务,这种策略与第二种不同之处在于它丢弃的不是最新提交的,而是队列中存活时间最长的,这样就可以腾出空间给新提交的任务,但同理它也存在一定的数据丢失风险。
  • 第四种拒绝策略是 CallerRunsPolicy,相对而言它就比较完善了,当有新任务提交后,如果线程池没被关闭且没有能力执行,则把这个任务交于提交任务的线程执行,也就是谁提交任务,谁就负责执行任务。这样做主要有两点好处。
    • 第一点新提交的任务不会被丢弃,这样也就不会造成业务损失。
    • 第二点好处是,由于谁提交任务谁就要负责执行任务,这样提交任务的线程就得负责执行任务,而执行任务又是比较耗时的,在这段期间,提交任务的线程被占用,也就不会再提交新的任务,减缓了任务提交的速度,相当于是一个负反馈。在此期间,线程池中的线程也可以充分利用这段时间来执行掉一部分任务,腾出一定的空间,相当于是给了线程池一定的缓冲期。

5.workqueue 队列 我们将队列大致分为三种 有界队列,无界队列, 同步移交队列

有界队列

常用的有两类,一类是遵循FIFO原则的队列如ArrayBlockingQueue,另一类是优先级队列如PriorityBlockingQueue。PriorityBlockingQueue中的优先级由任务的Comparator决定。 使用有界队列时队列大小需和线程池大小互相配合,线程池较小有界队列较大时可减少内存消耗,降低cpu使用率和上下文切换,但是可能会限制系统吞吐量。

在我们的修复方案中,选择的就是这个类型的队列,虽然会有部分任务被丢失,但是我们线上是排序日志搜集任务,所以对部分对丢失是可以容忍的。

无界队列

队列大小无限制,常用的为无界的LinkedBlockingQueue,容量为 Integer.MAX_VALUE,使用该队列做为阻塞队列时要尤其当心,当任务耗时较长时可能会导致大量新任务在队列中堆积最终导致OOM。阅读代码发现,Executors.newFixedThreadPool 采用就是 LinkedBlockingQueue,而楼主踩到的就是这个坑,当QPS很高,发送数据很大,大量的任务被添加到这个无界LinkedBlockingQueue 中,导致cpu和内存飙升服务器挂掉。

还有个一个常用的DelayedWorkQueue,延迟获取元素队列,指定时间后获取,为无界阻塞队列。

同步移交队列

如果不希望任务在队列中等待而是希望将任务直接移交给工作线程,可使用SynchronousQueue作为等待队列。SynchronousQueue不是一个真正的队列,而是一种线程之间移交的机制。要将一个元素放入SynchronousQueue中,必须有另一个线程正在等待接收这个元素。只有在使用无界线程池或者有饱和策略时才建议使用该队列。

下图列了一些常用线程池所使用的队列

为啥不应该自动创建线程

所谓的自动创建线程池就是直接调用 Executors 的各种方法来生成前面学过的常见的线程池,例如 Executors.newCachedThreadPool()。但这样做是有一定风险的,接下来我们就来逐一分析自动创建线程池可能带来哪些问题。

FixedThreadPool

首先我们来看第一种线程池 FixedThreadPool, 它是线程数量固定的线程池,如源码所示,newFixedThreadPool 内部实际还是调用了 ThreadPoolExecutor 构造函数。

public static ExecutorService newFixedThreadPool(int nThreads) { 
    return new ThreadPoolExecutor(nThreads, nThreads,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>());
}

这个线程池使用的队列是没有上限的LinkedBlockingQueue。如果我们对任务的处理速度比较慢,那么随着请求的增多,队列中堆积的任务也会越来越多,最终大量堆积的任务会占用大量内存,并发生 OOM ,也就是OutOfMemoryError,这几乎会影响到整个程序,会造成很严重的后果。

SingleThreadExecutor
public static ExecutorService newSingleThreadExecutor() { 
    return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>()));
}

newSingleThreadExecutor 和 newFixedThreadPool 的原理是一样的,只不过把核心线程数和最大线程数都直接设置成了 1,但是任务队列仍是无界的 LinkedBlockingQueue,所以也会导致同样的问题,也就是当任务堆积时,可能会占用大量的内存并导致 OOM。

CachedThreadPool
public static ExecutorService newCachedThreadPool() { 
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,60L, TimeUnit.SECONDS,new SynchronousQueue<Runnable>());
}

这个缓存线程池使用的是 SynchronousQueue,SynchronousQueue 本身并不存储任务,而是对任务直接进行转发,这本身是没有问题的,但你会发现构造函数的第二个参数被设置成了 Integer.MAX_VALUE,这个参数的含义是最大线程数,所以由于 CachedThreadPool 并不限制线程的数量,当任务数量特别多的时候,就可能会导致创建非常多的线程,最终超过了操作系统的上限而无法创建新线程,或者导致内存不足。

ScheduledThreadPool
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) { 
    return new ScheduledThreadPoolExecutor(corePoolSize);
}

采用的任务队列是 DelayedWorkQueue,这是一个延迟队列,同时也是一个无界队列,所以和 LinkedBlockingQueue 一样,如果队列中存放过的任务,就可能导致 OOM。

所以说我们最好要根据我们程序的业务场景,预测的并发量来自己创建线程池。

请关注我的公众号,一起交流后端技术

本文使用 mdnice 排版

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值