Fork/Join框架

190 阅读4分钟

Fork/Join框架解决什么问题?

Java7中引入Fork/Join框架,为了解决传统线程池ThreadPoolExecutor的两个明显缺点:

  1. 无法对大任务进行拆分,某些大任务任务只能由单线程执行,影响任务的执行效率。
  2. 工作线程从队列中获取任务时存在竞争情况。

而Fork/Join框架最适合计算密集型任务,而且最好是非阻塞任务。因为有任务拆分和任务窃取这两个特性:

  • 任务切分:Fork/Join的核心类ForkJoinPool,可以将其他线程向它提交的任务,分割成更小粒度的子任务,让更多的线程参与执行;(这些子任务由ForkJoinPool内部的工作线程并行执行。)
  • 任务窃取:允许空闲线程从繁忙线程双端队列的尾部拿任务。这样能充分地利用空闲线程,并减少竞争。窃取队列(work-stealing queue)只支持push,pop和poll约束比其他鲜橙多

使用Fork/Join的架构

Fork/Join 计算框架主要包含两部分:一部分是分治任务的线程池 ForkJoinPool,另一部分是分治任务 ForkJoinTask。

  • ForkJoinPool维护了一个队列数组 WorkQueue(WorkQueue[]),而不像传统执行池 Worker+Queue 的组合,这样在提交任务和线程任务的时候大幅度减少碰撞。

  • ForkJoinPool 内的每个工作线程都维护着一个工作队列(WorkQueue),这是一个双端队列(Deque),里面存放的对象是任务(ForkJoinTask)。通过ForkJoinPool的invoke() 或submit() 方法,将任务提交到对应的工作队列的top中,而工作线程在处理自己的工作队列时又从top中取任务执行。即用LIFO以保证任务的有序执行。 image.png

  • ForkJoinTask 是一个抽象类,它的方法有很多,最核心的是 fork() 方法和 join() 方法,承载着主要的任务协调作用,一个用于任务提交,一个用于结果获取。

ForkJoinTask工作原理

提交任务有三种方式:

  • execute类型的方法在提交任务后,不会返回结果。ForkJoinPool不仅允许提交ForkJoinTask类型任务,还允许提交Runnable任务。执行Runnable类型任务时,将会转换为ForkJoinTask类型。由于任务是不可切分的,所以这类任务无法获得任务拆分这方面的效益,不过仍然可以获得任务窃取带来的好处和性能提升。

  • invoke方法接受ForkJoinTask类型的任务,并在任务执行结束后,返回泛型结果。如果提交的任务是null,将抛出空指针异常。

  • submit方法支持三种类型的任务提交:ForkJoinTask类型、Callable类型和Runnable类型。在提交任务后,将返回ForkJoinTask类型的结果。如果提交的任务是null,将抛出空指针异常,并且当任务不能按计划执行的话,将抛出任务拒绝异常。

整个工作流程: image.png

Fork/Join的使用衡量指标

任务总数、单任务执行耗时以及并行数 都会影响到Fork/Join的性能。所以,当你使用Fork/Join框架时,你需要谨慎评估这三个指标,最好能通过模拟对比评估。在使用ForkJoinPool时,需要特别注意任务的类型是否为纯函数计算类型,也就是这些任务不应该关心状态或者外界的变化,这样才是最安全的做法。如果是阻塞类型任务,那么你需要谨慎评估技术方案。虽然ForkJoinPool也能处理阻塞类型任务,但可能会带来复杂的管理成本。

为什么工作线程总是从头部获取任务,窃取线程从尾部获取任务?

这样做的主要原因是为了提高性能,工作线程从头部获取最近提交的任务,可以增加资源仍分配在CPU缓存中的机会,这样CPU处理起来要快一些。而窃取者之所以从尾部获取任务,则是为了降低线程之间的竞争可能,而且队列中比较旧的任务更有可能粒度较大没被分割,所以空闲的线程更有精力完成这些粒度较大的任务。