一、6 种常见的线程池
6 种常见的线程池:
FixedThreadPool;
CachedThreadPool;
ScheduledThreadPool;
SingleThreadExecutor;
SingleThreadScheduledExecutor;
ForkJoinPool。
FixedThreadPool
第一种线程池叫作 FixedThreadPool,它的核心线程数和最大线程数是一样的,所以可以把它看作是固定线程数的线程池,它的特点是线程池中的线程数除了初始阶段需要从 0 开始增加外,之后的线程数量就是固定的,就算任务数超过线程数,线程池也不会再创建更多的线程来处理任务,而是会把超出线程处理能力的任务放到任务队列中进行等待。而且就算任务队列满了,到了本该继续增加线程数的时候,由于它的最大线程数和核心线程数是一样的,所以也无法再增加新的线程了。
如图所示,线程池有 t0~t9,10 个线程,它们会不停地执行任务,如果某个线程任务执行完了,就会从任务队列中获取新的任务继续执行,期间线程数量不会增加也不会减少,始终保持在 10 个。
CachedThreadPool
第二种线程池是 CachedThreadPool,可以称作可缓存线程池,它的特点在于线程数是几乎可以无限增加的(实际最大可以达到 Integer.MAX_VALUE,为 2^31-1,这个数非常大,所以基本不可能达到),而当线程闲置时还可以对线程进行回收。也就是说该线程池的线程数量不是固定不变的,当然它也有一个用于存储提交任务的队列,但这个队列是 SynchronousQueue,队列的容量为0,实际不存储任何任务,它只负责对任务进行中转和传递,所以效率比较高。当我们提交一个任务后,线程池会判断已创建的线程中是否有空闲线程,如果有空闲线程则将任务直接指派给空闲线程,如果没有空闲线程,则新建线程去执行任务,这样就做到了动态地新增线程。让我们举个例子,如下方代码所示。
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 种,如代码所示:
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 与第二种方法类似,也是周期执行任务,区别在于对周期的定义,之前的 scheduleAtFixedRate 是以任务开始的时间为时间起点开始计时,时间到就开始执行第二次任务,而不管任务需要花多久执行;而 scheduleWithFixedDelay 方法以任务结束的时间为下一次循环的时间起点开始计时。
SingleThreadExecutor
第四种线程池是 SingleThreadExecutor,它会使用唯一的线程去执行任务,原理和 FixedThreadPool 是一样的,只不过这里线程只有一个,如果线程在执行任务的过程中发生异常,线程池也会重新创建一个线程来执行后续的任务。这种线程池由于只有一个线程,所以非常适合用于所有任务都需要按被提交的顺序依次执行的场景,而前几种线程池不一定能够保障任务的执行顺序等于被提交的顺序,因为它们是多线程并行执行的。
SingleThreadScheduledExecutor
第五个线程池是 SingleThreadScheduledExecutor,它实际和第三种 ScheduledThreadPool 线程池非常相似,它只是 ScheduledThreadPool 的一个特例,内部只有一个线程,如源码所示:
new ScheduledThreadPoolExecutor(1)
它只是将 ScheduledThreadPool 的核心线程数设置为了 1。
总结上述的五种线程池,我们以核心线程数、最大线程数,以及线程存活时间三个维度进行对比,如表格所示。第一个线程池 FixedThreadPool,它的核心线程数和最大线程数都是由构造函数直接传参的,而且它们的值是相等的,所以最大线程数不会超过核心线程数,也就不需要考虑线程回收的问题,如果没有任务可执行,线程仍会在线程池中存活并等待任务。第二个线程池 CachedThreadPool 的核心线程数是 0,而它的最大线程数是 Integer 的最大值,线程数一般是达不到这么多的,所以如果任务特别多且耗时的话,CachedThreadPool 就会创建非常多的线程来应对。
ForkJoinPool
我们来看下第六种线程池 ForkJoinPool,这个线程池是在 JDK 7 加入的,它的名字 ForkJoin 也描述了它的执行机制,主要用法和之前的线程池是相同的,也是把任务交给线程池去执行,线程池中也有任务队列来存放任务。但是 ForkJoinPool 线程池和之前的线程池有两点非常大的不同之处。第一点它非常适合执行可以产生子任务的任务。
如图所示,我们有一个 Task,这个 Task 可以产生三个子任务,三个子任务并行执行完毕后将结果汇总给 Result,比如说主任务需要执行非常繁重的计算任务,我们就可以把计算拆分成三个部分,这三个部分是互不影响相互独立的,这样就可以利用 CPU 的多核优势,并行计算,然后将结果进行汇总。这里面主要涉及两个步骤,第一步是拆分也就是 Fork,第二步是汇总也就是 Join,到这里你应该已经了解到 ForkJoinPool 线程池名字的由来了。
举个例子,比如面试中经常考到的菲波那切数列,这个数列的特点就是后一项的结果等于前两项的和,第 0 项是 0,第 1 项是 1,那么第 2 项就是 0+1=1,以此类推。我们在写代码时应该首选效率更高的迭代形式或者更高级的乘方或者矩阵公式法等写法,不过假设我们写成了最初版本的递归形式,伪代码如下所示:
if (n <= 1) {
return n;
} else {
Fib f1 = new Fib(n - 1);
Fib f2 = new Fib(n - 2);
f1.solve();
f2.solve();
number = f1.number + f2.number;
return number;
}
可以看到如果 n<=1 则直接返回 n,如果 n>1 ,先将前一项 f1 的值计算出来,然后往前推两项求出 f2 的值,然后将两值相加得到结果,所以我们看到在求和运算中产生了两个子任务。计算 f(4) 的流程如下图所示。
在计算 f(4) 时需要首先计算出 f(2) 和 f(3),而同理,计算 f(3) 时又需要计算 f(1) 和 f(2),以此类推。
这是典型的递归问题,对应到我们的 ForkJoin 模式,如图所示,子任务同样会产生子子任务,最后再逐层汇总,得到最终的结果。
ForkJoinPool 线程池有多种方法可以实现任务的分裂和汇总,其中一种用法如下方代码所示。
class Fibonacci extends RecursiveTask<Integer> {
int n;
public Fibonacci(int n) {
this.n = n;
}
@Override
public Integer compute() {
if (n <= 1) {
return n;
}
Fibonacci f1 = new Fibonacci(n - 1);
f1.fork();
Fibonacci f2 = new Fibonacci(n - 2);
f2.fork();
return f1.join() + f2.join();
}
}
我们看到它首先继承了 RecursiveTask,RecursiveTask 类是对ForkJoinTask 的一个简单的包装,这时我们重写 compute() 方法,当 n<=1 时直接返回,当 n>1 就创建递归任务,也就是 f1 和 f2,然后我们用 fork() 方法分裂任务并分别执行,最后在 return 的时候,使用 join() 方法把结果汇总,这样就实现了任务的分裂和汇总。
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());
}
}
上面这段代码将会打印出斐波那契数列的第 0 到 9 项的值:
0
1
1
2
3
5
8
13
21
34
这就是 ForkJoinPool 线程池和其他线程池的第一点不同。
我们来看第二点不同,第二点不同之处在于内部结构,之前的线程池所有的线程共用一个队列,但 ForkJoinPool 线程池中每个线程都有自己独立的任务队列,如图所示。
ForkJoinPool 线程池内部除了有一个共用的任务队列之外,每个线程还有一个对应的双端队列 deque,这时一旦线程中的任务被 Fork 分裂了,分裂出来的子任务放入线程自己的 deque 里,而不是放入公共的任务队列中。如果此时有三个子任务放入线程 t1 的 deque 队列中,对于线程 t1 而言获取任务的成本就降低了,可以直接在自己的任务队列中获取而不必去公共队列中争抢也不会发生阻塞(除了后面会讲到的 steal 情况外),减少了线程间的竞争和切换,是非常高效的。
我们再考虑一种情况,此时线程有多个,而线程 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” 算法和双端队列很好地平衡了各线程的负载。
最后,我们用一张全景图来描述 ForkJoinPool 线程池的内部结构,你可以看到 ForkJoinPool 线程池和其他线程池很多地方都是一样的,但重点区别在于它每个线程都有一个自己的双端队列来存储分裂出来的子任务。ForkJoinPool 非常适合用于递归的场景,例如树的遍历、最优路径搜索等场景。
二、线程池的线程数设计
2、1 CPU密集型:服务器几核CPU,就定义最大线程池数,如12核CPU就定义线程池数最大数量为12,可以保证CPU利用效率最高.
2、2 IO密集型:判断程序中十分消耗IO的程序,大于这个就可以
2、3 获取cpu核数的方法:
2.3.1 java执行下面的代码。
Runtime.getRuntime().availableProcessors()
2.3.2 打开任务管理器电脑的CPU,会显示几核
三、线程池的拒绝策略
前提:达到最大线程数,并且队列已经满了,根据业务选择策略一般都是默认。 3、1 AbortPolicy() --(默认策略)// 抛出异常,中止任务。需要try{}catch(){}处理。 3、2 CallerRunsPolicy() // 哪个线程调用的,回到哪个线程 3、3 DiscardPolicy() // 丢掉任务,不会抛出异常 3、4 DiscardOldestPolicy() // 丢掉任务队列中最老的任务(相当排在第一个要执行的任务),不会抛出异常
示例:
@Bean
public Executor taskAsync() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(corePoolSize);
executor.setMaxPoolSize(maxPoolSize);
executor.setQueueCapacity(queueCapacity);
executor.setThreadNamePrefix(threadNamePrefix);
// 设置拒绝策略
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
四、线程池参数
4.1 corePoolSize:指定了线程池中的线程数量,它的数量决定了添加的任务是开辟新的线程去执行,还是放到workQueue任务队列中去;
4.2 maximumPoolSize:指定了线程池中的最大线程数量,这个参数会根据你使用的workQueue任务队列的类型,决定线程池会开辟的最大线程数量;
4.3 keepAliveTime:当线程池中空闲线程数量超过corePoolSize时,多余的线程会在多长时间内被销毁;
4.4 unit:keepAliveTime的单位
4.5 workQueue:任务队列,被添加到线程池中,但尚未被执行的任务;它一般分为直接提交队列、有界任务队列、无界任务队列、优先任务队列几种,LinkedBlockingQueue
是线程池默认使用的任务队列。
4.6 threadFactory:线程工厂,用于创建线程,一般用默认即可;
4.7 handler:拒绝策略;当任务太多来不及处理时,如何拒绝任务;
示例:
@Configuration
@EnableAsync
public class TaskExecutorConfig {
//核心线程数
private int corePoolSize = 10;
//最大线程数
private int maxPoolSize = 50;
//队列长度
private int queueCapacity = 10;
//线程名前缀
private String threadNamePrefix = "AsyncTask-";
@Bean
public Executor taskAsync() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(corePoolSize);
executor.setMaxPoolSize(maxPoolSize);
executor.setQueueCapacity(queueCapacity);
executor.setThreadNamePrefix(threadNamePrefix);
// 拒绝策略
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
五、线程池队列
1、ArrayBlockingQueue
是一个基于数组结构的有界阻塞队列,此队列按 FIFO(先进先出)原则对元素进行排序。
- ArrayBlockingQueue 在生产者放入数据和消费者获取数据,都是共用同一个锁对象,
- 由此也意味着两者无法真正并行运行
- 这点尤其不同于 LinkedBlockingQueue
2、LinkedBlockingQueue
一个基于链表结构的阻塞队列,此队列按 FIFO (先进先出) 排序元素,吞吐量通常要高于 ArrayBlockingQueue。
- 静态工厂方法 Executors.newFixedThreadPool () 使用了这个队列
3、SynchronousQueue
一个不存储元素的阻塞队列。
- 每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,
- 吞吐量通常要高于 LinkedBlockingQueue,
- 静态工厂方法 Executors.newCachedThreadPool(5)使用了这个队列。
4、PriorityBlockingQueue
- 一个具有优先级的无限阻塞队列。
- PriorityBlockingQueue 也是基于最小二叉堆实现
- 数组实现的最小堆
- 使用基于 CAS 实现的自旋锁来控制队列的动态扩容,
- 保证了扩容操作不会阻塞 take 操作的执行
- 不扩容就是正常的获取锁之后加入元素
- PriorityBlockingQueue 扩容时,
- 因为增加堆数组的长度并不影响队列中元素的出队操作,
- 因而使用自旋 CAS 操作实现的锁来控制扩容操作,
- 仅在数组引用替换和拷贝元素时才加锁,
- 从而减少了扩容对出队操作的影响
DelayQueue
- 只有当其指定的延迟时间到了,才能够从队列中获取到该元素。
- DelayQueue 是一个没有大小限制的队列,
- 因此往队列中插入数据的操作(生产者)永远不会被阻塞,而只有获取数据的操作(消费者)才会被阻塞。
- 使用场景:
- elayQueue 使用场景较少,但都相当巧妙,
- 常见的例子比如使用一个 DelayQueue 来管理一个超时未响应的连接队列。
六、线程池的执行过程
以下是参考原文(记录学习),如有问题请联系,祝各位码到功成!!!
1、6种线程池原文地址:https://www.modb.pro/db/136334
2、线程池参数原文链接:https://blog.csdn.net/u011208600/article/details/105199451/
3、线程池队列原文链接:线程池都有哪几种工作队列 - Java垒墙工程师的个人空间 - OSCHINA - 中文开源技术交流社区