引言
本文详细谈谈JDK1.7中新推出的线程池:ForkJoinPool。但ForkJoinPool的出现并不是为了替代ThreadPoolExecutor,而是作为它的补充,因为在某些场景下,它的性能会比ThreadPoolExecutor更好。在之前的模式中,往往一个任务会分配给一条线程执行,如果有个任务耗时比较长,并且在处理期间也没有新的任务到来,那么则会出现一种情况:线程池中只有一条线程在处理这个大任务,而其他线程却空闲着,这会导致CPU负载不均衡,空闲的处理器无法帮助工作,从而无法最大程度上发挥多核机器的性能。而ForkJoinPool则可以完美解决这类问题,但ForkJoinPool更适合的是处理一些耗时的大任务,如果是普通任务,反而会因为过多的任务拆分和多条线程CPU的来回切换导致效率下降。
一、初窥ForkJoin框架的神秘面纱
ForkJoinPool是一个建立在分治思想上的产物,其采用任务“大拆小”的方式以及工作窃取算法实现并行处理任务。通俗来说,ForkJoin框架的作用主要是为了实现将大型复杂任务进行递归的分解,直到任务小到指定阈值时才开始执行,从而递归的返回各个小任务的结果汇集成一个大任务的结果,依次类推最终得出最初提交的那个大型复杂任务的结果,这和方法的递归调用思想是一样的。当然ForkJoinPool线程池为了提高任务的并行度和吞吐量做了非常多而且复杂的设计实现,其中最著名的就是任务窃取机制。但ForkJoinPool更适合于处理一些大型任务,因此,ForkJoinPool的适用范围不大,仅限于某些密集且能被分解成多个子任务的任务,同时这些子任务运行的结果可以合并成最终结果。ForkJoin框架主体由三部分组成:
①ForkJoinWorkerThread:任务的执行者,具体的线程实体
②ForkJoinTask:需要执行的任务实体
③ForkJoinPool:管理执行者线程的管理池
后续源码阶段会详细分析!
OK~,接着先简单的来看看ForkJoin框架的使用,ForkJoinPool提交任务的方式也有三种,分别为:
execute():可提交Runnbale类型的任务
submit():可提交Callable类型的任务
invoke():可提交ForkJoinTask类型的任务,但ForkJoinTask存在三个子类: ①RecursiveAction:无返回值型ForkJoinTask任务 ②RecursiveTask:有返回值型ForkJoinTask任务 ③CountedCompleter:任务执行完成后可以触发钩子回调函数的任务
上个案例:
业务需求:需要根据ID值对某个范围区间内的每条数据进行变更,变更后获取最新数据更新缓存。
运行环境:四核机器
案例如上,在其中模拟了数据库查询1000W数据后,将数据添加到集合中的操作。其中定义了任务类:IdByFindUpdate,因为需要返回结果,所以IdByFindUpdate类实现了ForkJoinTask的子类RecursiveTask,确保任务可以提交给ForkJoinPool线程池执行。任务的拆分阈值设定为1W,当任务的查询数量小于阈值时,则直接执行任务。反之,拆分任务直至最小(达到阈值)为止才开始执行,执行结束后合并结果并返回。
同时,我们为了对比区别,也使用了普通的for循环来对比测试,结果如下:
很明显,ForkJoin的执行速度比普通的for循环速度快上三倍左右,但是值得一提的是:如果任务的量级太小,ForkJoin的处理速度反而比不上普通的For循环。这是因为ForkJoin框架在拆分任务fork阶段于合并结果join阶段需要时间,并且开启多条线程处理任务,CPU切换也需要时间,所以当一个任务fork/join阶段以及CPU切换的时间开销大于原本任务的执行时间时,这种情况下则没有必要使用ForkJoin框架。
注意:ForkJoin的执行时间跟机器硬件配置以及拆分临界值/阈值的设定也有关系,拆分的阈值并不是越小越好,因为阈值越小时,一个任务拆分的小任务也就会越多,而拆分、合并阶段都是需要时间的,所以阈值需要根据机器的具体硬件设施和任务的类型进行合理的计算,这样才能保证任务执行时能够到达最佳状态。
ok,我也做了一个比较有意思的小测试,把单线程for循环的模式来处理上述任务以及ForkJoin框架处理上述任务分别分为了两次来执行,同时监控了CPU的利用率状况,具体如下:
通过上图可以非常清晰的看见,当单线程for循环的模式处理任务时,因为是在多核机器上执行,所以对于CPU的利用率最高不到50%,而当使用ForkJoin框架处理任务时,几次触顶达到了100%的CPU利用率。所以我们可以得出一个结论:ForkJoin框架在处理任务时,能够在最大程度上发挥机器的性能。
二、ForkJoin框架原理浅析及成员构成
在如上,对于ForkJoin框架已经建立的初步的认知,接着慢慢继续分析其内部实现过程。
2.1、ForkJoin框架原理
在前面提到过,ForkJoin框架是建立在分治思想上的产物,而向FoorkJoinPool中传递一个任务时,任务的执行流程大体如下:
从图中可以很轻易的明白:提交的任务会被分割成一个个小的左/右任务,当分割到最小时,会分别执行每个小的任务,执行完成后,会将每个左/右任务的结果进行,从而合并出父级任务的结果,依次类推,直至最终计算出整个任务的最终结果。
工作窃取:在引言中曾提到过ForkJoin框架是基于分治和工作窃取思想实现的,那么何谓工作窃取呢?先举个例子带大家简单理解一下这个思想,具体的实现会在后面的源码分析中详细谈到。
例子:我是开了个工厂当老板,一条流水线上招聘八个年轻小伙做事,每个人安排了五十个任务,并且对他们说:“你们是一个团队,必须等到每个人做完了自己的任务之后才能一起下班!”。但是在这八个小伙里面,有手比较灵巧做的比较快的,也有做的比较慢、效率比较低的人。那么当一段时间过后,代号③的小伙率先完成了分配给自己的任务后,为了早些下班,会跑到一些做的比较慢的小伙哪儿去拿一些任务过来帮助完成进度,这便是工作窃取思想。在ForkJoin框架同样存在这样的情况,某条线程已经执行完成了分配给自己的任务后,有些线程却还在执行并且堆积着很多任务,那么这条已经处理完自己任务的线程则会去“窃取”其他线程的任务执行。
2.2、ForkJoin框架成员分析
在前面说过,ForkJoin框架是由三部分组成,分别为:执行者线程、任务实体以及线程池。接下来我们依次分析这些成员。
2.2.1、ForkJoinWorkerThread:任务的执行者
ForkJoinWorkerThread继承了Thread线程类,作为Thread的子类,但是却并没有对线程的调度、执行做改变,只是仅仅增加了一些额外功能。ForkJoinWorkerThread线程被创建出来后都交由ForkJoinPool线程池管理,并且设置为了守护线程,而ForkJoinWorkerThread线程创建出来之后都是被注册到FrokJoinPool线程池,由这些线程来执行用户提交的任务,所以ForkJoinWorkerThread也被称为任务的执行者。
ForkJoinPool线程池与之前的线程池有一点区别在于:之前的线程池中,总共只有一个任务队列,而ForkJoinPool中,每个ForkJoinWorkerThread线程在创建时,都会为它分配一个任务队列。同时为了实现工作窃取机制,该队列被设计为双向队列,线程执行自身队列中的任务时,采用LIFO的方式获取任务,当其他线程窃取任务时,采用FIFO的方式获取任务。ForkJoinWorkerThread线程的主要工作为执行自身队列中的任务,其次是窃取其他线程队列中的任务执行。源码如下:
很明显的可以看到,ForkJoinWorkerThread的构造函数中,在初始化时会将自身注册进线程池中,然后由线程池给每个线程对象分配一个队列。
2.2.2、ForkJoinTask:任务实体
ForkJoinTask与FutrueTask一样,是Futrue接口的子类,ForkJoinTask是一种可以将任务进行递归分解执行,从而提高执行并行度的任务类型,执行结束后也可以支持结果返回。但ForkJoinTask仅是一个抽象类,子类有三个:
①RecursiveAction:无返回值型ForkJoinTask任务
②RecursiveTask:有返回值型ForkJoinTask任务
③CountedCompleter:任务执行完成后可以触发钩子回调函数的任务
ForkJoinTask的作用就是根据任务的分解实现,将任务进行拆分,以及等待子任务的执行结果合并成父任务的结果。ForkJoinTask内部存在一个整数类型的成员status,该成员高16位记录任务的执行状态,如:如NORMAL、CANCELLED或EXCEPTIONAL,低16位预留用于记录用户自定义的任务标签。ForkJoinTask源码具体如下:
ForkJoinTask内部成员主要由两部分构成,一个是表示任务状态的int成员:status,其他的成员则都是跟任务异常信息记录相关的。不过值得注意一提的是:ForkJoinTask内部有关异常信息记录的成员都是static关键字修饰的,也就代表着这些成员是所有ForkJoinTask对象共享的,ForkJoinTask使用类似与HashMap的实现结构:固定长度32的数组+单向链表实现了一个哈希表结构,用于记录所有ForkJoinTask执行过程中出现的异常,所有异常信息都会被封装成ExceptionNode节点加入哈希表中存储,但是ExceptionNode节点是一种弱引用的实现,当程序下次GC发生时会被GC机制回收,GC时这些已捕获的异常则会被转移到exceptionTableRefQueue队列中存储。
而成员status代表任务的执行状态,成员类型为int,从最大程度上减少了内存占用,为了保证原子性,该成员使用了volatile修饰以及操作时都是CAS操作。而当任务未结束时,status都会大于0,任务执行结束后,status都会小于0。在ForkJoinTask也定义了如下几种状态:
①DONE_MASK状态:屏蔽非完成位标志,与NORMAL值相同,主要后续用于位运算判断任务是否正常执行结束 二进制值:1111 0000 0000 0000 0000 0000 0000 0000
②NORMAL状态:表示任务正常执行结束 二进制值:1111 0000 0000 0000 0000 0000 0000 0000
③CANCELLED状态:表示任务被取消 二进制值:1100 0000 0000 0000 0000 0000 0000 0000
④EXCEPTIONAL状态:表示任务执行过程中出现异常,导致任务执行终止结束 二进制值:1000 0000 0000 0000 0000 0000 0000 0000
⑤SIGNAL状态:表示传递信号状态,代表着当前任务存在依赖关系,执行结束后需要通知其他任务join合并结果 二进制值:0000 0000 0000 0001 0000 0000 0000 0000
⑥SMASK状态:低十六位的预留占位 二进制值:0000 0000 0000 0000 1111 1111 1111 1111
PS:②③④⑤为任务状态,其他的只是辅助标识
而ForkJoinTask中的所有方法也可以分为三大类:
①基于status状态成员操作以及维护方法
②执行任务以及等待完成方法
③附加对外报告结果的用户级方法
重点来看一下fork()以及join()方法:
fork方法逻辑: ①判断当前线程是否为池中的工作线程类型 是:将当前任务压入当前线程的任务队列中 不是:将当前任务压入common池中某个工作线程的任务队列中 ②返回当前的ForkJoinTask任务对象,方便递归拆分
doJoin&join方法逻辑: ①判断任务状态status是否小于0: 小于:代表任务已经结束,返回status值 不小于:判断当前线程是否为池中的工作线程: 是:取出线程任务队列的当前task执行,判断执行是否结束: 结束:返回执行结束的status值 未结束:调用awaitJoin方法等待执行结束 不是:调用externalAwaitDone()方法阻塞挂起当前线程 ②判断任务执行状态是否为非正常结束状态,是则抛出异常堆栈信息 任务状态为被取消,抛出CancellationException异常 任务状态为异常结束,抛出对应的执行异常信息 ③如果status为正常结束状态,则直接返回执行结果
OK~,最后再看看ForkJoinTask的内部类:
①ExceptionNode:用于记录任务执行过程中抛出的异常信息,是ForkJoinTask的弱引用
②AdaptedRunnableAction:用于封装Runable类型任务的适配器,抽象方法实现: getRawResult()方法:直接返回null setRawResult()方法:空实现 exec()方法:直接调用的run()方法
③AdaptedCallable:用于封装Callable类型任务的适配器,抽象方法实现: getRawResult()方法:返回call方法的执行结果 setRawResult()方法:设置Callable执行后的返回值 exec()方法:调用的call()方法
④AdaptedRunnable:用于封装Runable类型任务的适配器,可以通过构造器设置返回集
⑤RunnableExecuteAction:同②类似,区别在于它可以抛出异常
2.2.3、ForkJoinPool:线程池
ForkJoinPool也是实现了ExecutorService的线程池,但ForkJoinPool不同于其他类型的线程池,因为其内部实现了工作窃取机制,所有线程在执行完自己的任务之后都会尝试窃取其他线程的任务执行,只有当窃取不到任务的情况下才会发生阻塞等待工作。ForkJoinPool主要是为了执行ForkJoinTask而存在的,ForkJoinPool是整个ForkJoin框架的核心,负责整个框架的核心管理、检查监控与资源调度。
2.2.3.1、ForkJoinPool构造器
先来看看ForkJoinPool的构造函数源码:
ForkJoinPool对外提供了三个构造器,但是这三个构造器都是基于内部的私有构造完成的,所以直接分析最后一个全参的私有构造器,该构造器共有五个参数:
①parallelism并行度:默认为CPU核数,最小为1。相当于工作线程数,但会有些不同
②factory线程工厂:用于创建ForkJoinWorkerThread线程
③handler异常捕获策略:默认为null,执行任务出现异常从中被抛出时,就会被handler捕获
④mode调度模式:对应前三个构造中的asyncMode参数,默认为0,也就是false false:使用LIFO_QUEUE成员,mode=0,使用先进后出的模式调度工作 true:使用FIFO_QUEUE成员,mode=1<<16,使用先进先出的模式调度工作
⑤workerNamePrefix工作名称前缀:工作线程的名称前缀,有默认值,不需要传递该参数
创建ForkJoinPool线程池除开通过构造函数的方式之外,在JDK1.8中还提供了一个静态方法:commonPool(),该方法可以通过指定系统参数的方式(System.setProperty(?,?))定义“并行度、线程工厂和异常处理策略”,但是该方法是一个静态方法,也就代表着通过该方法创建出来的线程池对象只会有一个,调用commonPool()方法获取到的ForkJoinPool对象是整个程序通用的。
2.2.3.2、ForkJoinPool内部成员
前面我们了解了ForkJoinPool的构造器,现在再简单看看它的成员构成:
如上既是ForkJoinPool中的所有成员变量,总的可以分为四类:
①ForkJoinPool线程池运行过程中的成员结构
②通用线程池common以及所有线程池对象的默认配置
③线程池运行状态以及ctl成员的位存储记录
④直接操作内存的Unsafe相关成员及内存偏移量
具体的解释可以参考我源码中的注释,我们就挑重点说,先来说说核心成员:ctl,这个成员比较特殊,我估计DougLea在设计它的时候是抱着"一分钱掰成两份用"的心思写的,它是一个long类型的成员,占位8byte/64bit,是不是有些疑惑?用int类型不是更省内存嘛?如果你这样想,那你就错了。因为ctl不是只存储一个数据,而是同时记录四个,64Bit被拆分为四个16位的子字段,分别记录:
①1-16bit/AC:记录池中的活跃线程数
②17-32bit/TC:记录池中总线程数
③33-48bit/SS:记录WorkQueue状态,第一位表示active还是inactive,其余15位表示版本号(避免ABA问题)
④49-64bit/ID:记录第一个WorkQueue在数组中的下标,和其他worker通过字段stackPred组成的一个Treiber堆栈
低32位可以直接获取,如SP=(int)ctl,如果为负则代表存在空闲worker
ok~,了解了ctl成员之后,再来看看runState成员,它和线程池中的runState不同,它除开用于表示线程池的运行状态之外,同时也作为锁资源保护WorkQueues[]数组的更新。
2.2.3.3、ForkJoinPool内部类WorkQueue工作队列
WorkQueue是整个Fork/Join框架的桥接者,每个执行者ForkJoinWorkerThread对象中存在各自的工作队列,ForkJoinTask被存储在工作队列中,而ForkJoinPool使用一个WorkQueue数组管理协调所有执行者线程的队列。接着再看看WorkQueue工作队列的实现:
WorkQueue内部采用一个数组存储所有分配的任务,线程执行时会从该队列中获取任务,如果数组为空,那么则会尝试窃取其他线程的任务。
至此,结合前面谈到的ForkJoinPool线程池的结构一同理解,在ForkJoinPool中存在一个由WorkQueue构成的数组成员workQueues,而在每个WorkQueue中又存在一个ForkJoinTask构成的数组成员array,所以Fork/Join框架中存储任务的结构如下:
重点: workQueues数组的容量必须为2的整次幂。下标为偶数的用于存储外部提交的任务,奇数位置存储内部fork出的子任务 偶数位置的任务属于共享任务,由工作线程竞争获取,模式为FIFO 奇数位置的任务属于某个工作线程,一般是fork产生的子任务 工作线程在处理完自身任务时会窃取其他线程的任务,窃取方式为FIFO 工作线程执行自己队列中任务的模式默认为LIFO(可以改成FIFO,不推荐)
关于
ForkJoinWorkerThreadFactory线程工厂以及ManagedBlocker就不再阐述了
三、ForkJoin框架任务提交原理
在前面给出的ForkJoin使用案例中,我们使用invoke()方法将自己定义的任务提交给了ForkJoinPool线程池执行。前面曾提到过,提交任务的方式有三种:invoke()、execute()以及submit(),但它们三种方式的最终实现都大致相同,所以我们从invoke()方法开始,以它作为入口分析ForkJoin框架任务提交任务的原理实现。源码如下:
源码如上,可以很明显的看到,流程比较长,总结一下核心,如下:
①判断任务是否为空,为空抛出异常,不为空则开始提交任务
②调用externalPush()方法尝试快速提交任务 快速提交条件:探针哈希值已初始化、池中队列数组已初始化、随机的偶数位置队列不为空、线程池已初始化并状态正常、能成功获取队列锁 如上条件全部成立则快速提交任务,提交成功直接返回,结束执行 如果不成立则调用externalSubmit()提交任务,流程如下述步骤
③初始化线程的探针哈希值并开启死循环提交任务(这个循环会直至提交成功才终止)
④检查线程池状态是否正常,如果状态为关闭状态则拒绝任务,抛出异常
⑤检查线程池是否已启动,如果还未启动则获取池锁,初始化线程池
⑥如果通过探针值+队列数组容量减一的值+掩码计算出的偶数位队列不为空: 尝试获取队列锁: 成功:将任务添加到计算出的偶数位队列的任务数组中,如过数组长度不够则先扩容,任务添加成功后新建或唤醒一条线程,然后返回 失败:代表有其他线程在操作这个偶数位置的队列,将move标识改为true
⑦如果计算出的偶数位置队列还未初始化,那么则先尝试获取池锁 成功:在该位置上创建一个共享队列,最后再释放池锁 失败:代表有其他线程也在操作池中的队列数组,将move标识改为true
⑧如果move竞争标识为true,代表本次操作存在线程竞争,为了减少竞争,重新获取一个新的探针哈希值,计算出一个新的偶数位进行操作
⑨当任务第一次执行没有添加成功时,会继续重复这些步骤,直至任务成功入列 第一次添加失败的情况: 队列存在其他线程操作没有获取到队列锁 计算出的偶数索引位的队列为空,第一次执行会先初始化队列
⑩任务成功提交到工作队列后,join等待任务执行完成,返回结果合并
对于上述步骤中提及到的一些名词解释:
探针哈希值:Thread类threadLocalRandomProbe成员的值,
ThreadLocalRandom.getProbe()通过Unsafe计算当前线程的内存偏移量来获取
池锁:runState的RSLOCK状态,如果要对池中的队列数组进行操作则要先获取这个锁
队列锁:队列中的qlock成员,如果要对队列的任务数组进行操作则要先获取这个锁
偶数位队列:前面提到过,池中的队列数组workQueues下标为偶数的位置用来存储用户提交的任务,属于共享队列,不属于任何一条线程,里面的任务需要线程竞争获取
OK~,至此任务提交的流程分析完毕,关于execute()、submit()方法则不再分析,最终实现都是相同的,只是入口不同。最后再来个图理解一下:
四、ForkJoin框架任务工作原理
在前面的提交原理分析中可以得知,任务的执行都是通过调用signalWork()执行的,而这个方法会新创建一条线程处理任务,但当线程数量已经达到线程池的最大线程数时,则会尝试唤醒一条线程执行。下面则以signalWork()做为入口来分析ForkJoin框架任务工作原理,先来看看signalWork()源码:
如上两个方法的逻辑比较简单:
①如果有任务需要处理并且池中目前不存在空闲线程并且池中线程还未满,调用tryAddWorker()方法尝试创建线程
②获取池锁更改ctl值并检测线程池的状态是否正常,正常则调用createWorker()创建线程
③ryAddWorker()是一个自旋方法,在池中线程数未满且没有出现空闲线程的情况下,会一直循环至成功创建线程或者池关闭
④如果池中存在空闲线程或者线程数已满,那么则会尝试唤醒阻塞链上的第一条线程
4.1、工作线程创建及注册原理
接着继续看看createWorker()方法:
创建工作线程的源码比较简单,首先会获取池中采用的线程工厂,然后通过线程工厂创建一条ForkJoinWorkerThread工作线程。ok,再回到最开始的ForkJoin框架成员构成分析中的ForkJoinWorkerThread构造函数:
工作线程的创建与注册原理:
①将线程设置为守护线程,同时为新线程创建工作队列和设置异常处理策略
②尝试获取池锁成功后,先获取一个随机生成的用于计算数组下标的索引种子,然后通过种子和数组最大下标计算出一个奇数索引值
③如果计算出的奇数位值不为空,则通过偶数掩码+数组最大下标计算出一个偶数步长,然后通过这个步长循环整个数组找一个空的位置,如果找完了整个数组还是没有奇数空位,则对数组发生两倍扩容,然后再次依照步长遍历新数组找空位,直至找到奇数空位为止
④为队列设置hint、config、scanState值并将队列放到计算出的奇数位置上
⑤释放池锁并设置工作线程名字
工作线程注册的原理实则不难理解,难点在于计算奇数位索引有些玄妙,不理解的小伙伴可以看看下面这个案例:
4.2、工作线程注销原理
线程注销的逻辑相对比较简单,如下:
①获取池锁之后将工作线程的任务队列从数组中移除,移除后释放池锁
②将偷窃的任务数加到stealCounter成员,然后取消自身队列中的所有任务
③判断当前线程池的情况,判断当前销毁线程是否是因为线程池要关闭了: 如果是:直接退出 如果不是:再判断池中是否存在挂起阻塞的线程 存在:唤醒阻塞线程来代替被销毁的线程工作 不存在:判断池中线程是否已满 没满:新建一条线程代替被销毁的线程工作 满了:直接退出
④清除异常哈希表中当前线程的异常节点信息,然后抛出异常
总的来说,在销毁线程时,会先注销已注册的工作队列,注销之后会根据情况选择唤醒或新建一条线程来补偿线程池。