线程池ThreadPoolExecutor工作原理超详细解析

1,278 阅读10分钟

线程池基础

线程池概述

一种线程使用模式。线程过多会带来调度开销, 进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理 者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。

线程池的优势

线程池做的工作只要是控制运行的线程数量,处理过程中将任务放入队列,然后在线程创建后启动这些任务,如果线程数量超过了最大数量,超出数量的线程排队等候,等其他线程执行完毕,再从队列中取出任务来执行。

• 降低资源消耗: 通过重复利用已创建的线程降低线程创建和销毁造成的销耗。

• 提高响应速度: 当任务到达时,任务可以不需要等待线程创建就能立即执行。

• 提高线程的可管理性: 线程是稀缺资源,如果无限制的创建,不仅会销耗系统资 源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

Java 中的线程池是通过 Executor 框架实现的,该框架中用到了 Executor,Executors, ExecutorService,ThreadPoolExecutor 这几个类。

ThreadPoolExecutor类常用参数:

• corePoolSize :常驻线程数量(核心), 线程池的核心线程数

• maximumPoolSize 能容纳的最大线程数

• keepAliveTime 空闲线程存活时间

• unit 存活的时间单位

• workQueue 存放提交但未执行任务的队列

• threadFactory 创建线程的工厂类

• handler 等待队列满后的拒绝策略

执行流程

ThreadPoolExecutor中提供了两种执行任务的方法:

  1. void execute(Runnable command)
  2. Future submit(Runnable task)

实际上submit中最终还是调用的execute()方法,只不过会返回一个Future对象,用来获取任务执行结果。

public Future<?> submit(Runnable task) 
{
    if (task == null) throw new NullPointerException();
    RunnableFuture<Void> ftask = newTaskFor(task, null); 
    execute(ftask); 
    return ftask; 
}

image.png

1.在创建了线程池后,线程池中的线程数为零

2.当调用 execute()方法添加一个请求任务时,线程池会做出如下判断:

2.1 如果正在运行的线程数量小于 corePoolSize,那么马上创建线程运行这个任务;

2.2 如果正在运行的线程数量大于或等于 corePoolSize,那么将这个任务放入 队列;

2.3 如果这个时候队列满了且正在运行的线程数量还小于 maximumPoolSize,那么还是要创建非核心线程立刻运行这个任务;

2.4 如 果队列满了且正在运行的线程数量大于或等于 maximumPoolSize,那么线程 池会启动饱和拒绝策略来执行。

3.当一个线程完成任务时,它会从队列中取下一个任务来执行

4.当一个线程无事可做超过一定的时间(keepAliveTime)时,线程会判断:

4.1 如果当前运行的线程数大于 corePoolSize,那么这个线程就被停掉。

4.2 所以线程池的所有任务完成后,它最终会收缩到 corePoolSize 的大小。

注意:

1.只有在执行execute方法的时候线程才会创建。但是tomcat自己实现的线程池,创建线程池的时候会创建线程,达到预热的目的。

2.ThreadPoolExecutor相当于是非公平的,比如队列满了之后提交的Runnable可能会比正在排队的Runnable先执行。 3. 提交一个Runnable时,不管当前线程池中的线程是否空闲,只要数量小于核心线程数就会创建新线程。

四种拒绝策略:

image.png

线程池分类:

1.Executors.newFixedThreadPool(int) 一池N线程

2.Executors.newSingleThreadExecutor() 一池一线程

3.Executors.newCachedThreadPool() 线程池根据需求创建线程,可扩容,遇强则强

三种线程池的创建方式,实际中我们都不使用,我们都会自定义线程池。

线程池的五种状态是如何流转的?

线程池有五种状态:

  • RUNNING:会接收新任务并且会处理队列中的任务
  • SHUTDOWN:不会接收新任务并且会处理队列中的任务
  • STOP:不会接收新任务并且不会处理队列中的任务,并且会中断在处理的任务(注意:一个任务能不能被中断得看任务本身)
  • TIDYING:所有任务都终止了,线程池中也没有线程了,这样线程池的状态就会转为TIDYING,一旦达到此状态,就会调用线程池的terminated()----->默认实现是空方法,是留给我们扩展的
  • TERMINATED:terminated()执行完之后就会转变为TERMINATED

这五种状态并不能任意转换,只会有以下几种转换情况:

  1. RUNNING -> SHUTDOWN:手动调用shutdown()触发,或者线程池对象GC时会调用finalize()从而调用shutdown()
  2. (RUNNING or SHUTDOWN) -> STOP:调用shutdownNow()触发,如果先调shutdown()紧着调shutdownNow(),就会发生SHUTDOWN -> STOP
  3. SHUTDOWN -> TIDYING:队列为空并且线程池中没有线程时自动转换
  4. STOP -> TIDYING:线程池中没有线程时自动转换(队列中可能还有任务)
  5. TIDYING -> TERMINATED:terminated()执行完后就会自动转换

线程池中的线程是如何关闭的?

Thread类提供了一个stop(),但是标记了@Deprecated,为什么不推荐用stop()方法来停掉线程呢?

因为stop()方法太粗暴了,一旦调用了stop(),就会直接停掉线程,但是调用的时候根本不知道线程刚刚在做什么,任务做到哪一步了,这是很危险的。

这里强调一点,stop()会释放线程占用的synchronized锁(不会自动释放ReentrantLock锁,这也是不建议用stop()的一个因素)。

其实线程池中就是通过interrupt()来停止线程的,这样我们可以在线程内部通过isInterrupted()来判断是否要停止此线程,并作相应的处理,如果此时线程在阻塞中,则会抛出InterruptedException异常。比如shutdownNow()方法中会调用:

void interruptIfStarted() 
{ 
    Thread t; 
    if (getState() >= 0 && (t = thread) != null && !t.isInterrupted()) { 
        try { 
            t.interrupt(); 
        } catch (SecurityException ignore){
        
        } 
    }
}

线程池为什么一定得是阻塞队列?

线程池中的线程在运行过程中,执行完创建线程时绑定的第一个任务后,就会不断的从队列中获取任务并执行,那么如果队列中没有任务了,线程为了不自然消亡,就会阻塞在获取队列任务时,等着队列中有任务过来就会拿到任务从而去执行任务。

通过这种方法能最终确保,线程池中能保留指定个数的核心线程数,关键代码为:

try {
    Runnable r = timed ?
            workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
            workQueue.take();
    if (r != null)
        return r;
    timedOut = true;
} catch (InterruptedException retry) {
    timedOut = false;
}

某个线程在从队列获取任务时,会判断是否使用超时阻塞获取,我们可以认为非核心线程会poll(),核心线程会take(),非核心线程超过时间还没获取到任务后面就会自然消亡了。

线程发生异常,会被移出线程池吗?

答案是会的,那有没有可能核心线程数在执行任务时都出错了,导致所有核心线程都被移出了线程池?

image.png

在源码中,当执行任务时出现异常时,最终会执行processWorkerExit(),而出现异常后,completedAbruptly这个变量就不会赋值为false。processWorkerExit()方法就可以通过这个变量判断这是由于异常退出的线程。执行完这个方法后,当前发生异常的线程也就自然消亡了,但是!processWorkerExit()方法中会额外再新增一个线程,这样就能维持住固定的核心线程数。

线程池的核心线程数、最大线程数该如何设置?

我们都知道,线程池中有两个非常重要的参数:

  1. corePoolSize:核心线程数,表示线程池中的常驻线程的个数
  2. maximumPoolSize:最大线程数,表示线程池中能开辟的最大线程个数

那这两个参数该如何设置呢?

我们对线程池负责执行的任务分为三种情况:

  1. CPU密集型任务,比如找出1-1000000中的素数
  2. IO密集型任务,比如文件IO、网络IO
  3. 混合型任务

CPU密集型任务

CPU密集型任务的特点是,线程在执行任务时会一直利用CPU,所以对于这种情况,就尽可能避免发生线程上下文切换。 所以对于CPU密集型任务,线程数最好就等于CPU核心数,可以通过以下API拿到你电脑的核心数: Runtime.getRuntime().availableProcessors()

只不过,为了应对线程执行过程发生缺页中断或其他异常导致线程阻塞的请求,我们可以额外在多设置一个线程,这样当某个线程暂时不需要CPU时,可以有替补线程来继续利用CPU。

所以,对于CPU密集型任务,我们可以设置线程数为:CPU核心数+1

IO密集型任务

线程在执行IO型任务时,可能大部分时间都阻塞在IO上,假如现在有10个CPU,如果我们只设置了10个线程来执行IO型任务,那么很有可能这10个线程都阻塞在了IO上,这样这10个CPU就都没活干了,所以,对于IO型任务,我们通常会设置线程数为:2*CPU核心数

不过,就算是设置为了2*CPU核心数,也不一定是最佳的,比如,有10个CPU,线程数为20,那么也有可能这20个线程同时阻塞在了IO上,所以可以再增加线程,从而去压榨CPU的利用率。

通常,如果IO型任务执行的时间越长,那么同时阻塞在IO上的线程就可能越多,我们就可以设置更多的线程。 但是,线程肯定不是越多越好,我们可以通过以下这个公式来进行计算:

线程数 = CPU核心数 *( 1 + 线程等待时间 / 线程运行总时间 )

  • 线程等待时间:指的就是线程没有使用CPU的时间,比如阻塞在了IO
  • 线程运行总时间:指的是线程执行完某个任务的总时间

我们可以利用jvisualvm抽样来估计这两个时间。

以上只是理论,实际工作中情况会更复杂,比如一个应用中,可能有多个线程池,除开线程池中的线程可能还有很多其他线程,或者除开这个应用还是一些其他应用也在运行,所以实际工作中如果要确定线程数,最好是压测。

总结,我们再工作中,对于:

  1. CPU密集型任务:CPU核心数+1,这样既能充分利用CPU,也不至于有太多的上下文切换成本
  2. IO型任务:建议压测,或者先用公式计算出一个理论值(理论值通常都比较小)
  3. 对于核心业务(访问频率高),可以把核心线程数设置为我们压测出来的结果,最大线程数可以等于核心线程数,或者大一点点,比如我们压测时可能会发现500个线程最佳,但是600个线程时也还行,此时600就可以为最大线程数
  4. 对于非核心业务(访问频率不高),核心线程数可以比较小,避免操作系统去维护不必要的线程,最大线程数可以设置为我们计算或压测出来的结果。

如何设置阻塞队列的值呢?

看平均任务执行的时间,以及你能接受的排队的时间是多少。

ps:线程池详细源码解析,下篇博客会介绍

如果有收获就点个赞呗