
万字解读Java线程池设计思想及源码实现
前言
相信大家都看过很多的关于线程池的文章,基本上也是面试的时候必问的,如果你在看过很多文章以后,还是一知半解的,那希望这篇文章能让你真正的掌握好Java线程池。
本文一大重点是源码解析,同时会有少量篇幅介绍线程池设计思想以及作者Doug Lea实现过程中的一些巧妙用法。本文还是会一行行关键代码进行分析,目的是为了让看源码不是很理解的同学可以得到参考。
线程池是非常重要的工具,如果你要成为一个好的工程师,还是得比较好地掌握这个知识,很多线上问题都是因为没有用好线程池导致的。即使你为了谋生,也要知道,这基本上是面试必问的题目,而且面试官很容易从被面试者的回答中捕捉到被面试者的技术水平。
本文略长,建议在pc上阅读,边看文章边翻源码(Java7和Java8都一样),建议想好好看的读者抽出至少30分钟的整块时间来阅读。当然,如果读者仅为面试准备,可以直接滑到最后的总结部分。
总览
开篇来一些废话。下图是 java 线程池几个相关类的继承结构:
1
先简单说说这个继承结构,Executor位于最顶层,也是最简单的,就一个execute(Runnable runnable) 接口方法定义。
ExecutorService也是接口,在Executor接口的基础上添加了很多的接口方法,所以一般来说我们会使用这个接口。
再下来一层是AbstractExecutorService,从名字就知道是抽象类,这里实现了非常有用的一些方法供子类直接使用,之后再细说。
然后才到重点部分ThreadPoolExecutor类,这个类提供了关于线程池所需的非常丰富的功能。
另外,我们还涉及到下图中的这些类:
others
同在并发包中的Executors类,类名中带字母s,我们猜到这个是工具类,里面的方法都是静态方法,如以下我们最常用的用于生成ThreadPoolExecutor的实例的一些方法:
另外,由于线程池支持获取线程执行的结果,所以,引入了Future接口,RunnableFuture继承自此接口,然后我们最需要关心的就是它的实现类FutureTask。到这里,记住这个概念,在线程池的使用过程中,我们是往线程池提交任务(task),使用过线程池的都知道,我们提交的每个任务是实现了Runnable接口的,其实就是先将Runnable的任务包装成FutureTask,然后再提交到线程池。这样,读者才能比较容易记住FutureTask这个类名:它首先是一个任务(Task),然后具有Future接口的语义,即可以在将来(Future)得到执行的结果。
当然,线程池中的BlockingQueue也是非常重要的概念,如果线程数达到corePoolSize,我们的每个任务会提交到等待队列中,等待线程池中的线程来取任务并执行。这里的BlockingQueue通常我们使用其实现类LinkedBlockingQueue、ArrayBlockingQueue和SynchronousQueue,每个实现类都有不同的特征,使用场景之后会慢慢分析。想要详细了解各个BlockingQueue的读者,可以参考我的前面的一篇对BlockingQueue的各个实现类进行详细分析的文章。
把事情说完整:除了上面说的这些类外,还有一个很重要的类,就是定时任务实现类ScheduledThreadPoolExecutor,它继承自本文要重点讲解的ThreadPoolExecutor,用于实现定时执行。不过本文不会介绍它的实现,我相信读者看完本文后可以比较容易地看懂它的源码。
以上就是本文要介绍的知识,废话不多说,开始进入正文。
Executor 接口
我们可以看到Executor接口非常简单,就一个void execute(Runnable command) 方法,代表提交一个任务。为了让大家理解 java 线程池的整个设计方案,我会按照Doug Lea的设计思路来多说一些相关的东西。
我们经常这样启动一个线程:
用了线程池Executor后就可以像下面这么使用:
如果我们希望线程池同步执行每一个任务,我们可以这么实现这个接口:
我们希望每个任务提交进来后,直接启动一个新的线程来执行这个任务,我们可以这么实现:
我们再来看下怎么组合两个Executor来使用,下面这个实现是将所有的任务都加到一个queue中,然后从queue中取任务,交给真正的执行器执行,这里采用synchronized进行并发控制:
当然了,Executor这个接口只有提交任务的功能,太简单了,我们想要更丰富的功能,比如我们想知道执行结果、我们想知道当前线程池有多少个线程活着、已经完成了多少任务等等,这些都是这个接口的不足的地方。接下来我们要介绍的是继承自Executor接口的ExecutorService接口,这个接口提供了比较丰富的功能,也是我们最常使用到的接口。
ExecutorService
一般我们定义一个线程池的时候,往往都是使用这个接口:
因为这个接口中定义的一系列方法大部分情况下已经可以满足我们的需要了。
那么我们简单初略地来看一下这个接口中都有哪些方法:
这些方法都很好理解,一个简单的线程池主要就是这些功能,能提交任务,能获取结果,能关闭线程池,这也是为什么我们经常用这个接口的原因。
FutureTask
在继续往下层介绍ExecutorService的实现类之前,我们先来说说相关的类FutureTask。
我们知道,Runnable的void run() 方法是没有返回值的,所以,通常,如果我们需要的话,会在submit中指定第二个参数作为返回值:
其实到时候会通过这两个参数,将其包装成Callable。它和Runnable的区别在于run()没有返回值,而Callable的call()方法有返回值,同时,如果运行出现异常,call()方法会抛出异常。
在这里,就不展开说FutureTask类了,因为本文篇幅本来就够大了,这里我们需要知道怎么用就行了。
下面,我们来看看ExecutorService的抽象实现AbstractExecutorService。
AbstractExecutorService
AbstractExecutorService抽象类派生自ExecutorService接口,然后在其基础上实现了几个实用的方法,这些方法提供给子类进行调用。
这个抽象类实现了invokeAny方法和invokeAll方法,这里的两个newTaskFor方法也比较有用,用于将任务包装成FutureTask。定义于最上层接口Executor中的void execute(Runnable command)由于不需要获取结果,不会进行FutureTask的包装。
需要获取结果(FutureTask),用submit方法,不需要获取结果,可以用execute方法。
下面,我将一行一行源码地来分析这个类,跟着源码来看看其实现吧:
Tips: invokeAny和invokeAll方法占了这整个类的绝大多数篇幅,读者可以选择适当跳过,因为它们可能在你的实践中使用的频次比较低,而且它们不带有承前启后的作用,不用担心会漏掉什么导致看不懂后面的代码。
”
到这里,我们发现,这个抽象类包装了一些基本的方法,可是像submit、invokeAny、invokeAll等方法,它们都没有真正开启线程来执行任务,它们都只是在方法内部调用了execute方法,所以最重要的 execute(Runnable runnable)方法还没出现,需要等具体执行器来实现这个最重要的部分,这里我们要说的就是ThreadPoolExecutor 类了。
鉴于本文的篇幅,我觉得看到这里的读者应该已经不多了,大家都习惯了快餐文化。我写的每篇文章都力求让读者可以通过我的一篇文章而对相关内容有全面的了解,所以篇幅不免长了些。
”
ThreadPoolExecutor
ThreadPoolExecutor是JDK中的线程池实现,这个类实现了一个线程池需要的各个方法,它实现了任务提交、线程管理、监控等等方法。
我们可以基于它来进行业务上的扩展,以实现我们需要的其他功能,比如实现定时任务的类ScheduledThreadPoolExecutor就继承自ThreadPoolExecutor。当然,这不是本文关注的重点,下面,还是赶紧进行源码分析吧。
首先,我们来看看线程池实现中的几个概念和处理流程。
我们先回顾下提交任务的几个方法:
一个最基本的概念是,submit方法中,参数是Runnable类型(也有Callable类型),这个参数不是用于 new Thread(runnable).start()中的,此处的这个参数不是用于启动线程的,这里指的是任务,任务要做的事情是run()方法里面定义的或Callable中的call()方法里面定义的。
初学者往往会搞混这个,因为Runnable总是在各个地方出现,经常把一个Runnable包到另一个Runnable中。请把它想象成有个Task接口,这个接口里面有一个run()方法。
我们回过神来继续往下看,我画了一个简单的示意图来描述线程池中的一些主要的构件:
pool-1
当然,上图没有考虑队列是否有界,提交任务时队列满了怎么办?什么情况下会创建新的线程?提交任务时线程池满了怎么办?空闲线程怎么关掉?这些问题下面我们会一一解决。
我们经常会使用Executors这个工具类来快速构造一个线程池,对于初学者而言,这种工具类是很有用的,开发者不需要关注太多的细节,只要知道自己需要一个线程池,仅仅提供必需的参数就可以了,其他参数都采用作者提供的默认值。
这里先不说有什么区别,它们最终都会导向这个构造方法:
基本上,上面的构造方法中列出了我们最需要关心的几个属性了,下面逐个介绍下构造方法中出现的这几个属性:
● corePoolSize
核心线程数,不要抠字眼,反正先记着有这么个属性就可以了。
● maximumPoolSize
最大线程数,线程池允许创建的最大线程数。
● workQueue
任务队列,BlockingQueue接口的某个实现(常使用ArrayBlockingQueue和LinkedBlockingQueue)。
● keepAliveTime
空闲线程的保活时间,如果某线程的空闲时间超过这个值都没有任务给它做,那么可以被关闭了。注意这个值并不会对所有线程起作用,如果线程池中的线程数少于等于核心线程数corePoolSize,那么这些线程不会因为空闲太长时间而被关闭,当然,也可以通过调用allowCoreThreadTimeOut(true)使核心线程数内的线程也可以被回收。
● threadFactory
用于生成线程,一般我们可以用默认的就可以了。通常,我们可以通过它将我们的线程的名字设置得比较可读一些,如Message-Thread-1, Message-Thread-2类似这样。
● handler:
当线程池已经满了,但是又有新的任务提交的时候,该采取什么策略由这个来指定。有几种方式可供选择,像抛出异常、直接拒绝然后返回等,也可以自己实现相应的接口实现自己的逻辑,这个之后再说。
除了上面几个属性外,我们再看看其他重要的属性。
Doug Lea采用一个32位的整数来存放线程池的状态和当前池中的线程数,其中高3位用于存放线程池状态,低29位表示线程数(即使只有29位,也已经不小了,大概5亿多,现在还没有哪个机器能起这么多线程的吧)。我们知道,java语言在整数编码上是统一的,都是采用补码的形式,下面是简单的移位操作和布尔操作,都是挺简单的。
上面就是对一个整数的简单的位操作,几个操作方法将会在后面的源码中一直出现,所以读者最好把方法名字和其代表的功能记住,看源码的时候也就不需要来来回回翻了。
在这里,介绍下线程池中的各个状态和状态变化的转换过程:
● RUNNING:这个没什么好说的,这是最正常的状态:接受新的任务,处理等待队列中的任务
● SHUTDOWN:不接受新的任务提交,但是会继续处理等待队列中的任务
● STOP:不接受新的任务提交,不再处理等待队列中的任务,中断正在执行任务的线程
● TIDYING:所有的任务都销毁了,workCount为0。线程池的状态在转换为TIDYING状态时,会执行钩子方法 terminated()
● TERMINATED:terminated() 方法结束后,线程池的状态就会变成这个
RUNNING 定义为-1,SHUTDOWN定义为0,其他的都比0大,所以等于0的时候不能提交任务,大于0的话,连正在执行的任务也需要中断。
看了这几种状态的介绍,读者大体也可以猜到十之八九的状态转换了,各个状态的转换过程有以下几种:
● RUNNING -> SHUTDOWN:当调用了shutdown()后,会发生这个状态转换,这也是最重要的
● (RUNNING or SHUTDOWN) -> STOP:当调用shutdownNow()后,会发生这个状态转换,这下要清楚shutDown()和shutDownNow()的区别了
● SHUTDOWN -> TIDYING:当任务队列和线程池都清空后,会由SHUTDOWN转换为 TIDYING
● STOP -> TIDYING:当任务队列清空后,发生这个转换
● TIDYING -> TERMINATED:这个前面说了,当terminated()方法结束后
上面的几个记住核心的就可以了,尤其第一个和第二个。
另外,我们还要看看一个内部类Worker,因为Doug Lea把线程池中的线程包装成了一个个Worker,翻译成工人,就是线程池中做任务的线程。所以到这里,我们知道任务是Runnable(内部变量名叫task或command),线程是Worker。
Worker这里又用到了抽象类AbstractQueuedSynchronizer。题外话,AQS在并发中真的是到处出现,而且非常容易使用,写少量的代码就能实现自己需要的同步方式(对AQS源码感兴趣的读者请参看我之前写的几篇文章)。
前面虽然啰嗦,但是简单。有了上面的这些基础后,我们终于可以看看ThreadPoolExecutor的execute方法了,前面源码分析的时候也说了,各种方法都最终依赖于execute方法:
对创建线程的错误理解:如果线程数少于corePoolSize,创建一个线程,如果线程数在 [corePoolSize, maximumPoolSize] 之间那么可以创建线程或复用空闲线程,keepAliveTime对这个区间的线程有效。
从上面的几个分支,我们就可以看出,上面的这段话是错误的。
上面这些一时半会也不可能全部消化搞定,我们先继续往下吧,到时候再回头看几遍。
这个方法非常重要addWorker(Runnable firstTask, boolean core) 方法,我们看看它是怎么创建新的线程的:
简单看下addWorkFailed的处理:
回过头来,继续往下走。我们知道,worker中的线程start后,其run方法会调用runWorker方法:
继续往下看runWorker方法:
我们看看getTask()是怎么获取任务的,这个方法写得真的很好,每一行都很简单,组合起来却所有的情况都想好了:
到这里,基本上也说完了整个流程,读者这个时候应该回到execute(Runnable command)方法,看看各个分支,我把代码贴过来一下:
上面各个分支中,有两种情况会调用reject(command)来处理任务,因为按照正常的流程,线程池此时不能接受这个任务,所以需要执行我们的拒绝策略。接下来,我们说一说ThreadPoolExecutor中的拒绝策略。
此处的handler我们需要在构造线程池的时候就传入这个参数,它是RejectedExecutionHandler的实例。
RejectedExecutionHandler在ThreadPoolExecutor中有四个已经定义好的实现类可供我们直接使用,当然,我们也可以实现自己的策略,不过一般也没有必要。
到这里,ThreadPoolExecutor的源码算是分析结束了。单纯从源码的难易程度来说,ThreadPoolExecutor的源码还算是比较简单的,只是需要我们静下心来好好看看罢了。
Executors
这节其实也不是分析Executors这个类,因为它仅仅是工具类,它的所有方法都是static的。
生成一个固定大小的线程池:
最大线程数设置为与核心线程数相等,此时keepAliveTime设置为0(因为这里它是没用的,即使不为0,线程池默认也不会回收corePoolSize内的线程),任务队列采用LinkedBlockingQueue,无界队列。
过程分析:刚开始,每提交一个任务都创建一个worker,当worker的数量达到nThreads后,不再创建新的线程,而是把任务提交到LinkedBlockingQueue中,而且之后线程数始终为nThreads。
● 生成只有一个线程的固定线程池,这个更简单,和上面的一样,只要设置线程数为1就可以了:
● 生成一个需要的时候就创建新的线程,同时可以复用之前创建的线程(如果这个线程当前没有任务)的线程池:
核心线程数为0,最大线程数为Integer.MAX_VALUE,keepAliveTime为60秒,任务队列采用SynchronousQueue。
这种线程池对于任务可以比较快速地完成的情况有比较好的性能。如果线程空闲了60秒都没有任务,那么将关闭此线程并从线程池中移除。所以如果线程池空闲了很长时间也不会有问题,因为随着所有的线程都会被关闭,整个线程池不会占用任何的系统资源。
过程分析:把execute方法的主体黏贴过来,让大家看得明白些。鉴于corePoolSize是0,那么提交任务的时候,直接将任务提交到队列中,由于采用了SynchronousQueue,所以如果是第一个任务提交的时候,offer方法肯定会返回false,因为此时没有任何worker对这个任务进行接收,那么将进入到最后一个分支来创建第一个worker。
之后再提交任务的话,取决于是否有空闲下来的线程对任务进行接收,如果有,会进入到第二个if语句块中,否则就是和第一个任务一样,进到最后的else if分支创建新线程。
SynchronousQueue是一个比较特殊的BlockingQueue,其本身不储存任何元素,它有一个虚拟队列(或虚拟栈),不管读操作还是写操作,如果当前队列中存储的是与当前操作相同模式的线程,那么当前操作也进入队列中等待;如果是相反模式,则配对成功,从当前队列中取队头节点。具体的信息,可以看我的另一篇关于BlockingQueue的文章。
总结
本文的总结部分为准备面试的读者而写,希望能帮到面试者或者没有足够的时间看完全文的读者。
⒈java线程池有哪些关键属性?
corePoolSize,maximumPoolSize,workQueue,keepAliveTime,rejectedExecutionHandler
corePoolSize到 maximumPoolSize 之间的线程会被回收,当然corePoolSize 的线程也可以通过设置而得到回收(allowCoreThreadTimeOut(true))。
workQueue用于存放任务,添加任务的时候,如果当前线程数超过了corePoolSize,那么往该队列中插入任务,线程池中的线程会负责到队列中拉取任务。
keepAliveTime用于设置空闲时间,如果线程数超出了corePoolSize,并且有些线程的空闲时间超过了这个值,会执行关闭这些线程的操作
rejectedExecutionHandler用于处理当线程池不能执行此任务时的情况,默认有抛出RejectedExecutionException 异常、忽略任务、使用提交任务的线程来执行此任务和将队列中等待最久的任务删除,然后提交此任务这四种策略,默认为抛出异常。
说说线程池中的线程创建时机?
a、如果当前线程数少于 corePoolSize,那么提交任务的时候创建一个新的线程,并由这个线程执行这个任务;b、如果当前线程数已经达到 corePoolSize,那么将提交的任务添加到队列中,等待线程池中的线程去队列中取任务。c、如果队列已满,那么创建新的线程来执行任务,需要保证池中的线程数不会超过 maximumPoolSize,如果此时线程数超过了 maximumPoolSize,那么执行拒绝策略”
* 注意:如果将队列设置为无界队列,那么线程数达到corePoolSize后,其实线程数就不会再增长了。因为后面的任务直接往队列塞就行了,此时maximumPoolSize参数就没有什么意义。
⒉Executors.newFixedThreadPool(…)和Executors.newCachedThreadPool()构造出来的线程池有什么差别?
细说太长,往上滑一点点,在Executors的小节进行了详尽的描述。
⒊任务执行过程中发生异常怎么处理?
如果某个任务执行出现异常,那么执行任务的线程会被关闭,而不是继续接收其他任务。然后会启动一个新的线程来代替它。
⒋什么时候会执行拒绝策略?
a、workers的数量达到了corePoolSize(任务此时需要进入任务队列),任务入队成功,与此同时线程池被关闭了,而且关闭线程池并没有将这个任务出队,那么执行拒绝策略。这里说的是非常边界的问题,入队和关闭线程池并发执行,读者仔细看看execute方法是怎么进到第一个reject(command)里面的;b、workers 的数量大于等于 corePoolSize,将任务加入到任务队列,可是队列满了,任务入队失败,那么准备开启新的线程,可是线程数已经达到 maximumPoolSize,那么执行拒绝策略
因为本文实在太长了,所以就没有说执行结果是怎么获取的,也没有说关闭线程池相关的部分,这个就留给读者吧。
本文篇幅是有点长,如果读者发现什么不对的地方,或者有需要补充的地方,请不吝提出,谢谢。
文章转载自公众号:程序员新视界
