ForkJoin框架的初步了解(详解)

ForkJoin框架的初步理解

一. JUC简述

  • 在JAVA中,线程部分是一个重点,而JUC(java.util.concorrent 工具包的简称)则是一个专门处理线程的工具包。它是在JDK1.5开始出现的,目的就是为了更好地支持高并发任务,方便开发者利用这个包进行多线程编程时可以有效的减少竞争条件和死锁线程。

我们先看下这个包的内容:

(可能有点多)但既然要从头学起了,我们还是先看一下这个包都有啥!希望日后能逐个击破每一个并发工具!

阻塞队列 + 延时队列 + 线程池 + 信号量 + 线程安全类concurrent:

AbstractExecutorService
ArrayBlockingQueue
BlockingDeque
BlockingQueue
Callable
CompletionService
ConcurrentHashMap
ConcurrentLinkedDeque
ConcurrentLinkedQueue
ConcurrentMap
ConcurrentNavigableMap
ConcurrentSkipListMap
ConcurrentSkipListSet
CopyOnWriteArrayList
CopyOnWriteArraySet
CountDownLatch
CyclicBarrier
Delayed
DelayQueue
Exchanger
Executor
ExecutorCompletionService
Executors
ExecutorService
ForkJoinPool
ForkJoinTask
ForkJoinWorkerThread

Future
FutureTask

LinkedBlockingDeque
LinkedBlockingQueue
LinkedTransferQueue
Phaser
PriorityBlockingQueue
RecursiveAction
RecursiveTask

RejectedExecutionHandler
RunnableFuture
RunnableScheduledFuture
ScheduledExecutorService
ScheduledFuture
ScheduledThreadPoolExecutor
Semaphore
SynchronousQueue
ThreadFactory
ThreadLocalRandom
ThreadPoolExecutor
TimeUnit
TransferQueue

原子操作数类atomic:

AtomicBoolean
AtomicInteger
AtomicIntegerArray
AtomicIntegerFieldUpdater
AtomicLong
AtomicLongArray
AtomicLongFieldUpdater
AtomicMarkableReference
AtomicReference
AtomicReferenceArray
AtomicReferenceFieldUpdater
AtomicStampedReference

可重入锁类locks:

AbstractOwnableSynchronizer
AbstractQueuedLongSynchronizer
AbstractQueuedSynchronizer
Condition
Lock
LockSupport
ReadWriteLock
ReentrantLock
ReentrantReadWriteLock

尽管有这么多的抽象类和接口,但不用害怕,这次我们学习的是ForkJoin框架,仅仅是带黄色标记的几个抽象类和接口,其他的都不需要去管,找到这个框架的根源就可以了。

二. ForkJoin框架

1.ForkJoin概述

ForkJoin框架是用来并行执行任务的框架,它可以把一个大任务分割成多个小任务,然后各个小任务去执行然后再把执行结果汇总在一起得到最终的结果,通过这种拆分实现多线程并行执行充分的利用CPU资源,提高程序的效率。

类似与一种分而治之的思想。比如计算1+2+。。。+10000,可以分割成10个子任务,每个子任务分别对1000个数进行求和,最终汇总这10个子任务的结果。

看个图理解一下吧:
forkjoin图例
由最初的任务被fork(任务分割)为任务1和任务2,任务1和任务2再各自分割为更小的任务1.1 1.2 2.1 2.2,待最小的任务执行完毕后,将结果join(任务合并)汇总为任务1结果和任务2结果,最后由任务1结果和任务2结果汇总得出最终结果。

但是这里有个问题,到底任务是要分到多小才合适?分小到哪种地步呢? 这个常常要看具体的情况,比如上面1到10000的求和例子,我们一般设一个值为1000(一般在这里叫阈值),即可将任务分为10个子任务,当然也可以设置为2000,阈值可以由我们来调整,但要使用合理的阈值,把阈值设的过小或过大都不合适。


2.工作窃取算法(work-stealing)

一个大任务拆分成多个小任务,为了减少线程间的竞争,把这些子任务分别放到不同的队列中,并且每个队列都有单独的线程来执行队列里的任务,线程和队列一一对应。

但是会出现这样一种情况:A线程完成了自己的任务,但是B线程里的队列还有很多任务需要处理。

A线程的活儿容易一点,很快就干完了,但是B线程还没有完成,所以为了提高效率我们让空闲的A线程过去帮忙,但是如果两个线程访问同一个队列,会产生竞争(线程A和线程B都取第一个任务,就会产生竞争)。
所以想了一个办法,让线程A从双端队列的尾部拿任务执行。而B线程是永远从双端队列的头部拿任务执行。

看个图理解一下:
工作窃取算法图例

既然如此,那也会产生双端队列只剩下一个任务的情况,A线程和B线程仍然会产生竞争(只剩下一个任务,A线程要拿,B线程也要拿),这也是工作窃取算法的一个缺点,还有就是窃取算法创建了更多的线程和队列,这无疑消耗了更多的系统资源。

但既然是工作窃取算法,自然也有优点,优点是:充分利用了线程并行计算,减少了线程间的竞争。


3.ForkJoin主要类

学习了这个框架的大致思想,我们来看一下ForkJoin框架的主要组成类,主要类有 ForkJoinTask(任务)ForkJoinPool(线程池)ForkJoinWorkerThread(工作线程),外加 WorkQueue(任务队列),它的核心在于分治工作窃取

  • ForkJoinTask : 运行在ForkJoinPool的一个任务的抽象,Future接口的实现类。fork是其核心方法,用于分解任务并异步运行;而join方法在任务结果计算完毕之后才会运行,用来合并或返回计算结果。

  • ForkJoinPool : ExecutorService的实现类,负责工作线程的管理、任务队列的维护,以及控制整个任务调度流程, 执行ForkJoinTask任务的一个线程池。

  • ForkJoinWorkerThread: Thread的子类,作为线程池中的工作线程(Worker)执行任务。

  • WorkQueue : 任务队列,用于保存任务。

ForkJoinTask

从ForkJoin框架的描述上来看,"任务"要满足的条件:

1.支持Fork,即任务自身的分解
2.支持Join,即任务结果的合并

因此,JUC提供了一个抽象类------ForkJoinTask,作为该框架任务的抽象定义。

我们先来看一下ForkJoinTask抽象类的定义:

 1 public abstract class ForkJoinTask <V> implements Future<V>,Serializable

ForkJoinTask实现了Future接口和Serializable(序列化类,这个可以不用管),而Future接口还要认识一下,不然实在是太困惑人了,这个我们等下简单说一下,现在先不用管。

ForkJoinTask中最核心的两个方法就是fork()join(),很明显,前者分割任务,后者合并任务,这里的

join()Future接口里的get() 方法很相似(因为本来就是继承的Future接口,ForkJoinTask可以看作一个轻

量级的Future),都会产生阻塞,一直等到任务执行完毕才返回,但区别是get()方法会抛出异常,join()方法不会.

ForkJoinTaskForkJoinPool中执行的任务的基本类型,它是一个抽象类,它也有两个子类:
1.RecursiveAction:表示没有返回结果的ForkJoin任务
2.RecursiveTask:表示有返回结果的ForkJoin任务

所谓Recursive就是递归的意思,不断对任务的分割,其实每段的任务运算逻辑是一样的,顾名思义所以叫RecursiveTask

因此在实际使用中根据任务是否有返回值,我们常常继承RecursiveTask或RecursiveAction,作为真正执行的任务,现在来创建一个任务:

//创建一个有返回结果且返回值为Long类型的任务
1 public class ForkJoinDemo extends RecursiveTask<Long>{

}
//创建一个无返回结果的任务
1 public class ForkJoinDemo extends RecursiveAction{

}

看下这几个类的类图:
继承关系
它们都有一个抽象方法compute(),其中定义了任务的逻辑,也就是要覆写的方法,就像我们继承Runnable接口实现多线程必须要覆写run() 方法一样。

ForkJoinPool

ForkJoinPool是执行ForkJoinTask的线程池,我们将任务提交到ForkJoinPool之后,它维护的线程就会执行

我们提交的任务,ForkJoinPool会通过工作窃取算法,让线程在完成本身任务之后帮其他线程执行任务,

这个调度是ForkJoinPool的核心功能。

ForkJoinPool提供了3类外部提交任务的方法:invokeexecutesubmit,它们的主要区别如下:

  • 通过invoke方法提交的任务,调用线程直到任务执行完成才会返回,也就是说这是一个同步方法,且有返回结果。
  • 通过execute方法提交的任务,调用线程会立即返回,也就是说这是一个异步方法,且没有返回结果。
  • 通过submit方法提交的任务,调用线程会立即返回,也就是说这是一个异步方法,且有返回结果(返回Future实现类,可以通过get获取结果)。

而ForkJoinWorkerThread则是线程池中一个执行任务的线程,每个线程都有对应的队列数组,WorkQueue保存着任务。

三. 实例

这里我们以一个带返回值的任务为例。

题目:求1一直加到10亿的和。

这里最简单的便是使用一个简单的For循环了,当然我们肯定要使用今天的ForkJoin框架,我们用这两种方法各运算,对比一下。

首先建一个Task任务:

import java.util.concurrent.RecursiveTask;

public class ForkJoinDemo extends RecursiveTask<Long>{
    
    private static final long serialVersionUID = -3923889751453431920L;
    //继承ForkjoinTask的子类RecursiveTask,该类有返回值,
    //另一个子类为RecursiveAction为处理事务,无返回值
    private Long start;//起始值
    private Long end;//结束值
    private static final Long temp = 10000L;//临界值,也可以叫阈值

    public ForkJoinDemo(Long start, Long end) {
        this.start = start;
        this.end = end;
    }
    @Override
    //必须重写计算方法,该方法为Task实现过程
    protected Long compute() {
        //如果这个数超过临界值,就分任务计算
        if((end - start) <= temp) {//当end-start>10000时,继续分割任务,当小于10000时说明任务已经足够小了,
        //可以进行小任务求和
            long sum = 0L;
            for(Long i = start;i<=end; i++) {
                sum += i;
            }
            return sum;
        } else {
            //获取中间值,任务一分为二
            long middle = (start + end) / 2;
            ForkJoinDemo LeftTask  = new ForkJoinDemo(start, middle);//第一个任务
            LeftTask.fork();//这里线程池接受Task任务fork出的子任务的提交
            ForkJoinDemo RightTask = new ForkJoinDemo(middle+1, end);//第一个任务
            RightTask.fork();
            return LeftTask.join() + RightTask.join();//合并结果
        }
    }
}

然后我们把ForkJoinDemo这个任务放入线程池中运行:

import java.util.concurrent.ForkJoinPool;
import java.util.stream.LongStream;

public class ForkJoinTest {

    public static void main(String[] args) {
        test1();//正常的求和测试
        test2();//ForkJoin测试
    }

    //正常的求和测试
    public static void test1() {
        long start = System.currentTimeMillis();//这里记录一个程序开始进行求和的时间戳
        long sum = 0L;
        for(Long i=0L; i<=1000000000L; i++) {
            sum+= i;
        }
        long end = System.currentTimeMillis();//这里记录一个程序结束求和的时间戳
        System.out.println("正常操作求和:");
        System.out.println("Time : " + (end-start)+  "ms" + " " + "Sum = " + sum);
    }

    //ForkJoin测试
    public static void test2() {
        long start = System.currentTimeMillis();
        ForkJoinPool forkJoinPool = new ForkJoinPool();
        ForkJoinDemo forkJoinDemo = new ForkJoinDemo(0L, 1000000000L);
        long sum = forkJoinPool.invoke(forkJoinDemo);
        long end = System.currentTimeMillis();
        System.out.println(" ");
        System.out.println("ForkJoin求和:");
        System.out.println("Time : " + (end-start)+  "ms" + " " + "Sum = " + sum);
    }
}

test1() 为正常的求和测试,test2()则是把该任务放入线程池中调度,这里我们设置合适的阈值为10000L,也就是临界值。

下面来看下运行结果:

结果
显然使用ForkJoin提高了程序效率。

四. 总结

使用fork / join框架可以加速处理大型任务,但要实现这一结果,应遵循一些指导原则:

  • 使用尽可能少的线程池 - 在大多数情况下,最好的决定是为每个应用程序或系统使用一个线程池
  • 如果不需要特定调整,请使用默认的公共线程池
  • 使用合理的阈值将ForkJoingTask拆分为子任务
  • 避免在 ForkJoingTasks中出现任何阻塞
  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值