六种常见的线程池含ForkJoinPool(Java 8+)

六种常见的线程池含ForkJoinPool(Java 8+)

前言

在之前的文章 线程池使用及源码分析 中有提到过一部分,本章再进行详细的介绍,6 种常见的线程池如下:

  • FixedThreadPool
  • CachedThreadPool
  • ScheduledThreadPool
  • SingleThreadExecutor
  • SingleThreadScheduledExecutor
  • ForkJoinPool

1.FixedThreadPool

构造函数如下:

参数 nThreads: the number of threads in the pool

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

核心线程数和最大线程一样,都是 nThreads,可以将它看成是固定线程数的线程池,就算任务数超过了任务队列(workQueue)的最大限制,也不会创建新的线程来进行处理,而是会采取拒绝策略。

2.CachedThreadPool

构造函数如下:

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

缓存线程池,它的特点在于线程数是几乎可以无限增加的(实际最大可以达到 Integer.MAX_VALUE,为 231 -1 ,这个数非常大,所以基本不可能达到)。

而当线程闲置时还可以对线程进行回收,60秒后自动进行回收。也就是说该线程池的线程数量不是固定不变的,当然它也有一个用于存储提交任务的队列,但这个队列是 SynchronousQueue,队列的容量为0,实际不存储任何任务,它只负责对任务进行中转和传递,所以效率比较高。

示例:

public class CachedThreadPoolDemo {

   static ExecutorService executorService = Executors.newCachedThreadPool();//伸缩性,60s后回收

    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            executorService.execute(()->{
                System.out.println(Thread.currentThread().getName()+"执行");
            });
        }
    }
}

执行结果

pool-1-thread-1执行
pool-1-thread-3执行
pool-1-thread-2执行
pool-1-thread-4执行
pool-1-thread-5执行
pool-1-thread-6执行
pool-1-thread-7执行
pool-1-thread-8执行
pool-1-thread-9执行
pool-1-thread-10执行
pool-1-thread-11执行
pool-1-thread-12执行
pool-1-thread-13执行
pool-1-thread-14执行
...

循环提交 100 个任务给线程池执行,每个任务执行100毫秒,因为 for 循环执行是非常快的,导致第一个任务还没有执行完,那么线程池会继续创建线程来执行后续提交的任务。

而当任务执行完之后,假设没有新的任务了,那么大量的闲置线程又会造成内存资源的浪费,这时线程池就会检测线程在 60 秒内有没有可执行任务,如果没有就会被销毁,最终线程数量会减为 0。

3.ScheduledThreadPool

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

  • service.schedule(new Runnable(), 1, TimeUnit.SECONDS);
  • service.scheduleAtFixedRate(new Runnable(), 1, 1, TimeUnit.SECONDS);
  • service.scheduleWithFixedDelay(new Runnable(), 1, 1, TimeUnit.SECONDS);

三种方法的区别

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

前面两种测试

public class ScheduledThreadPoolDemo {

    static ScheduledExecutorService service = Executors.newScheduledThreadPool(10);

    public static void main(String[] args) throws InterruptedException {
        service.schedule(() -> {
            System.out.println("定时线程执行1:" + LocalDateTime.now());
        }, 1, TimeUnit.SECONDS);


        service.scheduleAtFixedRate(() -> {
            System.out.println("定时线程执行2:" + LocalDateTime.now());
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, 1, 1, TimeUnit.SECONDS);

    }
}

执行结果

定时线程执行2:2020-06-24T11:27:02.726
定时线程执行1:2020-06-24T11:27:02.726
定时线程执行2:2020-06-24T11:27:03.699
定时线程执行2:2020-06-24T11:27:04.699
定时线程执行2:2020-06-24T11:27:05.700
定时线程执行2:2020-06-24T11:27:06.700
定时线程执行2:2020-06-24T11:27:07.699

可以看到第一种只执行一次,第二种每一秒执行一次。

第三种测试

public class ScheduledThreadPoolDemo {

    static ScheduledExecutorService service = Executors.newScheduledThreadPool(10);

    public static void main(String[] args) throws InterruptedException {
        service.scheduleWithFixedDelay(() -> {
            System.out.println("定时线程执行3:" + LocalDateTime.now());
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, 1, 1, TimeUnit.SECONDS);
    }
}

执行结果

定时线程执行3:2020-06-24T11:32:30.871
定时线程执行3:2020-06-24T11:32:32.872
定时线程执行3:2020-06-24T11:32:34.873
定时线程执行3:2020-06-24T11:32:36.873
定时线程执行3:2020-06-24T11:32:38.874
定时线程执行3:2020-06-24T11:32:40.874

每 2 秒执行一次,和第二种(scheduleAtFixedRate)不一样的,第二种(scheduleAtFixedRate)是不论线程执行是否完成,都是每 1 秒执行一次,而 scheduleWithFixedDelay 必须等上一次任务执行完成。

4.SingleThreadExecutor

构造函数如下:

        return new FinalizableDelegatedExecutorService
            (new ThreadPoolExecutor(1, 1,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>()));

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

5.SingleThreadScheduledExecutor

相关创建的源码

    public static ScheduledExecutorService newSingleThreadScheduledExecutor() {
        return new DelegatedScheduledExecutorService
            (new ScheduledThreadPoolExecutor(1));
    }

它实际和第三种 ScheduledThreadPool 线程池非常相似,它只是 ScheduledThreadPool 的一个特例,内部只有一个线程。

6.五种线程池对比

线程池 核心线程数 最大线程数 空闲线程存活时间
FixedThreadPool 构造器传入 构造器传入 0
CachedThreadPool 0 Integer.MAX_VALUE 60秒
ScheduledThreadPool 构造器传入 Integer.MAX_VALUE 0
SingleThreadExecutor 1 1 0
SingleThreadScheduledExecutor 1 Integer.MAX_VALUE 0

7.ForkJoinPool

ForkJoinPool 线程池在 JDK 8 加入,主要用法和之前的线程池是相同的,也是把任务交给线程池去执行,线程池中也有任务队列来存放任务,和之前的五种线程池不同的是,它非常适合执行可以分解子任务的任务,比如树的遍历,归并排序,或者其他一些递归场景。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1Z6pHkti-1592980328694)(G:\workspace\csdn\learn-document\java\concurrent\image-20200624114720538.png)]

采用的分治思想,可以结合 归并排序算法 进行学习,参考 数据结构与算法|第九章:排序-中

如图所示,我们有一个 Task,这个 Task 可以产生三个子任务,三个子任务并行执行完毕后将结果汇总给 Result,比如说主任务需要执行非常繁重的计算任务,我们就可以把计算拆分成三个部分,这三个部分是互不影响相互独立的,这样就可以利用 CPU 的多核优势,并行计算,然后将结果进行汇总。这里面主要涉及两个步骤,第一步是拆分也就是 Fork,第二步是汇总也就是 Join,到这里我们应该已经了解到 ForkJoinPool 线程池名字的由来了。

7.1 斐波那契数列

递归相关的基础知识可以参考 数据结构与算法|第七章:递归

这个数列的特点就是后一项的结果等于前两项的和,第 0 项是 0,第 1 项是 1,那么第 2 项就是 0+1=1,以此类推。

0、1、1、2、3、5、8、13…

递推公式如下:

f(n) = f(n-1)+f(n-2);

终止条件:n<=1

根据递推公式的伪代码如下:

        protected Integer f(int n) {
            if (n <= 1) {
                return n;
            }
            int a = f(n - 1);
            int b = f(n - 2);
            return a + b;
        }

7.2 ForkJoinPool 代码实现

public class ForkJoinPoolDemo {

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ForkJoinPool forkJoinPool = new ForkJoinPool();
        for (int i = 0; i < 10; i++) {
            ForkJoinTask task = forkJoinPool.submit(new Fibonacci(i));
            System.out.println(task.get());
        }
    }

    static class Fibonacci extends RecursiveTask<Integer> {

        int n;

        public Fibonacci(int n) {
            this.n = n;
        }

        @Override
        protected Integer compute() {
            if (n <= 1) {
                return n;
            }
            Fibonacci fib1 = new Fibonacci(n - 1);
            fib1.fork();
            Fibonacci fib2 = new Fibonacci(n - 2);
            fib2.fork();
            return fib1.join() + fib2.join();
        }
    }

}

执行结果:

0
1
1
2
3
5
8
13
21
34

对比归并算法,fork 的过程就相当于 拆分 的过程,join 的过程就相当于 合并 的过程。

7.3 ForkJoinPool 中的任务队列

前面五种线程池,线程使用都是的同一个任务队列(workQueue),但是 ForkJoinPool 线程池中每个线程都有自己独立的任务队列。

ForkJoinPool 线程池内部除了有一个共用的任务队列之外,每个线程还有一个对应的双端队列 deque,这时一旦线程中的任务被 Fork 分裂了,分裂出来的子任务放入线程自己的 deque 里,而不是放入公共的任务队列中。如果此时有三个子任务放入线程 t1 的 deque 队列中,对于线程 t1 而言获取任务的成本就降低了,可以直接在自己的任务队列中获取而不必去公共队列中争抢也不会发生阻塞( steal 情况除外),减少了线程间的竞争和切换,是非常高效的。

deque 双端队列

deque 是一种具有 队列 的性质的数据结构。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-eNYrj7QH-1592980328696)(G:\workspace\csdn\learn-document\java\concurrent\image-20200624142043501.png)]
work-stealing 是什么?

假设此时线程有多个任务,线程 t1 的任务特别繁重,分裂了数十个子任务,但是 t0 此时却无事可做,它自己的 deque 队列为空,这时为了提高效率,t0 就会想办法帮助 t1 执行任务,这就是 work-stealing
在这里插入图片描述
双端队列 deque 中,线程 t1 获取任务的逻辑是后进先出(栈的特点),也就是LIFO(Last In Frist Out),而线程 t0 在 steal 偷线程 t1 的 deque 中的任务的逻辑是先进先出(队列的特点),也就是FIFO(Fast In Frist Out),如图所示,图中很好的描述了两个线程使用双端队列分别获取任务的情景。你可以看到,使用 work-stealing 算法和双端队列很好地平衡了各线程的负载。

8.参考

  • 《Java 并发编程 78 讲》- 徐隆曦
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章