学习 CompletableFuture 进阶之前先掌握两种线程池

前言

本来是准备直接写 CompletableFuture 线程池进阶文章的,但是总感觉不说一下线程池又不太好直接开展,所以本篇文章先讲解一下 Java 中的两种线程池。

为什么需要线程池

当我们需要异步处理任务时,最常用也是最简单的方式就是新开一个线程去做。而线程的创建和销毁是需要消耗 CPU 资源的,当异步任务越来越多时,如果一味的新开线程去处理,那么我们可能会无法控制 CPU 的资源。所以首先我们需要控制线程的开启数量。

类比数据库连接池,想一想之前使用 JDBC 访问数据库的时候是不是要先建立连接,也就是创建一个 Connection 对象。在 web 项目中,通常会有很多请求访问数据库,在使用框架 Spring+Mybatis 时,如果当前上下文没有事务的话那么每一个数据库操作方法都需要创建一个 Connection,这样频繁的创建 Connection 对象无疑是资源的浪费,也会拉低接口吞吐量。

所以我们需要一个池子来维护数据库连接,也就是数据库连接池。同样我们也需要线程池。

线程池简介

线程池是一种基于池化思想管理线程的工具,类似于数据库连接池。

基于池化的思想是提前创建一定数量的 Connection 对象,然后将其存放起来,这样每次需要的时候直接从池中获取 Connection 对象即可,避免了频繁的创建和销毁的操作。线程池则是将线程 Thread 对象提前创建缓存起来,这样当提交任务的时候直接将任务提交给池中的线程即可,而不需要创建新的 Thread。总结线程池的优势:

  • 传统创建线程的方式对资源无限申请缺少抑制手段,容易引发资源耗尽的风险
  • 通过复用线程池中的线程可以避免频繁创建线程,省去创建线程的时间,提高响应速度
  • 线程池可以指定最大线程数量,超出的任务会在等待队列等待,不会出现大量线程耗尽服务器资源

传统线程池 ThreadPoolExecutor

ThreadPoolExecutor 是使用最为广泛的线程池实现类,查看类继承结构

QQ图片20220112183304.png

顶层接口 Executor 提供了一种思想:将任务提交和任务执行进行解耦。用户无需关注如何创建线程,如何调度线程来执行任务,用户只需提供 Runnable 对象,将任务的运行逻辑提交到执行器 (Executor) 中,由 Executor 框架完成线程的调配和任务的执行部分。换句话说,你只需要把要做的事情给线程池即可。

使用 ThreadPoolExecutor 时直接实例化即可,它有很多重载的构造方法,这里我们选择参数最全的方法来进行讲解。

image.png

corePoolSize:核心线程数

用于执行任务的理想线程数,当线程池中正在运行的线程数量小于 corePoolSize 时,新任务来了会创建新线程执行任务

keepAliveTime 空闲线程存活时间

当线程数量大于 corePoolSize 时,超出的空闲线程在终止前等待新任务的最长时间。也就是说过了这个时间还没有新任务需要执行的话,空闲线程就会被销毁。很明显当 corePoolsize == maximumPoolSize 时, keepAliveTime 没有意义。

workQueue 工作队列

当线程池中正在运行的线程数量大于等于 corePoolSize ,新任务来了会放进队列等待。Java 提供的队列有 LinkedBlockingQueue、ArrayBlockingQueue、LinkedBlockingDeque 等,这里不详细介绍。

maximumPoolSize 最大线程数

线程池中最大允许的线程数量,很多人以为它的作用是这样的:当线程池中的任务超过 corePoolSize 时,会继续创建线程,直到线程数小于 maximumPoolSize。这种理解是完全错误的

真正的作用是:当线程池中的线程数大于 corePoolSize 并且 workQueue 已经放满,这时要看当前线程数是否小于 maximumPoolSize ,如果小于它则继续创建线程执行新来的任务,而不是 workQueue 中的(为什么?)。如果等于 maximumPoolSize ,则会执行相应的拒绝策略。

image.png

很多人可能会奇怪,为什么队列放满了,线程池还会创建新的线程?也许官方的设计思路可能是这样的,核心线程数已经满,workQueue 也满了,此时说明我们的线程执行任务的效率是低下的,它通过多耗费资源来试图帮我们提高效率,相当于给我们预备了几个备用线程。

为什么核心线程数之外的新创建的线程执行的任务不是从 workQueue 拿,而是执行新来的任务?这不是插队了吗?这是一个好问题,我也不知道......官方就是这么设计的,我们可以写个代码去验证这件事,的确是这样。

threadFactory 线程工厂

线程池中的线程都是通过线程工厂来创建的,通过线程工厂,我们也可以在创建线程时对线程做一些定制的设置,例如设置线程名、优先级等。另外,可以通过线程工厂来做对子线程的异常处理,Java 中主线程不能直接捕获到子线程抛出的异常,使用线程池时,可以通过 ThreadFactory 设置子线程异常处理逻辑。将以下匿名实现作为参数传递即可

r -> {
    Thread t = new Thread(r);
    t.setName("自定义线程名");//设置线程名
    t.setPriority(10);//设置优先级
    t.setDaemon(false);//是否守护线程
    t.setUncaughtExceptionHandler((t1, e) -> {
        System.out.println(e.getMessage());//处理子线程异常
    });
    return t;
}
复制代码

通常我们并不会用到此参数,使用默认的就可以了。

handler 拒绝策略

当线程池中的工作线程数已经达到了 maximumPoolSize,此时再提交任务就会被拒绝,JDK 提供了四种拒绝策略

  • AbortPolicy

丢弃任务并抛出 RejectedExecutionException 异常。这是线程池默认策略,如果是比较关键的业务推荐使用此策略,这样在子系统不能承载更大并发的时候,能够及时通过异常发现

  • DiscardPolicy

丢弃任务但是不抛出异常。使用此策略可能会使我们无法发现系统的异常,一般无关紧要的业务使用此策略

  • DiscardOldestPolicy

丢弃队列最前面的任务, 也就是最早的任务,然后重新提交被拒绝的任务

  • CallerRunsPolicy

由调用线程(提交任务的线程)处理该任务。相当于是把任务交给主线程去执行,这样由于主线程在执行任务就会被阻塞,就不会继续给线程池提交任务,在某些情况下来说这也是个不错的拒绝策略。

当然我们也可以实现自己的拒绝策略,只需要实现 RejectedExecutionHandler 重写拒绝逻辑即可。

ThreadPoolExecutor 的工作机制

下面我们通过一张图来理解线程池的工作机制

image.png

线程池任务分配有三种情况,第一种就是线程池中的线程数小于核心线程数,直接执行。第二种是线程池中的线程数大于核心线程数,放进队列等待。第三种就是线程池中的线程数达到最大线程数,直接拒绝。

ThreadPoolExecutor 的状态

  • RUNNING :运行状态。能接受新提交的任务,并且也能处理阻塞队列中的任务
  • SHUTDOWN : 关闭状态。不再接受新提交的任务,能处理阻塞队列中的已经保存的任务
  • STOP : 停止状态。不能接受新提交的任务,也不能处理阻塞队列中的任务,会中断正在处理任务的线程
  • TIDYING : 所有任务都终止了,workerCount (工作线程数)为 0
  • TERMINATED : 终止状态。terminated() 方法执行完进入该状态。

image.png

ThreadPoolExecutor 的源码中采用二进制的高三位来存储它的状态,看起来很有逼格的样子......

private static final int RUNNING    = -1 << COUNT_BITS;
private static final int SHUTDOWN   =  0 << COUNT_BITS;
private static final int STOP       =  1 << COUNT_BITS;
private static final int TIDYING    =  2 << COUNT_BITS;
private static final int TERMINATED =  3 << COUNT_BITS;
复制代码

ThreadPoolExecutor 执行任务的流程

image.png

如何选择最优的核心线程数

说了这么多,在实际工作中,我们该怎么确定线程池的核心线程数?这个问题可以分为两种类型来看

  • 计算型

对于计算型任务来说,核心线程数设置为 CPU核数 - 1 是比较好的,因为计算型设置超过 CPU 的核心数的线程不会有额外的帮助,因为计算型任务都占用 CPU ,反而会增加线程调度的时间。为什么不设置成和 CPU 核数相同呢?因为我们要考虑CPU 被线程池占满的情况,此时其他业务就没法正常执行了。

  • IO 型

对于 IO 型任务来说。我们需要明白 IO 操作其实并不是 CPU 去做的(IO 这种耗时的操作要是给 CPU 做,那真的是暴殄天物......),而是 CPU 会把任务委托给 DMA(Direct Memory Access),那此时 CPU 会处于空闲状态就可以去做其他事情了。所以我们可以将线程数设置为超过核心数合理利用资源。但实际上到底设置多少是最优其实是不一定的,正规的做法是不断压测去尝试出最符合当前服务器的一个最优参数配置。

特殊线程池 ForkJoinPool

Java 7 提供了一种新的线程池实现 ForkJoinPool。这种线程池用于一些特殊的场景,网上有很多文章去吹这个 ForkJoinPool 多么多么牛逼,其实不是这样的。实际上绝大多数情况下传统的线程池是处理并发任务的最好方式,使用 ForkJoinPool 只是用于解决某些特殊场景下传统的线程池的不足。

为什么需要 ForkJoinPool

让我们先来看两个场景

场景一

假设现在有一个具有多层依赖的并发任务。如下图

image.png

现在有一个 Task1 需要完成,但是想完成 Task1 必须要先完成 Task2、Task3、Task4、Task5,想完成 Task2 又必须先完成 Task6 ...... 以此类推存在一个这样的依赖任务链。那么此时你会发现使用 ThreadPoolExecutor 会存在一些局限。

ThreadPoolExecutor pool = new ThreadPoolExecutor(4, 4, 0, TimeUnit.SECONDS, new LinkedBlockingDeque<>(10));
复制代码

Github 完整代码 假设我们现在用的是上面的线程池配置,参考示例代码 ThreadPoolExecutorTest.java 思考一下任务的执行结果。由于 corePoolSize = maxPoolSize = 4 所以当核心线程数达到 4 个时,不管队列满不满都不会额外创建线程。那么我们根据流程思考,当程序开始提交任务时,核心线程分别对于的四个任务是 task1、task2、task3、task4,剩下的任务都会丢进阻塞队列。那么此时 task1,2,3,4没法结束,因为它们的依赖任务还没有完成。但是它们的依赖任务在阻塞队列中,需要等待核心线程执行的任务 task1,2,3,4 完成去释放线程来执行自己。此时就会发现它们一直处于互相等待中。

image.png

相当于形成了 “死锁”。为什么要加引号呢,因为我们使用 jps、jstack 排查的话会发现没有找到 deadLock,而是大量的 waiting。实际上它只是傻傻的阻塞,并非死锁,但是这种阻塞是无法解决的。我们可以去阅读 FutureTask.get() 源码就明白了,如果提交的任务没完成的话就会调用 awaitDone 方法,该方法内部最终会调用一个 LockSupport.park(this),使当前线程阻塞,直到当前线程负责的那个任务完成之后会去调用 finishCompletion() 该方法内部会有一个 LockSupport.unpark(t) 来解除当前线程的阻塞。

所以产生循环等待的核心就是核心线程调用 LockSupport.park(this) 阻塞了,但是没机会调用 LockSupport.unpark(t)

容易发现,以前的线程池提交任务之后,都只能交给一个线程执行。即使这个任务很复杂,需要非常长的时间。比如现在有五个任务 A、B、C、D、E,五个活跃线程,B、C、D、E 执行完毕只需要 1 秒,A 执行完毕需要20秒,那么当 B、C、D、E 任务执行结束时 A 仍然要等 20 秒。这显然没有利用好线程资源调度,于是我们开始思考能不能将 A 这个复杂的任务进行拆分,将他拆分为多个和 B、C、D、E 一样只需要1秒钟就能执行完毕的子任务,然后利用刚刚空闲的四个线程去调度执行,提高效率。

场景二

假如现在要计算 1 ~ N 的和,N 非常大,此时我们就可以使用 ForkJoinPool 来做,先设置一个计算阈值(当拆分到几个元素的时候开始求和) ,然后根据这个阈值将这个范围进行无限拆分。这里为了方便以 1~1000 为例,设置阈值为 20,那么这个任务会被拆分为

  • 1\~500, 501\~1000
  • 1\~250, 251\~500,501\~750, 751\~1000
  • 1\~125, 126\~250,251\~375, 376\~500,501\~625, 626\~750,751~875, 876~1000
  • 继续拆分......直到拆分粒度达到我们设置的合理阈值开始计算,什么是合理的阈值?目前来说可以这么理解,这个子任务能够立即执行完毕得到执行结果的时候,这个阈值就是合理的。

这样一个复杂的任务就被拆分为多个简单的子任务给多个线程去调度计算。能够高效的利用CPU 资源。

ForkJoinPool 应用

该类的核心思想就是 分治算法,将一个规模为 N 的问题分解为 K 个规模较小的子问题 。它有两个核心方法 fork()、join()fork() 则是把一个大任务进行无限拆分,直到拆分到我们设置的阈值的最细粒度。对于我们上面的例子,使用 ForkJoinPool.fork() 最终将会被拆分为

image.png

其实 fork 的工作是把任务放进线程绑定的 workQueue 中。

join() 则是阻塞当前线程,等待子任务的执行结果。

ForkJoinPool 执行流程图

image.png

任务窃取

任务窃取其实就是一个能者多劳,互帮互助的思想。ForkJoinPoolThreadPoolExecutor 不同的地方在于它每一个线程都有一个工作队列 workQueue,它是一个双端队列,当线程(worker)操作自己的 WorkQueue 默认是 LIFO 操作(栈),当线程(worker)尝试窃取其他 WorkQueue 里的任务时,这个时候执行的是FIFO操作(队列),即从 base 端窃取。

image.png

假设 A、B、C、D 四个线程分别有 10 个任务,假设 A 线程先执行完自己的任务,那么它会从 B、C、D 的工作队列里“窃取”任务帮忙执行。反正此时线程 A 闲着也是闲着,不如帮其他线程干点事情。

空闲线程为什么从其他非空闲线程的工作队列尾部窃取任务?因为栈是先进后出嘛, fork 时,越大的任务越在 WorkQueue 的 base 端,尽早被窃取执行 fork(),能够尽快进入执行 join()。

结语

通常 ThreadPoolExecutor 已经能满足业务需求,当涉及到特定的分治需求时可以考虑使用 ForkJionPool。 本篇文章介绍了两种线程池的使用场景,关于 ForkJoinPool 只介绍了基本使用方法和执行流程,其内部原理非常复杂,需要有深厚的并发编程功底、数据结构功底才能阅读懂源码。

如果这篇文章对你有帮助,记得点赞加关注!你的支持就是我继续创作的动力!

猜你喜欢

转载自juejin.im/post/7070428873556492302