JDK线程池介绍
在我们的Java开发中,几乎离不了对于线程池的使用,即使你从来没有使用过多线程的开发,你的web容器(如tomcat、jetty)、RPC服务(dubbo)背后都是大量的使用线程池来支持并发执行。随着CPU硬件技术的发展,多线程已经是一个语言最关键的特性和优化点。
Java中线程池最常用的姿势是这样的。(java.util.concurrent.ThreadPoolExecutor)
public class Foo {
// 固定大小的线程池,提交任务超过20时存入队列(队列大小无限,OOM警告⚠️)
private static ExecutorService fixThreadPool = Executors.newFixThreadPool(20);
// 无限大小的线程池,提一亿个任务它也接收,OOM警告⚠️
private static ExecutorService cachedThreadPool = Executors.newCachedThreadPool();
// 自定义的线程池,线程数,队列和队列容量可控,阿里Java规范手册中要求的用法
private static ExecutorService customThreadPool = new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>())
}
如代码示例中的注释所示,不同类型的线程池有着不同的能力,需要注意的是如下几点:
- 可以看到线程池都被初始化为static静态变量,这意味着线程池是 单例 的。部分新手读者可能会在使用线程池中借鉴了网上搜索到的不规范用法的demo,在方法的局部变量中声明创建了线程池,这即不符合线程池存在的意义(将好不容易创建好的线程省下来准备后面复用),也会存在线程溢出的系统隐患(线程池的引用结束后,线程池并不会被GC回收,笔者在新手期曾经踩过这个坑)
public class foo {
// 这个方法每调用一次就会创建2个线程,并且在方法结束时不会回收
public void theMethod() {
ExecutorService fixThreadPool = Executors.newFixThreadPool(2);
fixThreadPool.execute(() -> {});
fixThreadPool.execute(() -> {});
fixThreadPool.shutdown(); // 如果一定要在局部变量中使用线程池,那么结尾一定要执行shutdown,且最好用try-finally包裹住
}
}
- 可以看到不论是
newFixThreadPool还是newCachedThreadPool,都存在着OOM的风险,很多我们当前项目中在这样使用的线程池没有出问题真的只是因为并发量小,因此《阿里巴巴Java开发手册》【1】中强制要求使用第三种自定义的线程池ThreadPoolExecutor,需要使用者在使用线程池前自行评估以后的并发规模来决定参数。
| 序号 | 名称 | 类型 | 含义 |
|---|---|---|---|
| 1 | corePoolSize | int | 核心线程池大小 |
| 2 | maximumPoolSize | int | 最大线程池大小 |
| 3 | keepAliveTime | long | 线程最大空闲时间 |
| 4 | unit | TimeUnit | 时间单位 |
| 5 | workQueue | BlockingQueue | 线程等待队列 |
| 6 | threadFactory | ThreadFactory | 线程创建工厂 |
| 7 | handler | RejectedExecutionHandler | 拒绝策略 |
更多关于JDK线程池的使用方式,以及不同参数(corePoolSize、maximumPoolSize等)设置后的执行效果,已经有很多博客有过详细介绍,本文不再赘述。本文重点从源码的角度分析这些线程池功能的实现原理。
ThreadPoolExecutor源码
不论是Executors.newFixThreadPool,还是Executors.newCachedThreadPool,实际上都是通过不同的初始化参数调用创建了ThreadPoolExecutor,分析ThreadPoolExecutor的源码,首先需要了解ThreadPoolExecutor的数据结构。
ThreadPoolExecutor的核心成员变量如下图所示。
ctl —— runState与wokerCount二合一
ThreadPoolExecutor中有一个很核心的字段,即ctl,数据类型是JDK中的原子操作的AtomicInteger(是基于CAS乐观锁保证了对i++等操作的原子性,感兴趣的读者可以自行了解)。
通过对注释以及源码的了解,我们可以知道这个32位的AtomicInteger实际上是runState(线程池状态)与workerCount(线程池线程数)的二合一,二者分别占据32位数字的不同比特位,如下图。
runState
线程池状态runState各个状态的描述如下,可以看出runState的取值随着线程池的生命周期由小到大。
| 状态 | 取值 | 英文描述 | 中文说明 |
|---|---|---|---|
| RUNNING | -1,二进制 101 | Accept new tasks and process queued tasks | 运行中 |
| SHUTDOWN | 0,二进制 000 | Don't accept new tasks, but process queued tasks | 停止接收新任务,处理队列中的任务 |
| STOP | 1,二进制 001 | Don't accept new tasks, don't process queued tasks, and interrupt in-progress tasks | 停止接收新任务,停止处理队列中的任务,中断正在执行的任务 |
| TIDYING | 2,二进制 010 | All tasks have terminated, workerCount is zero, the thread transitioning to state TIDYING will run the terminated() hook method | 清理中。所有任务都已经停止,workerCount==0,转为TIDYING的线程会执行terminated()的hook方法 |
| TERMINATED | 3,二进制 011 | terminated() has completed | 彻底关闭,terminated()方法回调完毕 |
各个状态之间的转换关系如下图所示。
最终整个状态机的转换步骤可以概述为:
RUNNABLE是线程池正常运行时的状态,通过温柔的shutdown或者暴力的shutdownNow走向不同的状态,然后这两个状态在进行资源释放工作后转为TIDYING状态,TIDYING状态通过调用预留的terminated()方法结束后转为最终结束状态TERMINATED。
workerCount
关于workerCount的解释,应该没有比源码中Doug Lea写的注释更权威的了。
The workerCount is the number of workers that have been permitted to start and not permitted to stop. The value may be transiently different from the actual number of live threads, for example when a ThreadFactory fails to create a thread when asked, and when exiting threads are still performing bookkeeping before terminating. The user-visible pool size is reported as the current size of the workers set.
翻译一下,workerCount记录了workers中 允许启动且不允许停止 的线程数(即线程池中可以用来分配新任务的Worker)。这个值可能存在短暂的与实际存活的线程数量不一致的情况,例如ThreadFactory创建线程失败,正在退出的线程还注册在线程中。用户可见的线程池大小报告为worker集合的当前大小(即getPoolSize()方法返回的是workers.size())。
为什么要二合一?
对于同时需要判断runState和workerCount的情况,免去了加锁的性能损失。
之所以需要同时判断runState和workerCount,是因为在addWorker时需要 原子性 的检测runState和workerCount, 防止在不应该添加线程的线程池状态下添加线程 (具体细节在下文ThreadPoolExecutor.Worker相关部分会详细展开),不愧是Doug Lea老爷子。
ThreadPoolExecutor#execute()方法
execute方法本身并不复杂,本文直接贴出源码加注释
public void execute(Runnable command) {
if (command == null) // 0. 空指针异常情况
throw new NullPointerException();
int c = ctl.get();
if (addWorker(command, true)) // 1.1 这里是应对并发情况,可能走到这一步的时候core满了,也可能runState不是RUNNABLE
return;
c = ctl.get();
}
if (isRunning(c) && workQueue.offer(command)) { // 2. 当前线程超过coreSize,入队列
int recheck = ctl.get();
if (! isRunning(recheck) && remove(command)) // 2.1 应对并发情况,如果线程池停了,把它从队列中清掉
reject(command);
else if (workerCountOf(recheck) == 0) // 2.2 线程池没停,并发情况刚好执行到这里有线程结束了(because existing ones died since last checking)
addWorker(null, false);
}
else if (!addWorker(command, false)) // 3. 入队列失败,则再尝试创建线程,如果也失败,表示shutdown了或者线程池满了
reject(command);
}
整个ThreadPoolExecutor#execute()的执行流程可以概括为如下图
可以看到在线程数小于coreSize时,所有线程池的表现都是创建新线程执行。而当线程数超过coreSize时,则会执行入队列操作,对于采用了不同队列实现的线程池则会有不同的表现(例如SynchronousQueue.offer会直接失败进而走到再尝试addWorker,所以表现为core满了之后直接扩充线程池;LinkedBlockingQueue.offer会入队列,队列容量如果是无限,则正常情况下永远也不会走到再尝试addWorder那步,读者可以自行理解)。
ThreadPoolExecutor.Worker
上面我们可以将addWorder概括的理解为给线程池创建新的线程,这里具体分析下addWorker()的执行流程。addWorker()方法的核心简化版伪代码可以概括如下
def addWorker(firstTask, core):
while True:
if runState > SHUTDOWN # 线程池停了,不能添加worker
return False
if runState == SHUTDOWN and workQueue.isEmpty()): # SHUTDOWN阶段,队列中的消息消费完了,也不能再添加worker了
return False
if not cas_increment_worker_count(core) # 通过cas+死循环的方式做到 workerCount++
return False
worker = new Worker(firstTask)
workers.add(worker) # 添加worker
worker.start() # 启动worker线程
def cas_increment_worker_count(core):
while True:
if workCounter_too_mach(core): # 根据是否是core扩充模式,判断不同的workerCount上限
return False;
if cas_increment(workerCount) # CAS的方式 workerCount++
return True;
# 走到这里意味着cas执行失败了,可能是runState变了,可能是workerCount变了,都再重试,这里就是ctl字段二合一的原因,省去了两个字段同时判断需要加锁的负担
基于上面的理解,我们知道了ctl字段二合一的意义所在,就是在worker扩充的时候保证 一把cas操作同时判断了runStatue和workerCount两个字段的不变 ,否则想让两个字段保持不变只能 加锁,那样的话线程池的execute方法执行的速度肯定会慢许多,尤其是在很多大量依赖线程池的服务中(如tomcat等web容器、dubbo)
另外,我们可以看到在通过cas增加了workerCount之后,就会将WorkerCount作为一个 线程 执行start()方法去启动,Worker的结构以及工作流程如下图。
从上图我们可以看到,Worker有三个属性
- thread,worker本身实现了Runnable接口,thread记录这个worker执行时承载它的线程
- firstTask,线程池之所以是线程池,就是因为里面的线程是 可复用 的,worker第一次创建时执行的任务,执行完之后就自己去queue里领任务了
- completedTasks,统计用的,实际上用不到,记录着这个Worker做了多少事情
龙龙的奇妙比喻:
每一个Worker就像一个打工人,入职的第一天会带着类似 新人报到 这样的初始任务(firstTask),被安排好自己的工号和工位信息(thread),然后开始接活(getTask),不断周而复始,直到公司裁员了、公司破产了就离职。
看懂了的朋友们把 泪目 打在公屏上
def runWorker(worker):
# thread指当前线程
task = worker.firstTask
while task != null or (task = getTask()) != null:
if runState >= STOP and thread.isNotInterrupted(): # 如果是线程池已经终止了,且当前线程还没中断,则中断它(源码中处理了shutdownNow方法冲突的问题,解决办法是停止shutdownNow的中断,让我来中断)
thread.interrupt() # 中断当前线程
beforeExecute(thread, task) # 空滴,预留滴~
task.run() # 在当前线程执行task.run(),这个task就是我们发给线程池的runnable
afterExecute(task, exception) # 空滴,预留滴~
worker.completedTasks++ # 打工人绩效+1
# 因为异常,或者裁员,总之worker的死循环结束了,该退出了
processWorkerExit()
def processWorkerExit():
workers.remove(worker)
if STOP > runState and need_revival:
addWorker() # 如果线程判定完结束后需要扩容,那么你复活辣!
# 线程池停止了,不用考虑是否需要复活了
need_revival的判断较为复杂,且不是重点,笔者这里忽略了,总之就是判断下当前是否是异常情况退出的,以及退出后线程池的大小是否还容许重启新线程。(员工猝死后,企业根据当前业绩是否还需要人手决定是否再招一个新员工来填补坑位,泪目)
Callable、FutureTask与executor.submit()
executor.submit()
ThreadPoolExecutor除了有execute(runnable)方法外,还有一个submit(callable)方法用来支持获取任务的返回结果,其本质就是将我们传入的callable对象进行了一层 封装,在run()执行时将所有get()操作的等待线程(waiters)挂起,等任务处理完之后 唤醒所有挂起等待的线程。
ThreadPoolExecutor.submit(callable)的代码很简单,这里直接贴出源码
public <T> Future<T> submit(Callable<T> task) {
if (task == null) throw new NullPointerException();
RunnableFuture<T> ftask = newTaskFor(task); // callable ==> RunnableFuture(实际实现类是FutureTask)
execute(ftask); // 封装完了之后还是走的execute那一套
return ftask; // submit方法返回的Future,其实是一个FutureTask对象
}
Callable与FutureTask
可以看出callable的本质还是调用了execute那一套,返回给使用者一个FutureTask对象,使用者通过执行其get()方法等待响应的结果。所以callable的秘密在于封装的newTaskFor这一层上,newTaskFor返回的实现类其实是FutureTask,其继承关系如下图所示。
submit方法返回给使用者的是一个Future接口,FutureTask作为其实现类,同时继承并实现了Runnable和Future的接口,对callable的封装如下。
FutureTask的执行过程与使用者get()的关系如下图
FutureTask在执行过程中的整个生命周期状态机如图
JDK线程池中的锁
ThreadPoolExecutor#addWorker
ThreadPoolExecutor有着一个HashSet<Worker> workers的容器来维护线程池内的worker,在addWorker等对workers容器进行新增、遍历等等操作的时候,需要通过ReentrantLock保证HashSet操作的线程安全。
Worker与AbstractQueuedSynchronizer
AbstractQueuedSynchronizer,以下简称AQS,是JDK源码中各个同步相关功能ReentrantLock,Semaphore和CountDownLatch等都依赖的同步器。
AQS是一类同步器,简单理解为JDK将 加锁、解锁 大同小异的逻辑 封装在了一起 ,搞出了一个 同步器Synchronizer 的概念。例如ReentranceLock加锁=将该线程重入该锁的次数+1,Semaphore加锁=剩余的许可数量-1。同时AQS将实际的tryAcquire(加锁)和tryRelease(释放)等方法的实际执行逻辑交给子类实现。
Worker继承了AQS,对其遗留给子类的方法进行了实现,回到上文中runWorker方法的实现中,我们知道这个是Worker打工人在持续的接收工作、处理工作的过程。
而标出这部分逻辑是需要保持 原子性 的部分,打工人在接收一个工作之后之后就只能一心一意的处理这个工作的部分,不能在执行的过程中受其它的干扰,于是这里在实际Worker的执行代码中通过继承了AQS进行了加锁操作。加锁的方式是CAS乐观锁。
读者可以想象下,task.run()方法就是我们通过executor.execute()/submit()方式提交的线程,如果一个Worker在没有任何加锁的情况下同时执行两个task.run(),那么它们的ThreadLocal就会完全复用,会出多么可怕的问题,程序员要赔多少钱。。。
线程池与ThreadLocal
通过对runWorker方法的分析,我们也可以看出来,对于复用线程的线程池来说,一个Worker即一个线程是会一直持续的处理我们通过executor.execute()提交的任务的。因此从线程池使用者的角度来看,提交的线程结束了,可实际上Worker并没有结束。这就涉及到ThreadLocal的清理问题,如果我们还以为线程已经直接完成并且结束了,并没有在线程执行结束前对ThreadLocal进行清理重置,那么在下一轮线程开始的时候就会继承上一个线程的ThreadLocal,可能会因此数据错乱造成不可想象的bug(程序员祭天警告⚠️!!!)
更多关于ThreadLocal的知识,读者请移步往期博客 ——【3】图解分析ThreadLocal的原理与应用场景
总结与回顾
深入ThreadPoolExecutor源码中徜徉了这么久,让我们再回到最初的起点,记忆中你青涩的脸~
在使用者的视角来看,
我们创建了一个ThreadPoolExecutor(Executors.newFixThreadPool() 和 Executors.newCachedThreadPool()本质也是ThreadPoolExecutor),随后在代码中执行了executor.execute(submit方法本身也会调用execute)
executor.execute()方法首先根据core线程的数量与队列状况决定是调用 addWorker 还是 入队列 操作。其中:
- addWorker本质上会启动Worker线程
- 入队列的任务会在Worker执行中被捞出来处理
至此,我们已经彻底解剖了一遍JDK线程池的大致逻辑,那么读者们,如果现在让你们来空手实现一个线程池,你们能做到了吗?