目录
介绍
线程池主要解决两个问题,一是当执行大量异步任务时线程池能够提供较好的性能,当新任务来时不需要反复的new线程。二是线程池提供了资源限制和管理手段,比如动态新增线程等。
类图
从类图中可以看出ThreadPoolExecutor实现了Executor接口而我们所熟知的Executors实际上是一个工具类,提供了静态方法方便开发人员创建线程
线程池状态
基本状态
- RUNNING:接受新任务并且处理阻塞队列里的任务
- SHUTDOWN:拒绝新任务但是处理阻塞队列里的任务
- STOP:拒绝新任务并且抛弃阻塞队列里的任务,同时中断正在处理的任务。
- TIDYING:所有任务都执行完(包括阻塞队列里面的任务)后当前线程池活动线程数为0,将要调用terminate方法。
- TERMINATED:终止状态。terminated方法调用完成以后的状态。
状态流转
核心参数
执行流程
拒绝策略
AbortPolicy
种拒绝策略在拒绝任务时,会直接抛出一个类型为 RejectedExecutionException 的 RuntimeException,让提交者感知到任务被拒绝了,于是便可以根据业务逻辑选择重试或者放弃提交等策略。
DiscardPolicy
这种拒绝策略正如它的名字所描述的一样,当新任务被提交后直接被丢弃掉,也不会给任的通知,相对而言存在一定的风险,因为我们提交的时候根本不知道这个任务会被丢弃,可能造成数据丢失。
DiscardOldestPolicy
如果线程池没被关闭且没有能力执行,则会丢弃任务队列中的头结点,通常是存活时间最长的任务,这种策略与第二种不同之处在于它丢弃的不是最新提交的,而是队列中存活时间最长的,这样就可以腾出空间给新提交的任务,但同理它也存在一定的数据丢失风险。
CallerRunsPolicy
这种策略比较完善,当有新任务提交后,如果线程池没被关闭且没有能力执行,则把这个任务交于提交任务的线程执行,也就是谁提交任务,谁就负责执行任务。这样做主要有两点好处。
- 第一点,新提交的任务不会被丢弃,这样也就不会造成业务损失。
- 第二点,由于谁提交任务谁就要负责执行任务,这样提交任务的线程就得负责执行任务,而执行任务又是比较耗时的,在这段期间,提交任务的线程被占用,也就不会再提交新的任务,减缓了任务提交的速度,相当于是一个负反馈。在此期间,线程池中的线程也可以充分利用这段时间来执行掉一部分任务,腾出一定的空间,相当于是给了线程池一定的缓冲期。
常见线程池工具
Executors为我们提供了6种常见的线程池工具
FixedThreadPool
CachedThreadPool
ScheduledThreadPool
SingleThreadExecutor
SingleThreadScheduledExecutor
ForkJoinPool
FixedThreadPool
它的核心线程数和最大线程数都是由构造函数直接传参的,而且它们的值是相等的,所以最大线程数不会超过核心线程数,也就不需要考虑线程回收的问题,如果没有任务可执行,线程仍会在线程池中存活并等待任务。
CachedThreadPool
CachedThreadPool 的核心线程数是 0,而它的最大线程数是 Integer 的最大值,线程数一般是达不到这么多的,所以如果任务特别多且耗时的话,CachedThreadPool 就会创建非常多的线程来应对。
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
它会使用唯一的线程去执行任务,原理和 FixedThreadPool 是一样的,只不过这里线程只有一个,如果线程在执行任务的过程中发生异常,线程池也会重新创建一个线程来执行后续的任务。这种线程池由于只有一个线程,所以非常适合用于所有任务都需要按被提交的顺序依次执行的场景,而前几种线程池不一定能够保障任务的执行顺序等于被提交的顺序,因为它们是多线程并行执行的。
SingleThreadScheduledExecutor
它实际和第三种 ScheduledThreadPool 线程池非常相似,它只是 ScheduledThreadPool 的一个特例,内部只有一个线程,如源码所示:
new ScheduledThreadPoolExecutor(1)
以上5种线程池参数设置如下:
对应的阻塞队列如下:
ForkJoinPool
概念
是JDK1.7加入的新的线程池实现,体现了分治思想,适用于能够进行任务拆分的cpu密集型运算,所谓任务拆分就是将一个大任务拆分为算法上相同的小任务,直至不能拆分可以直接求解,forkJoin默认会创建与CPU核心数大小相同的线程池。
使用
提交给forkJoin线程池的任务需要继承RecursiveTask(有返回值)或Recursive(没有返回值),代码如下
public class ForkJoinTest {
public static void main(String[] args) {
ForkJoinPool forkJoinPool = new ForkJoinPool();
System.out.println(forkJoinPool.invoke(new MyTask(5)));
}
}
class MyTask extends RecursiveTask<Integer>{
private int n;
public MyTask(int n) {
this.n = n;
}
@Override
protected Integer compute() {
// 终止条件
if(n==1){
return 1;
}
// 拆分成Task(4)
MyTask t1 = new MyTask(n-1);
t1.fork();// 让一个线程去执行此任务
return n + t1.join();
}
}
工作流程如下:
上面的过程并没有体现出太明显的拆分优势,每个线程都需要等另一个线程的结果,所以改进代码如下
public class ForkJoinTest01 {
public static void main(String[] args) {
ForkJoinPool forkJoinPool = new ForkJoinPool();
System.out.println(forkJoinPool.invoke(new MyTask01(1, 5)));
}
}
class MyTask01 extends RecursiveTask<Integer> {
private int begin;
private int end;
public MyTask01(int begin, int end) {
this.begin = begin;
this.end = end;
}
@Override
protected Integer compute() {
// 终止条件 5,5
if (begin == end) {
return begin;
}
// 5 4
if (end - begin == 1) {
return end + begin;
}
int mid = (end + begin) / 2;
MyTask01 task01 = new MyTask01(begin, mid);
task01.fork();
MyTask01 task02 = new MyTask01(mid + 1, end);
task02.fork();
return task01.join() + task02.join();
}
}
工作流程如下:
从上图可以看出,一个大任务确实分成了多个子任务完成了并行计算。
原理分析
- 独立队列&&双端队列
之前的线程池所有的线程共用一个队列,但 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 非常适合用于递归的场景,例如树的遍历、最优路径搜索等场景。
自动创建线程池弊端
- FixedThreadPool 队列用的是LinkedBlockingQueue 如果处理任务比较慢,随着请求增多,队列堆积的任务也会越来越多,可能引发OOM。
- SingleThreadExecutor 核心线程数和最大线程数都是1,队列仍然用的无界的LinkedBlockingQueue,可能引发OOM异常。
- CachedThreadPool SynchronousQueue并不存储任务,而是对任务直接进行转发本身没有问题,但是最大线程数是Integer.MAX_VALUE,当请求不断增多可能会导致创建非常多的线程,最终超过了操作系统的上限而无法创建新的线程,或导致内存不足。
- ScheduledThreadPool 和 SingleThreadScheduledExecutor 都是采用的DelayedWorkQueue,这是一个无界队列,同样可能会引发OOM。
关闭线程池
- shutdown() 安全的关闭线程池,使线程池不再接收新任务,但是工作队列的还会继续执行,该方法立即返回
- isShutdown() 判断是否已经开始了关闭工作,返回true表示开始了关闭流程
- isTerminated() 检测线程池是否真正“终结”了,这不仅代表线程池已关闭,同时代表线程池中的所有任务都已经都执行完毕了
- awaitTermination() 当调用awaitTermination方法后,当前线程会被阻塞,直到线程池状态变为TERMINATION才返回,或者等待时间超时才返回。
- shutdowNow() 立即关闭,线程池不再接收新任务且丢弃工作队列里面的任务,正在执行的任务也会被中断。
定制自己的线程池
参数设置
- 核心线程数
- CPU密集型:设置为CPU线程线程的1~2倍,不宜过多,CPU的计算任务比较繁重,如果设置过多的线程数,会造成不必要的上下文切换,此时线程数的增多并没有让性能提升,反而由于线程数量过多会导致性能下降。
- IO密集型:这种任务的特点是并不会特别消耗 CPU 资源,但是 IO 操作很耗时,总体会占用比较多的时间。推荐算法为线程数 = CPU 核心数 *(1+平均等待时间/平均工作时间),如果任务的平均等待时间长,线程数就随之增加,而如果平均工作时间长,线程数就随之减少。
- 阻塞队列
- CPU密集型:可以使用容量更大的队列和更小的最大线程数,来减少上下文切换带来的开销
- IO 密集型:可以选择稍小容量的队列和更大的最大线程数,这样整体的效率就会更高
- 线程工厂 可以使用默认的 defaultThreadFactory,也可以传入自定义的有额外能力的线程工厂,比如可以通过com.google.common.util.concurrent.ThreadFactoryBuilder来实现
- 拒绝策略 可以使用之前提到的4中拒绝策略,也可以实现rejectedExecution自定义拒绝侧策略来满足业务需求。
具体实现
-- Java
ThreadPoolExecutor executor = new ThreadPoolExecutor(5, 50,
20, TimeUnit.SECONDS, new ArrayBlockingQueue(5000));
-- SpringBoot
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>30.1-jre</version>
</dependency>
@Configuration
@EnableAsync
@Data
public class ExecutorConfig {
private int corePoolSize = 3;
private int maxPoolSize = 10;
private int queueCapacity = 1000;
private int keepAliveSeconds = 10;
@Bean(name = "taskExecutor")
public Executor asyncServiceExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
//配置核心线程数
executor.setCorePoolSize(corePoolSize);
//配置最大线程数
executor.setMaxPoolSize(maxPoolSize);
//配置队列大小
executor.setQueueCapacity(queueCapacity);
//线程名前缀
//executor.setThreadNamePrefix("xxx");
//setNameFormat为线程名格式化方式(与上面设置冲突,二者取其一,区别见日志输出)
//这里也可以用默认工厂
executor.setThreadFactory(new ThreadFactoryBuilder().setNameFormat("XX-task-%d").build());
//配置线程池中线程所允许的空闲时间
executor.setKeepAliveSeconds(keepAliveSeconds);
// rejection-policy:当pool已经达到max size的时候,如何处理新任务
// CALLER_RUNS:不在新线程中执行任务,而是有调用者所在的线程来执行
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
//执行初始化
executor.initialize();
return executor;
}
}
调用:
@Resource(name = "taskExecutorXXX")
private ThreadPoolTaskExecutor taskExecutor;
@RequestMapping("/testTaskExecutor")
public String testTaskExecutor(){
taskExecutor.submit(new Callable<Object>() {
@Override
public Object call() throws Exception {
String threadName = Thread.currentThread().getName();
logger.info(threadName);
return null;
}
});
return "xxx";
}
日志输出
--生效 executor.setThreadNamePrefix("xxx");
2021-09-26 21:54:57.530 [xxx1] INFO c.z.crm.springboot.dubbo.web.web.HelloController - xxx1
--生效 executor.setThreadFactory(new ThreadFactoryBuilder().setNameFormat("XX-task-%d").build());
2021-09-26 21:56:13.150 [XX-task-0] INFO c.z.crm.springboot.dubbo.web.web.HelloController - XX-task-0
-- Springmvc:
xml文件:
<!-- 配置线程池>
<bean id="taskExecutor" class="org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor">
<!-- 线程池维护线程的最少数量 -->
<property name="corePoolSize" value="100"/>
<!-- 线程池维护线程所允许的空闲时间 -->
<property name="keepAliveSeconds" value="1800"/>
<!-- 线程池维护线程的最大数量 -->
<property name="maxPoolSize" value="500"/>
<!-- 线程池所使用的缓冲队列 -->
<property name="queueCapacity" value="200"/>
</bean>
调用方式
@Autowired
private ThreadPoolTaskExecutor taskExecutor;
线程池核心源码解读
ThreadPoolExecutor类中的execute作用是提交任务command到线程池进行执行,用户线程提交任务的execute方法的具体代码如下
public void execute(Runnable command) {
// 如果任务为空,抛出NPE
if (command == null)
throw new NullPointerException();
// 获取当前线程池的状态+线程个数变量的组合值
int c = ctl.get();
// 当前线程池中线程个数小于corePoolSize 则开启新线程运行
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}
// 如果处于RUNNING状态,则添加任务到阻塞队列
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
// 如果不是RUNNING则从队列中删除任务,并执行拒绝策略
if (! isRunning(recheck) && remove(command))
reject(command);
// 如果当前线程池为空,则添加一个线程
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
// 如果队列满,则新增线程,新增失败执行拒绝策略
else if (!addWorker(command, false))
reject(command);
}
在 execute 方法中,多次调用 addWorker 方法把任务传入,addWorker 方法会添加并启动一个 Worker,这里的 Worker 可以理解为是对 Thread 的包装,Worker 内部有一个 Thread 对象,它正是最终真正执行任务的线程,所以一个 Worker 就对应线程池中的一个线程,addWorker 就代表增加线程。线程复用的逻辑实现主要在 Worker 类中的 run 方法里执行的 runWorker 方法中,简化后的 runWorker 方法代码如下所示。
runWorker(Worker w) {
Runnable task = w.firstTask;
while (task != null || (task = getTask()) != null) {
try {
task.run();
} finally {
task = null;
}
}
}
可以看出,实现线程复用的逻辑主要在一个不停循环的 while 循环体中。
- 通过取 Worker 的 firstTask 或者通过 getTask 方法从 workQueue 中获取待执行的任务。
- 直接调用 task 的 run 方法来执行具体的任务(而不是新建线程)。
在这里,我们找到了最终的实现,通过取 Worker 的 firstTask 或者 getTask方法从 workQueue 中取出了新任务,并直接调用 Runnable的 run 方法来执行任务,也就是如之前所说的,每个线程都始终在一个大循环中,反复获取任务,然后执行任务,从而实现线程的复用。
参考资料:《Java并发78讲》
《Java并发编程之美》