Java 线程池实现原理及实战
导读
线程池概述
在面向对象编程中,创建和销毁对象是很费时间的,因为创建一个对象要获取内存资源或者其它更多资源。在 Java 中更是如此,虚拟机将试图跟踪每一个对象,以便能够在对象销毁后进行垃圾回收。所以提高服务程序效率的一个手段就是尽可能减少创建和销毁对象的次数,特别是一些很耗资源的对象创建和销毁。如何利用已有对象来服务就是一个需要解决的关键问题,其实这就是一些「池化资源」技术产生的原因。比如大家所熟悉的数据库连接池正是遵循这一思想而产生的!
线程池是什么?
线程池(Thread Pool)是一种基于池化思想管理线程的工具,经常出现在多线程服务器中,类似的还有数据库连接池、HTTP 连接池等等。
线程池解决的问题是什么?
线程池解决的核心问题就是资源管理问题。
在并发环境下,系统不能够确定在任意时刻中,有多少任务需要执行,有多少资源需要投入。这种不确定性将带来以下若干问题:
- 频繁申请/销毁资源和调度资源,将带来额外的消耗,可能会非常巨大。
- 对资源无限申请缺少抑制手段,易引发系统资源耗尽的风险。
- 系统无法合理管理内部的资源分布,会降低系统的稳定性。
为解决资源分配这个问题,线程池采用了「池化」(Pooling)思想。池化的思想主要是为了减少每次获取和结束资源的消耗,提高对资源的利用率。
线程池的好处
- 降低资源消耗:减少每次创建、销毁线程的开销;
- 提高响应速度:请求到来时,线程已创建好,可直接执行,提高响应速度;
- 提高线程的可管理性:线程是稀缺资源,需根据情况加以限制,确保系统稳定运行;
- 提供更多更强大的功能:线程池具备可拓展性,允许开发人员向其中增加更多的功能。比如延时定时线程池
ScheduledThreadPoolExecutor
,就允许任务延期执行或定期执行。
在了解完“是什么”和“为什么”之后,下面我们来一起深入分析线程池的内部实现原理。
线程池核心设计与实现
Java 中的线程池核心实现类是 ThreadPoolExecutor
,本文基于 JDK 1.8 的源码来分析 Java 线程池的核心设计与实现。
总体设计
我们首先来看一下 ThreadPoolExecutor
的 UML 类图,了解下 ThreadPoolExecutor
的继承关系。
Executor
ThreadPoolExecutor
实现的顶层接口是 Executor
。
Executor
提供了一种思想:将任务提交和任务执行进行解耦。用户无需关注如何创建线程,如何调度线程来执行任务,用户只需提供 Runnable
对象,将任务的运行逻辑提交到执行器(Executor
)中,由 Executor
框架完成线程的调配和任务的执行部分。
ExecutorService
ExecutorService
接口增加了一些能力:
- 扩展了可异步跟踪执行任务生成返回值
Future
的方法,如submit()
等方法。 - 提供了管控线程池生命周期的方法,如
shutDown()
,shutDownNow()
等。
AbstractExecutorService
AbstractExecutorService
则是上层的抽象类,将执行任务的流程串联了起来,保证下层的实现只需关注一个执行任务的方法即可。
最下层的实现类 ThreadPoolExecutor
实现最复杂的运行部分,ThreadPoolExecutor
将会一方面维护自身的生命周期,另一方面同时管理线程和任务,使两者良好的结合从而执行并行任务。
ThreadPoolExecutor
是如何运行,又是如何同时维护线程和执行任务的呢?
ThreadPoolExecutor
ThreadPoolExecutor
运行机制如下图所示:
线程池在内部实际上构建了一个生产者消费者模型,将线程和任务两者解耦,并不直接关联,从而良好的缓冲任务,复用线程。
线程池的运行主要分成两部分:任务管理
、线程管理
。
任务管理部分充当生产者的角色,当任务提交后,线程池会判断该任务后续的流转:
- 直接申请线程执行该任务;
- 缓冲到队列中等待线程执行;
- 拒绝该任务。
线程管理部分是消费者,它们被统一维护在线程池内,根据任务请求进行线程的分配,当线程执行完任务后则会继续获取新的任务去执行,最终当线程获取不到任务的时候,线程就会被回收。
接下来,我们会按照以下三个部分去详细讲解线程池运行机制:
- 线程池如何维护自身状态?
- 线程池如何管理任务?
- 线程池如何管理线程?
线程池运行机制
生命周期管理
线程池运行的状态,并不是用户显式设置的,而是伴随着线程池的运行,由内部来维护。线程池内部使用一个 AtomicInteger
变量维护两个值:运行状态(runState
)和线程数量(workerCount
)。
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
AtomicInteger:一个提供原子操作的 Integer 类。
我们知道,在不同操作系统下,Java 中的 Integer 变量都是 32 位,ThreadPoolExecutor
使用高 3 位(31 ~ 29)表示线程池状态,用后 29 位(28 ~ 0)表示活跃线程数。
这样设置的目的是什么呢?
(1)我们知道,在并发场景中同时维护两个变量的代价是非常大的,往往需要进行加锁来保证两个变量的变化是原子性的。而将两个参数用一个变量维护,便只需一条语句就能保证两个变量的原子性。这种方式大大降低了使用过程中的并发问题。
(2)通过阅读线程池源代码也可以发现,经常出现要同时判断线程池运行状态和线程数量的情况。线程池也提供了若干方法去供用户获得线程池当前的运行状态、线程个数。这里都使用的是位运算的方式,相比于基本运算,速度也会快很多。
ThreadPoolExecutor
的运行状态有 5 种,分别为:
RUNNING
:可接收新任务,可持续处理阻塞队列中的任务。SHUTDOWN
:关闭状态,不可接收新任务,但却可继续处理阻塞队列中已保存的任务。STOP
:不可接收新任务,也不处理队列中的任务,并且会中断正在处理任务的线程。TIDYING
:所有的任务都已终止了,workerCount(有效线程数)为 0。TERMINATED
:线程池线程池彻底停止,线程池处于TIDYING
状态时,执行完terminated()
之后, 就会由TIDYING
→TERMINATED
。
其生命周期转换如下入所示:
任务执行机制
任务调度
任务调度是线程池的主要入口,当用户提交了一个任务,接下来这个任务将如何执行都是由这个阶段决定的。了解这部分就相当于了解了线程池的核心运行机制。
首先,在 ThreadPoolExecutor
类中,任务提交方法的入口是 execute(Runnable command)
方法(submit()
方法也是调用了 execute()
),这部分完成的工作是:检查现在线程池的运行状态、运行线程数、运行策略,决定接下来执行的流程,是直接申请线程执行,或是缓冲到队列中执行,亦或是直接拒绝该任务。
执行过程如下:
- 如果线程池当前线程数量少于
corePoolSize
,则addWorker(command, true)
创建新 worker 线程,如创建成功返回,如没创建成功,则执行后续步骤;addWorker(command, true)
失败的原因可能是:- A、线程池已经
shutdown
,shutdown
的线程池不再接收新任务; - B、
workerCountOf(c) < corePoolSize
判断后,由于并发,别的线程先创建了 worker 线程,导致workerCount>=corePoolSize
;
- A、线程池已经
- 如果线程池还在
running
状态,将 task 加入workQueue
阻塞队列中,如果加入成功,进行 double-check,如果加入失败(可能是队列已满),则执行后续步骤;- double-check 主要目的是判断刚加入 workQueue 阻塞队列的 task 是否能被执行
- A、如果线程池已经不是
running
状态了,应该拒绝添加新任务,从workQueue
中删除任务; - B、如果线程池是运行状态,或者从
workQueue
中删除任务失败(刚好有一个线程执行完毕,并消耗了这个任务),确保还有线程执行任务(只要有一个就够了);
- A、如果线程池已经不是
- double-check 主要目的是判断刚加入 workQueue 阻塞队列的 task 是否能被执行
- 如果线程池不是
running
状态 或者 无法入队列,尝试开启新线程,扩容至maxPoolSize
,如果addWork(command, false)
失败了,拒绝当前 command。
任务缓冲
任务缓冲模块是线程池能够管理任务的核心部分。
线程池的本质是对任务和线程的管理,而做到这一点最关键的思想就是将任务和线程两者解耦,不让两者直接关联,才可以做后续的分配工作。
线程池中是以生产者消费者模式,通过一个阻塞队列来实现的。阻塞队列缓存任务,工作线程从阻塞队列中获取任务。
阻塞队列(BlockingQueue
)是一个支持两个附加操作的队列。这两个附加的操作是:
- 在队列为空时,获取元素的线程会等待队列变为非空。
- 当队列满时,存储元素的线程会等待队列可用。
下图中展示了线程 1 往阻塞队列中添加元素,而线程 2 从阻塞队列中移除元素:
使用不同的队列可以实现不一样的任务存取策略。在这里,我们再介绍下阻塞队列的成员:
任务申请
由上文的任务分配部分可知,任务的执行有两种可能:
- 一种是任务直接由新创建的线程执行。
- 另一种是线程从任务队列中获取任务然后执行,执行完任务的空闲线程会再次去从队列中申请任务再去执行。
第一种情况仅出现在线程初始创建的时候,第二种是线程获取任务绝大多数的情况。
线程需要从任务缓存模块中不断地取任务执行,帮助线程从阻塞队列中获取任务,实现线程管理模块和任务管理模块之间的通信。
这部分策略由 getTask()
方法实现,Runnable getTask()
方法是为 void runWorker(Worker w)
方法服务的,它的作用就是在任务队列(workQueue)中获取 task(Runnable)。
getTask()
执行流程:
- 首先判断是否可以满足从 workQueue 中获取任务的条件,不满足 return null;
- A、线程池状态是否满足:
- (a)
shutdown 状态 + workQueue为空
或stop 状态
,都不满足,因为被shutdown
后还是要执行 workQueue 剩余的任务,但 workQueue 也为空,就可以退出了; - (b)
stop状态
,shutdownNow()
操作会使线程池进入 stop,此时不接受新任务,中断正在执行的任务,workQueue 中的任务也不执行了,故 return null 返回;
- (a)
- B、
线程数量是否超过 maximumPoolSize
或获取任务是否超时
- (a)线程数量超过
maximumPoolSize
可能是线程池在运行时被调用了setMaximumPoolSize()
被改变了大小,否则已经addWorker()
成功不会超过maximumPoolSize
; - (b)如果
当前线程数量>corePoolSize
,才会检查是否获取任务超时,这也体现了当线程数量达到maximumPoolSize
后,如果一直没有新任务,会逐渐终止 worker 线程直到corePoolSize
;
- (a)线程数量超过
- A、线程池状态是否满足:
- 如果满足获取任务条件,根据是否需要定时获取调用不同方法:
- A、
workQueue.poll()
:如果在 keepAliveTime 时间内,阻塞队列还是没有任务,返回 null; - B、
workQueue.take()
:如果阻塞队列为空,当前线程会被挂起等待;当队列中有任务加入时,线程被唤醒,take 方法返回任务;
- A、
- 在阻塞从 workQueue 中获取任务时,可以被
interrupt()
中断,代码中捕获了InterruptedException
,重置timedOut
为初始值 false,再次执行第 1 步中的判断,满足就继续获取任务,不满足 return null,会进入 worker 退出的流程。
任务拒绝
任务拒绝模块是线程池的保护部分,线程池有一个最大的容量,当线程池的任务缓存队列已满,并且线程池中的线程数目达到 maximumPoolSize
时,就需要拒绝掉该任务,采取任务拒绝策略,保护线程池。
拒绝策略是一个接口,其设计如下:
public interface RejectedExecutionHandler {
void rejectedExecution(Runnable r, ThreadPoolExecutor executor);
}
用户可以通过实现这个接口去定制拒绝策略,也可以选择 JDK 提供的四种已有拒绝策略,其特点如下:
总结
Worker 线程管理
Worker 线程
线程池为了掌握线程的状态并维护线程的生命周期,设计了线程池内的工作线程 Worker
。我们来看一下它的部分代码:
private final class Worker extends AbstractQueuedSynchronizer implements Runnable{
final Thread thread;//Worker持有的线程
Runnable firstTask;//初始化的任务,可以为null
}
Worker
这个工作线程,实现了 Runnable
接口,并持有一个线程 thread
,一个初始化的任务 firstTask
。
thread
是在调用构造方法时通过ThreadFactory
来创建的线程,可以用来执行任务;firstTask
用它来保存传入的第一个任务,这个任务可以有也可以为 null。- 如果这个值是【非空】的,那么线程就会在启动初期立即执行这个任务,也就对应「核心线程」创建时的情况;
- 如果这个值是 【null】,那么就需要创建一个线程去执行任务列表(workQueue)中的任务,也就是「非核心线程」的创建。
Worker
执行任务的模型如下图所示:
线程池需要管理线程的生命周期,需要在线程长时间不运行的时候进行回收。线程池使用一张 Hash 表去持有线程的引用,这样可以通过添加引用、移除引用这样的操作来控制线程的生命周期。这个时候重要的就是如何判断线程是否在运行。
Worker
是通过继承 AQS
,使用 AQS
来实现独占锁这个功能。没有使用可重入锁 ReentrantLock
,而是使用 AQS
,为的就是实现不可重入的特性去反应线程现在的执行状态。
- lock 方法一旦获取了独占锁,表示当前线程正在执行任务中。
- 如果正在执行任务,则不应该中断线程。
- 如果该线程现在不是独占锁的状态,也就是空闲的状态,说明它没有在处理任务,这时可以对该线程进行中断。
- 线程池在执行
shutdown
方法或tryTerminate
方法时会调用interruptIdleWorkers
方法来中断空闲的线程,interruptIdleWorkers
方法会使用tryLock
方法来判断线程池中的线程是否是空闲状态;如果线程是空闲状态则可以安全回收。
在线程回收过程中就使用到了这种特性,回收过程如下图所示:
Worker 线程增加
增加线程是通过线程池中的 addWorker()
方法,该方法的功能就是增加一个线程,该方法不考虑线程池是在哪个阶段增加的该线程,这个分配线程的策略是在上个步骤完成的,该步骤仅仅完成增加线程,并使它运行,最后返回是否成功这个结果。
addWorker
方法有两个参数:firstTask
、core
。
firstTask
:worker 线程的初始任务,可以为空;core
参数为true
表示在新增线程时会判断当前活动线程数是否少于corePoolSize
,false
表示新增线程前需要判断当前活动线程数是否少于maximumPoolSize
。
addWorker
方法有 4 种传参的方式:
- addWorker(command, true)
- addWorker(command, false)
- addWorker(null, false)
- addWorker(null, true)
在 execute
方法中就使用了前 3 种,结合这个核心方法进行以下分析:
- 第一个:线程数小于
corePoolSize
时,放一个需要处理的 task 进 Workers Set。如果 Workers Set 长度超过corePoolSize
,就返回 false; - 第二个:当队列被放满时,就尝试将这个新来的 task 直接放入 Workers Set,而此时 Workers Set的长度限制是
maximumPoolSize
。如果线程池也满了的话就返回 false; - 第三个:放入一个空的 task 进 Workers Set,长度限制是
maximumPoolSize
。这样一个 task 为空的 worker 在线程执行的时候会去任务队列里拿任务,这样就相当于创建了一个新的线程,只是没有马上分配任务; - 第四个:这个方法就是放一个 null 的 task 进 Workers Set,而且是在小于
corePoolSize
时,如果此时 Set中 的数量已经达到corePoolSize
那就返回 false,什么也不干。实际使用中是在prestartAllCoreThreads()
方法,这个方法用来为线程池预先启动corePoolSize
个 worker 等待从 workQueue 中获取任务执行。
执行流程:
- 判断线程池当前是否为可以添加 worker 线程的状态,可以则继续下一步,不可以 return false:
A、线程池状态>shutdown
,可能为 stop、tidying、terminated,不能添加 worker 线程;
B、线程池状态==shutdown
,firstTask 不为空
,不能添加 worker 线程,因为 shutdown 状态的线程池不接收新任务;
C、线程池状态==shutdown
,firstTask==null
,workQueue为空
,不能添加 worker 线程,因为 firstTask 为空是为了添加一个没有任务的线程再从 workQueue 获取 task,而 workQueue 为空,说明添加无任务线程已经没有意义; - 线程池当前线程数量是否超过上限(corePoolSize 或 maximumPoolSize),超过了return false,没超过则对
workerCount+1
,继续下一步; - 在线程池的 ReentrantLock 保证下,向 Workers Set 中添加新创建的 worker 实例,添加完成后解锁,并启动 worker 线程,如果这一切都成功了,return true,如果添加 worker入Set 失败或启动失败,调用
addWorkerFailed()
逻辑。
Worker 线程执行任务
在 Worker
类中的 run
方法调用了 runWorker
方法来执行任务,里面是一个 while 循环,循环判断任务是否为空,若不为空,执行任务;若取不到任务,或发生异常,退出循环,执行processWorkerExit(w, completedAbruptly);
在这个方法里把工作线程移除掉。
runWorker
方法的执行过程如下:
- 取任务的来源有两个,一个是
firstTask
,这个是工作线程第一次跑的时候执行的任务,最多只能执行一次,后面得从getTask()
方法里取任务。 getTask()
方法从阻塞队列中取任务。- 如果线程池正在停止,那么要保证当前线程是中断状态,否则要保证当前线程不是中断状态。
- 执行任务。
- 如果
getTask
结果为 null 则跳出循环,执行processWorkerExit()
方法,销毁线程。
下一步,就得看看,什么情况下 getTask()
会返回 null。
一共有两种情况会返回 null,见红框处 。
-
第一种情况,线程池的状态已经是
STOP
,TIDYING
,TERMINATED
,或者是SHUTDOWN
且工作队列为空; -
第二种情况,「工作线程数已经大于最大线程数」或「当前工作线程已超时」,
且
,「还有其他工作线程」或「任务队列为空」。这点比较难理解,总之先记住,后面会用。
runWorker
执行流程如下图所示:
Worker 线程回收
通过源码我们可以看到,在线程池提交任务后,会通过内部的 worker 线程进行任务的处理。在 runWorker()
方法中 worker 会通过循环获取任务,对于有超时限制的 worker,会在获取方法 getTask()
中进行超时判断,若判断已超时也就是空闲线程已到最大存活时间,则进行 worker 数的修改并返回空任务。
在获取到空任务时,会跳出循环进行 worker 回收的方法 processWorkerExit()
,在该方法中加锁进行完成任务数的统计及 worker 从 set 中的移除,最后判断是否需要进行 worker 的补充。在实现 worker 数值修改时是通过循环 + CAS 的方式实现的。
try {
while (task != null || (task = getTask()) != null) {
//执行任务
}
} finally {
processWorkerExit(w, completedAbruptly);//获取不到任务时,主动回收自己
}
线程池中线程的销毁依赖 JVM 自动的回收,线程池做的工作是根据当前线程池的状态维护一定数量的线程引用,防止这部分线程被 JVM 回收,当线程池决定哪些线程需要回收时,只需要将其引用消除即可。
事实上,在这个方法中,将线程引用移出线程池就已经结束了线程销毁的部分。但由于引起线程销毁的可能性有很多,线程池还要判断是什么引发了这次销毁,是否要改变线程池的现阶段状态,是否要根据新状态,重新分配线程。
线程池在业务中的实践
业务背景
在当今的互联网业界,为了最大程度利用 CPU 的多核性能,并行运算的能力是不可或缺的。通过线程池管理线程获取并发性是一个非常基础的操作,让我们来看两个典型的使用线程池获取并发性的场景。
场景1:快速响应用户请求
描述:用户发起的实时请求,服务追求响应时间。比如说用户要查看一个商品的信息,那么我们需要将商品维度的一系列信息如商品的价格、优惠、库存、图片等等聚合起来,展示给用户。
分析:从用户体验角度看,这个结果响应的越快越好,如果一个页面半天都刷不出,用户可能就放弃查看这个商品了。而面向用户的功能聚合通常非常复杂,伴随着调用与调用之间的级联、多级级联等情况,业务开发同学往往会选择使用线程池这种简单的方式,将调用封装成任务并行的执行,缩短总体响应时间。另外,使用线程池也是有考量的,这种场景最重要的就是获取最大的响应速度去满足用户,所以应该不设置队列去缓冲并发任务,调高 corePoolSize
和 maxPoolSize
去尽可能创造多的线程快速执行任务。
场景2:快速处理批量任务
描述:离线的大量计算任务,需要快速执行。比如说,统计某个报表,需要计算出全国各个门店中有哪些商品有某种属性,用于后续营销策略的分析,那么我们需要查询全国所有门店中的所有商品,并且记录具有某属性的商品,然后快速生成报表。
分析:这种场景需要执行大量的任务,我们也会希望任务执行的越快越好。这种情况下,也应该使用多线程策略,并行计算。但与响应速度优先的场景区别在于,这类场景任务量巨大,并不需要瞬时的完成,而是关注如何使用有限的资源,尽可能在单位时间内处理更多的任务,也就是吞吐量优先的问题。所以应该设置队列去缓冲并发任务,调整合适的 corePoolSize
去设置处理任务的线程数。在这里,设置的线程数过多可能还会引发线程上下文切换频繁的问题,也会降低处理任务的速度,降低吞吐量。
实际问题及方案思考
线程池使用面临的核心的问题在于:线程池的参数并不好配置。
- 一方面线程池的运行机制不是很好理解,配置合理需要强依赖开发人员的个人经验和知识;
- 另一方面,线程池执行的情况和任务类型相关性较大,IO 密集型和 CPU 密集型的任务运行起来的情况差异非常大,这导致业界并没有一些成熟的经验策略帮助开发人员参考。
业务中要使用线程池,而使用不当又会导致故障,那么我们怎样才能更好地使用线程池呢?针对这个问题,我们下面延展几个方向。
追求参数设置合理性
业界的一些线程池参数配置方案:
调研了以上业界方案后,我们并没有得出通用的线程池计算方式。并发任务的执行情况和任务类型相关,IO 密集型和 CPU 密集型的任务运行起来的情况差异非常大,但这种占比是较难合理预估的,这导致很难有一个简单有效的通用公式帮我们直接计算出结果。
尽管经过谨慎的评估,仍然不能够保证一次计算出来合适的参数,那么我们是否可以将修改线程池参数的成本降下来,这样至少可以发生故障的时候可以快速调整从而缩短故障恢复的时间呢?
基于这个思考,我们是否可以将线程池的参数从代码中迁移到分布式配置中心上,实现线程池参数可动态配置和即时生效,线程池参数动态化前后的参数修改流程对比如下:
基于以上三个方向对比,我们可以看出参数动态化方向简单有效。
动态化线程池
整体设计
动态化线程池的核心设计包括以下三个方面:
- 简化线程池配置:线程池构造参数有8个,但是最核心的是3个:
corePoolSize
、maximumPoolSize
,workQueue
,它们最大程度地决定了线程池的任务分配和线程分配策略。考虑到在实际应用中我们获取并发性的场景主要是两种:- 并行执行子任务,提高响应速度。这种情况下,应该使用同步队列,没有什么任务应该被缓存下来,而是应该立即执行。
- 并行执行大批次任务,提升吞吐量。这种情况下,应该使用有界队列,使用队列去缓冲大批量的任务,队列容量必须声明,防止任务无限制堆积。所以线程池只需要提供这三个关键参数的配置,并且提供两种队列的选择,就可以满足绝大多数的业务需求,Less is More。
- 参数可动态修改:为了解决参数不好配,修改参数成本高等问题。在Java线程池留有高扩展性的基础上,封装线程池,允许线程池监听同步外部的消息,根据消息进行修改配置。将线程池的配置放置在平台侧,允许开发同学简单的查看、修改线程池配置。
- 增加线程池监控:对某事物缺乏状态的观测,就对其改进无从下手。在线程池执行任务的生命周期添加监控能力,帮助开发同学了解线程池状态。
功能架构
动态化线程池提供如下功能:
- 动态调参:支持线程池参数动态调整、界面化操作;包括修改线程池核心大小、最大核心大小、队列长度等;参数修改后及时生效。
- 任务监控:支持应用粒度、线程池粒度、任务粒度的Transaction监控;可以看到线程池的任务执行情况、最大任务执行时间、平均任务执行时间、95/99线等。
- 负载告警:线程池队列任务积压到一定值的时候会通过报警通知应用开发负责人;当线程池负载数达到一定阈值的时候也会会通过报警告知应用开发负责人。
- 操作监控:创建/修改和删除线程池都会通知到应用的开发负责人。
- 操作日志:可以查看线程池参数的修改记录,谁在什么时候修改了线程池参数、修改前的参数值是什么。
- 权限校验:只有应用开发负责人才能够修改应用的线程池参数。
参数动态化
JDK 原生线程池 ThreadPoolExecutor
提供了如下几个 public 的 setter 方法,如下图所示:
JDK 允许线程池使用方通过 ThreadPoolExecutor
的实例来动态设置线程池的核心策略,以 setCorePoolSize
为方法例,在运行期线程池使用方调用此方法设置 corePoolSize
之后,线程池会直接覆盖原来的 corePoolSize
值,并且基于当前值和原始值的比较结果采取不同的处理策略。对于当前值小于当前工作线程数的情况,说明有多余的 worker 线程,此时会向当前 idle 的 worker 线程发起中断请求以实现回收,多余的 worker 在下次 idel 的时候也会被回收;对于当前值大于原始值且当前队列中有待执行任务,则线程池会创建新的 worker 线程来执行队列任务。
setCorePoolSize
具体流程如下:
线程池内部会处理好当前状态做到平滑修改,其他几个方法限于篇幅,这里不一一介绍。重点是基于这几个public方法,我们只需要维护 ThreadPoolExecutor 的实例,并且在需要修改的时候拿到实例修改其参数即可。基于以上的思路,我们实现了线程池参数的动态化、线程池参数在管理平台可配置可修改,其效果图如下图所示:
用户可以在管理平台上通过线程池的名字找到指定的线程池,然后对其参数进行修改,保存后会实时生效。目前支持的动态参数包括核心数、最大值、队列长度等。除此之外,在界面中,我们还能看到用户可以配置是否开启告警、队列等待任务告警阈值、活跃度告警等等。关于监控和告警,我们下面一节会对齐进行介绍。
线程池监控
除了参数动态化之外,为了更好地使用线程池,我们需要对线程池的运行状况有感知,比如:
- 当前线程池的负载是怎么样的?
- 分配的资源够不够用?
- 任务的执行情况是怎么样的?
- 是长任务还是短任务?
基于对这些问题的思考,动态化线程池提供了多个维度的监控和告警能力,包括:线程池活跃度、任务的执行Transaction(频率、耗时)、Reject异常、线程池内部统计信息等等,既能帮助用户从多个维度分析线程池的使用情况,又能在出现问题第一时间通知到用户,从而避免故障或加速故障恢复。
负载监控和告警
线程池负载关注的核心问题是:基于当前线程池参数分配的资源够不够。
对于这个问题,我们可以从事前和事中两个角度来看。
事前,线程池定义了“活跃度”这个概念,来让用户在发生 Reject 异常之前能够感知线程池负载问题,线程池活跃度计算公式为:线程池活跃度 = activeCount/maximumPoolSize
。
这个公式代表当活跃线程数趋向于 maximumPoolSize
的时候,代表线程负载趋高。
事中,也可以从两方面来看线程池的过载判定条件:
- 一个是发生了 Reject 异常
- 一个是队列中有等待任务(支持定制阈值)。
以上两种情况发生了都会触发告警,告警信息会通过消息推送给服务所关联的负责人。
任务级精细化监控
在传统的线程池应用场景中,线程池中的任务执行情况对于用户来说是透明的。比如在一个具体的业务场景中,业务开发申请了一个线程池同时用于执行两种任务,一个是发消息任务、一个是发短信任务,这两类任务实际执行的频率和时长对于用户来说没有一个直观的感受,很可能这两类任务不适合共享一个线程池,但是由于用户无法感知,因此也无从优化。
动态化线程池内部实现了任务级别的埋点,且允许为不同的业务任务指定具有业务含义的名称,线程池内部基于这个名称做 Transaction 打点,基于这个功能,用户可以看到线程池内部任务级别的执行情况,且区分业务,任务监控示意图如下图所示:
运行时状态实时查看
用户基于 JDK 原生线程池 ThreadPoolExecutor
提供的几个 public 的 getter 方法,可以读取到当前线程池的运行状态以及参数,如下图所示:
动态化线程池基于这几个接口封装了运行时状态实时查看的功能,用户基于这个功能可以了解线程池的实时状态,比如当前有多少个工作线程,执行了多少个任务,队列中等待的任务数等等。效果如下图所示:
参考资料
Java线程池实现原理及其在美团业务中的实践
Java线程池ThreadPoolExecutor使用和分析(二) - execute()原理