ForkJoinPool原理与实战

1. 介绍

Fork/Join框架通过把问题以递归的方式划分为多个子任务,然后并行的执行这些子任务,等所有的子任务都结束的时候,再合并最终结果,通过这种方式来支持并发编程。

简单来说,就是使用分治思想实现并发框架。fork操作将会启动一个新的并行Fork/Join子任务,join操作会一直等待直到所有的子任务都结束,然后合并结果。

Thread类应用到Fork/Join框架会有什么局限性呢?以下引用Doug Lea大神的原文:

  • Fork/Join任务对同步和管理有简单的和常规的需求。相对于常规的线程来说,Fork/Join任务所展示的计算布局将会带来更加灵活的调度策略。例如,Fork/Join任务除了等待子任务外,其他情况下是不需要阻塞的。因此传统的用于跟踪记录阻塞线程的代价在这种情况下实际上是一种浪费。
  • 对于一个合理的基础任务粒度来说,构建和管理一个线程的代价甚至可以比任务执行本身所花费的代价更大。尽管粒度是应该随着应用程序在不同特定平台上运行而做出相应调整的。但是超过线程开销的极端粗粒度会限制并行的发挥。

简而言之,Java标准的线程框架对Fork/Join程序而言太笨重了。但是既然线程构成了很多其他的并发和并行编程的基础,完全消除这种代价或者为了这种方式而调整线程调度是不可能(或者说不切实际的)。

因此,创造了一个轻量级的执行框架,可以更直观的编写Fork/Join程序。

2. 原理解析

2.1 work-stealing调度策略

调度策略的基本概念如下:

  • 每一个工作线程维护自己的调度队列中的可运行任务。
  • 队列以双端队列的形式被维护,不仅支持 LIFOpushpop操作,还支持 FIFOtake操作。
  • 对于一个给定的工作线程来说,任务所产生的子任务将会被放入到工作者自己的双端队列中。
  • 工作线程使用 LIFO(最新的元素优先)的顺序,通过弹出任务来处理队列中的任务。
  • 当一个工作线程的本地没有任务去运行的时候,它将使用 FIFO的规则尝试随机的从别的工作线程中「窃取」一个任务去运行。
  • 当一个工作线程触及了join操作,如果可能的话它将处理其他任务,直到目标任务被告知已经结束(通过isDone方法)。所有的任务都会无阻塞的完成。
  • 当一个工作线程无法再从其他线程中获取任务和失败处理的时候,它就会退出(通过yieldsleep或者优先级调整)并经过一段时间之后再度尝试直到所有的工作线程都被告知他们都处于空闲的状态。在这种情况下,他们都会阻塞直到其他的任务再度被上层调用。

在这里插入图片描述

2.2 在JUC中的实现

1)基本原理

上述窃取操作在实际代码中基于无界双端队列实现。具体原理如下:

  1. 每个工作线程都有自己的工作队列WorkQueue,这是一个双端队列,它是线程私有的;
  2. ForkJoinTask中fork的子任务,将放入运行该任务的工作线程的队头,工作线程将以LIFO的顺序来处理工作队列中的任务;
  3. 为了最大化地利用CPU,空闲的线程将从其它线程的队列中的队尾“窃取”任务来执行,以减少竞争;
  4. 双端队列的操作:push()/pop()仅在其所有者工作线程中调用,poll()是由其它线程窃取任务时调用的;
  5. 当只剩下最后一个任务时,还是会存在竞争,是通过CAS来实现的;

2)核心类

  • ForkJoinPool: 用来执行Task,或生成新的ForkJoinWorkerThread,执行 ForkJoinWorkerThread间的 work-stealing 逻辑。
  • ForkJoinTask: 执行具体的分支逻辑,声明以同步/异步方式进行执行
  • ForkJoinWorkerThread: 是 ForkJoinPool内的 worker thread,执行ForkJoinTask, 内部有ForkJoinPool.WorkQueue来保存要执行的ForkJoinTask
  • ForkJoinPool.WorkQueue:保存要执行的ForkJoinTask

3)ForkJoinPool

ForkJoinPool是 java 7 中新增的线程池类。它同ThreadPoolExecutor一样,也实现了ExecutorExecutorService接口。它使用了一个无限队列来保存需要执行的任务,而线程的数量则可以通过构造函数传入。

在这里插入图片描述

ForkJoinPool不是为了替代ExecutorService,而是它的补充,在某些应用场景下性能比 ExecutorService更好。

ForkJoinPool 最适合的是计算密集型的任务,如果存在 I/O,线程间同步,sleep() 等会造成线程长时间阻塞的情况时,最好配合使用 ManagedBlocker

其包含3个提交方法:

  • execute():异步,不返回结果;
  • invoke():同步,返回结果,调用之后需要等待任务完成,才能执行后面的代码;
  • submit():异步,返回结果,只有在Future调用get的时候会阻塞。

创建方法:

ForkJoinPool有一个方法commonPool(),这个方法返回一个ForkJoinPool内部声明的静态ForkJoinPool实例。这个方法适用于大多数的应用,因为其初始线程数为“CPU核数-1 ”(Runtime.getRuntime().availableProcessors() - 1

除了使用默认线程数,还可以通过java.util.concurrent.ForkJoinPool.common.parallelism进行配置,最大值不能超过MAX_CAP,即32767。必须得在commonPool初始化之前(parallel的stream被调用之前,一般可在系统启动后设置)注入进去,否则无法生效。如果没有指定,则默认为Runtime.getRuntime().availableProcessors() - 1

4)ForkJoinTask

包含两个主要方法:

  • fork()方法:类似于线程的Thread.start()方法,但是它不是真的启动一个线程,而是将任务放入到工作队列中。
  • join()方法:类似于线程的Thread.join()方法,但是它不是简单地阻塞线程,而是利用工作线程运行其它任务。当一个工作线程中调用了join()方法,它将处理其它任务,直到注意到目标子任务已经完成了。可以使用join()来取得ForkJoinTask的返回值。

由于RecursiveTask类实现了Future接口,所以也可以使用get()取得返回值。get()join()有两个主要的区别:

  • join()方法不能被中断。如果你中断调用join()方法的线程,这个方法将抛出InterruptedException异常。
  • 如果任务抛出任何未受检异常,get()方法将返回一个ExecutionException异常,而join()方法将返回一个RuntimeException异常。

ForkJoinTask有3个子类:

  • RecursiveAction:无返回值任务。
  • RecursiveTask:有返回值任务。
  • CountedCompleter:无返回值任务,完成任务后可以触发回调。

3. 实战

假设我们需要实现一个需求:计算一个数组中全部元素的累加和。

3.1 实现方法一:单线程循环累加

public class SimpleCalculator {
    public long sumUp(long[] numbers) {
        long total = 0;
        for (long i : numbers) {
            total += i;
        }
        return total;
    }
}

3.2 实现方法二:ExecutorService多线程并发

public class ExecutorServiceCalculator implements Callable<Long> {
    private long[] numbers;
    private int from;
    private int to;

    public ExecutorServiceCalculator(long[] numbers,int from,int to) {
        this.numbers = numbers;
        this.from=from;
        this.to = to;
    }

    @Override
    public Long call() {
        long total = 0;
        for (int i = from; i <= to; i++) {
            total += numbers[i];
        }
        return total;
    }
}

class CalculateExecutor{
    private int parallism;
    private ExecutorService pool;

    public CalculateExecutor(int parallism) {
        this.parallism = parallism;
        // 创建线程池(禁止使用这种方法创建线程池!!此处只是我懒了(*^▽^*))
        this.pool = Executors.newFixedThreadPool(parallism);
    }

    public long sumUp(long[] numbers) {
        List<Future<Long>> results = new ArrayList<>();

        // 把任务分解为 n 份,交给 n 个线程处理
        // 然后把每一份都扔给一个独立的线程去处理
        int part = numbers.length / parallism;
        for (int i = 0; i < parallism; i++) {
            //开始位置
            int from = i * part;
            //结束位置
            int to = (i == parallism - 1) ? numbers.length - 1 : (i + 1) * part - 1;
            //扔给线程池计算
            results.add(pool.submit(new ExecutorServiceCalculator(numbers, from, to)));
        }

        // 把每个线程的结果相加,得到最终结果 
        // get()方法是阻塞的,可以采用CompletableFuture来优化
        long total = 0L;
        for (Future<Long> f : results) {
            try {
                total += f.get();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return total;
    }
}

本例只是简单的求和,如果要实现复杂的场景,可能还会使用到CountDownLatch、CyclicBarrier等技术,比较复杂。

3.3 实现方法三:使用ForkJoinPool

//执行任务RecursiveTask:有返回值  RecursiveAction:无返回值
class ForkJoinCalculator extends RecursiveTask<Long> {
    private long[] numbers;
    private int from;
    private int to;
    // 递归终止条件
    private int recursionEnd;

    public ForkJoinCalculator(long[] numbers, int from, int to, int recursionEnd) {
        this.numbers = numbers;
        this.from = from;
        this.to = to;
        this.recursionEnd = recursionEnd;
    }

    //使用递归的方式对任务进行拆分
    @Override
    protected Long compute() {

        // 当需要计算的数字个数小于recursionEnd时,直接计算结果
        if (to - from < recursionEnd) {
            long total = 0;
            for (int i = from; i <= to; i++) {
                total += numbers[i];
            }
            return total;
        } else { // 否则,把任务一分为二,递归拆分(注意此处有递归)到底拆分成多少分 需要根据具体情况而定
            int middle = (from + to) / 2;
            ForkJoinCalculator taskLeft = new ForkJoinCalculator(numbers, from, middle, recursionEnd);
            ForkJoinCalculator taskRight = new ForkJoinCalculator(numbers, middle + 1, to, recursionEnd);
            taskLeft.fork();
            taskRight.fork();
            return taskLeft.join() + taskRight.join();
        }
    }
}

public class CalculateExecutor {
    private ForkJoinPool pool;
    private int recursionEnd;

    public CalculateExecutor(int recursionEnd) {
        pool = ForkJoinPool.commonPool();
        this.recursionEnd = recursionEnd;
    }

    public long sumUp(long[] numbers) {
        Long result = pool.invoke(new ForkJoinCalculator(numbers, 0, numbers.length - 1, recursionEnd));
        pool.shutdown();
        return result;
    }
}

可以看到,这段代码里没有显式地把任务分配给线程,只是分解了任务,而把具体的任务到线程的映射交给了 ForkJoinPool 来完成。

使用ForkJoinPool能够使用数量有限的线程来完成非常多的具有父子关系的任务。但是,使用ThreadPoolExecutor时,是很难完成的,因为ThreadPoolExecutor中的Thread无法选择优先执行子任务,需要完成200万个具有父子关系的任务时,也需要200万个线程,或者使用CountDownLatch或Semaphore等工具来实现父线程等待子线程的效果。

3.4 项目优化

在实际项目中,我们一般不会每次都创建一个新的ForkJoinPool,否则出现高并发场景时会导致线程数量爆炸,可以利用Spring框架实现bean复用,例如:

@Bean("threadPoolTaskExecutor")
    public Executor getAsyncExecutor() {
    ForkJoinPool pool = new ForkJoinPool(
             6, ForkJoinPool.defaultForkJoinWorkerThreadFactory, null, true);
        return pool;
    }

或者使用Spring框架提供的ForkJoinPoolFactoryBean

@Bean
@ConditionalOnMissingBean(name = "appRegistryFJPFB")
public ForkJoinPoolFactoryBean appRegistryFJPFB() {
  ForkJoinPoolFactoryBean forkJoinPoolFactoryBean = new ForkJoinPoolFactoryBean();
  forkJoinPoolFactoryBean.setParallelism(4);
  return forkJoinPoolFactoryBean;
}
  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值