前言

前几篇文章我们学习了线程池的概念,不了解的读者请移步

Java并发编程(八)——走进线程池

Java并发编程(九)——线程池重点参数详解

本文我们来学习常见的几种线程池。

FixedThreadPool

第一种线程池叫作 FixedThreadPool,它的核心线程数和最大线程数是一样的,所以可以把它看作是固定线程数的线程池,它的特点是线程池中的线程数除了初始阶段需要从 0 开始增加外,之后的线程数量就是固定的,就算任务数超过线程数,线程池也不会再创建更多的线程来处理任务,而是会把超出线程处理能力的任务放到任务队列中进行等待。而且就算任务队列满了,到了本该继续增加线程数的时候,由于它的最大线程数和核心线程数是一样的,所以也无法再增加新的线程了。

如图所示,线程池有 t0~t9,10 个线程,它们会不停地执行任务,如果某个线程任务执行完了,就会从任务队列中获取新的任务继续执行,期间线程数量不会增加也不会减少,始终保持在 10 个。

CachedThreadPool

第二种线程池是 CachedThreadPool,可以称作可缓存线程池,它的特点在于线程数是几乎可以无限增加的(实际最大可以达到 Integer.MAX_VALUE,为 2^31-1,这个数非常大,所以基本不可能达到),而当线程闲置时还可以对线程进行回收。也就是说该线程池的线程数量不是固定不变的,当然它也有一个用于存储提交任务的队列,但这个队列是 SynchronousQueue,队列的容量为0,实际不存储任何任务,它只负责对任务进行中转和传递,所以效率比较高。

当我们提交一个任务后,线程池会判断已创建的线程中是否有空闲线程,如果有空闲线程则将任务直接指派给空闲线程,如果没有空闲线程,则新建线程去执行任务,这样就做到了动态地新增线程。让我们举个例子,如下方代码所示。

1
2
3
4
5
ExecutorService service = Executors.newCachedThreadPool();
for (int i = 0; i < 1000; i++) {
service.execute(new Task() {
});
}

代码中使用 for 循环提交 1000 个任务给 CachedThreadPool,假设这些任务处理的时间非常长,会发生什么情况呢?因为 for 循环提交任务的操作是非常快的,但执行任务却比较耗时,就可能导致 1000 个任务都提交完了但第一个任务还没有被执行完,所以此时 CachedThreadPool 就可以动态的伸缩线程数量,随着任务的提交,不停地创建 1000 个线程来执行任务,而当任务执行完之后,假设没有新的任务了,那么大量的闲置线程又会造成内存资源的浪费,这时线程池就会检测线程在 60 秒内有没有可执行任务,如果没有就会被销毁,最终线程数量会减为 0。

ScheduledThreadPool

第三个线程池是 ScheduledThreadPool,它支持定时或周期性执行任务。比如每隔 10 秒钟执行一次任务,而实现这种功能的方法主要有 3 种,如代码所示:

1
2
3
4
5
6
7
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);

那么这 3 种方法有什么区别呢?

  1. 第一种方法 schedule 比较简单,表示延迟指定时间后执行一次任务,如果代码中设置参数为 10 秒,也就是 10 秒后执行一次任务后就结束。
  2. 第二种方法 scheduleAtFixedRate 表示以固定的频率执行任务,它的第二个参数 initialDelay 表示第一次延时时间,第三个参数 period 表示周期,也就是第一次延时后每次延时多长时间执行一次任务。
  3. 第三种方法 scheduleWithFixedDelay 与第二种方法类似,也是周期执行任务,区别在于对周期的定义,之前的 scheduleAtFixedRate 是以任务开始的时间为时间起点开始计时,时间到就开始执行第二次任务,而不管任务需要花多久执行;而 scheduleWithFixedDelay 方法以任务结束的时间为下一次循环的时间起点开始计时。

举个例子,假设某个同学正在熬夜写代码,需要喝咖啡来提神,假设每次喝咖啡都需要花10分钟的时间,如果此时采用第2种方法 scheduleAtFixedRate,时间间隔设置为 1 小时,那么他将会在每个整点喝一杯咖啡,以下是时间表:

00:00: 开始喝咖啡

00:10: 喝完了

01:00: 开始喝咖啡

01:10: 喝完了

02:00: 开始喝咖啡

02:10: 喝完了

但是假设他采用第3种方法 scheduleWithFixedDelay,时间间隔同样设置为 1 小时,那么由于每次喝咖啡需要10分钟,而 scheduleWithFixedDelay 是以任务完成的时间为时间起点开始计时的,所以第2次喝咖啡的时间将会在1:10,而不是1:00整,以下是时间表:

00:00: 开始喝咖啡

00:10: 喝完了

01:10: 开始喝咖啡

01:20: 喝完了

02:20: 开始喝咖啡

02:30: 喝完了

思考2个问题:

  1. 在FixedRate模式下,假设每秒触发,如果某次任务执行时间超过1秒,后续任务会不会并发执行?

看源码:

1
If any execution of this task takes longer than its period, then  subsequent executions may start late, but will not concurrently execute.

译:如果此任务的任何执行时间超过其周期,则后续执行可能会延迟开始,但不会并发执行。

  1. 如果任务抛出了异常,后续任务是否继续执行?

看源码:

1
If any execution of the task encounters an exception, subsequent executions are suppressed. 

译:如果任务的任何执行遇到异常,则将禁止后续任务的执行。

SingleThreadExecutor

第四种线程池是 SingleThreadExecutor,它会使用唯一的线程去执行任务,原理和 FixedThreadPool 是一样的,只不过这里线程只有一个,如果线程在执行任务的过程中发生异常,线程池也会重新创建一个线程来执行后续的任务。这种线程池由于只有一个线程,所以非常适合用于所有任务都需要按被提交的顺序依次执行的场景,而前几种线程池不一定能够保障任务的执行顺序等于被提交的顺序,因为它们是多线程并行执行的。

SingleThreadScheduledExecutor

第五个线程池是 SingleThreadScheduledExecutor,它实际和第三种 ScheduledThreadPool 线程池非常相似,它只是 ScheduledThreadPool 的一个特例,内部只有一个线程,如源码所示:

1
new ScheduledThreadPoolExecutor(1)

它只是将 ScheduledThreadPool 的核心线程数设置为了 1。

几种线程池对比

总结上述的五种线程池,我们以核心线程数、最大线程数,以及线程存活时间三个维度进行对比,如表格所示。

第一个线程池 FixedThreadPool,它的核心线程数和最大线程数都是由构造函数直接传参的,而且它们的值是相等的,所以最大线程数不会超过核心线程数,也就不需要考虑线程回收的问题,如果没有任务可执行,线程仍会在线程池中存活并等待任务。

第二个线程池 CachedThreadPool 的核心线程数是 0,而它的最大线程数是 Integer 的最大值,线程数一般是达不到这么多的,所以如果任务特别多且耗时的话,CachedThreadPool 就会创建非常多的线程来应对。