并发与高并发(十四)J.U.C组件拓展

前言

J.U.C的拓展组件有哪些?分别在什么场景下使用?如何使用?

主体概要

  • J.U.C-FutureTask
  • J.U.C-ForkJoin
  •  J.U.C-BlockingQueue

主体内容

一、J.U.C-FutureTask

 1.这里要介绍的第一个组件是FutureTask,这个组件是J.U.C里面的,但它不是AQS的子类。这个类对线程结果的处理值得我们去学习。在学习Java多线程的时候,一定接触过Thread,Runnable,一种是直接继承Thread,另一种就是实现Runnable,这两种方式有个共同的缺陷,那就是无法获得线程执行的结果。从Java1.5开始就提供了Callable、Future,通过他们可以在任务执行完毕之后得到任务执行的结果。以下将介绍Callable、Future、FutureTask三个类的使用方法。

2.Callable和Runnable接口对比。

(1)Runnable很简单,只有一个run()方法,任务在run中执行即可。

(2)Callable是一个泛型的接口,它里面有一个call()函数,call函数返回类型就是我们传进去的类型。Callable功能比Runnable更强大些,主要是其执行完毕有返回值,并且能够抛出异常。

3.Future接口。

对于我们具体的Runnable或者Callable的一个任务,他可以进行取消,查询的任务是否被取消,查询是否完成以及获取结果等等。通常线程都是异步进行的,所以不可能从别的线程中直接获得方法的返回值,这个时候Future就有作用了,Future可以监听目标线程调用call的情况,当调用Future的get方法是时候,就可以获得它的结果。通常这个时候线程可能不会直接完成,当前线程就开始阻塞,直到call方法结束,返回出结果,线程才继续执行。总结出一句话:Future它可以得到别的线程任务方法的返回值。

4.FutureTask类。

FutureTask类它的父类是RunnableFuture,而RunnableFuture继承了Runnable和Future这两个接口。由此可见,FutureTask也是执行callable类型的任务,如果构造函数参数是Runnable的话,他会转换成Callable类型。FutureTask实现了两个接口:Runnable和Future,所以它即可以作为Runnable,被线程执行,又可以作为Future,得到Callable的返回值。那么这个组合的使用有什么好处呢?假设有一个很费时的逻辑,需要计算并且返回出值,同时这个值又不是马上就需要得到,那么就可以使用这个组合。用另外一个线程获取返回值,而这个线程就可以在返回值出来之前做其他的操作,等到需要这个返回值的时候,再通过Future得到。

5.接下来,我们举一个例子来演示一下Future类的使用。(例子中已有注释),注重看一下它的输出结果顺序。

import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

@Slf4j
public class FutureExample {
    static class MyCallable implements Callable<String>{//定义一个类实现Callable接口
        @Override
        public String call() throws Exception {
            log.info("do something in callable");
            Thread.sleep(5000);//这里用线程睡了5秒代表它做了很多逻辑的操作
            return "Done";//执行完毕让它返回一个“Done”完成
        }
    }

    public static void main(String[] args) throws Exception {
        ExecutorService executorService = Executors.newCachedThreadPool();//声明一个线程池
        Future<String> future=executorService.submit(new MyCallable());//让线程池直接提交这个任务,并且用Future接受这个结果,这里就是用future接受了另外一个线程的结果
        log.info("do somethis in main");//代表主线程做了其他事情
        Thread.sleep(1000);//这里也假设主线程睡了1秒代表作了1秒的逻辑处理
        String result = future.get();//这时我再调用future的get方法获得之前执行的任务的结果,如果之前的方法一直没有结束,那么主线程就会一直阻塞在这里。
        log.info("result:{}",result);//最后打印一波结果,看看它返回的东西
    }
}

结果发现resultDone在上次输出大概5秒后出现(注意红框中时间),意思就是call中任务到出结果需要理想状态下的5秒,我主线程只要是睡在5秒以内,就不会对输出done的时间造成影响。另外一方面,也验证了Future可以得到其他线程之前的执行结果,只要callable中任务没有完成,主线程将会一直阻塞在future.get():

 6.接下来介绍一下FutureTask的用法,类似的我们还是采用上面例子的逻辑,稍稍改动一下。

import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;
@Slf4j
public class FutureTaskExample {
    public static void main(String[] args) throws Exception {
        FutureTask<String> futureTask = new FutureTask<String>(new Callable() {
            @Override
            public Object call() throws Exception {
                log.info("do something in callable");
                Thread.sleep(5000);//这里用线程睡了5秒代表它做了很多逻辑的操作
                return "Done";//执行完毕让它返回一个“Done”完成
            }
        });

        new Thread(futureTask).start();//这里直接启动一个线程去执行futureTask
        log.info("do something in main");
        Thread.sleep(1000);//让主线程睡1秒模拟其做了1秒的其他任务
        String result = futureTask.get();
        log.info("result:{}",result);
    }
}

结果的效果和上面例子一样,隔了5秒:

 

 其实看起来FutureTask似乎更方面。深入看一下,它不同传入参数的方法。

 /**
     * Creates a {@code FutureTask} that will, upon running, execute the
     * given {@code Callable}.
     *
     * @param  callable the callable task
     * @throws NullPointerException if the callable is null
     */
    public FutureTask(Callable<V> callable) {
        if (callable == null)
            throw new NullPointerException();
        this.callable = callable;
        this.state = NEW;       // ensure visibility of callable
    }

    /**
     * Creates a {@code FutureTask} that will, upon running, execute the
     * given {@code Runnable}, and arrange that {@code get} will return the
     * given result on successful completion.
     *
     * @param runnable the runnable task
     * @param result the result to return on successful completion. If
     * you don't need a particular result, consider using
     * constructions of the form:
     * {@code Future<?> f = new FutureTask<Void>(runnable, null)}
     * @throws NullPointerException if the runnable is null
     */
    public FutureTask(Runnable runnable, V result) {
        this.callable = Executors.callable(runnable, result);
        this.state = NEW;       // ensure visibility of callable
    }

它的参数不仅可以为Callable还可以为Runnable,并且可以指定返回值类型。FutureTask相当于把Future和Callable相关的东西结合了,因此今后比较建议使用FutureTask。

二、J.U.C-ForkJoin

 1.ForkJoin是Java7提供的一个用于定型执行任务的框架,它是一个把大任务分割成小任务,最终汇总小任务结果为大任务结果的框架。它采用的是一个叫做工作窃取(work-stealing)的算法:指某个线程从其他队列里窃取任务来执行。这里有一个大概的流程图。

那么为什么需要使用工作窃取算法呢?

假如我们需要做一个比较大的任务,我们可以把这个任务分割为若干互不依赖的子任务,为了减少线程间的竞争,于是把这些子任务分别放到不同的队列里,并为每个队列创建一个单独的线程来执行队列里的任务,线程和队列一一对应,比如A线程负责处理A队列里的任务。但是有的线程会先把自己队列里的任务干完,而其他线程对应的队列里还有任务等待处理。干完活的线程与其等着,不如去帮其他线程干活,于是它就去其他线程的队列里窃取一个任务来执行。而在这时它们会访问同一个队列,所以为了减少窃取任务线程和被窃取任务线程之间的竞争,通常会使用双端队列,被窃取任务线程永远从双端队列的头部拿任务执行,而窃取任务的线程永远从双端队列的尾部拿任务执行。

工作窃取算法的优点是充分利用线程进行并行计算,并减少了线程间的竞争,其缺点是在某些情况下还是存在竞争,比如双端队列里只有一个任务时。并且消耗了更多的系统资源,比如创建多个线程和多个双端队列。

 

 

 ForkJoin框架有一些局限性:

  • 任务只能使用Fork和join操作来进行同步机制,如果使用了其他同步机制,那他们在同步操作时,工作线程就不能执行其他任务了,比如在Forkjoin框架中,你使任务进入了睡眠,那么在此睡眠期间内,正在执行该任务的工作线程将不会执行其他任务了。
  • 我们所创建的任务不应该执行IO操作,如读写数据文件。
  • 任务不能抛出检查异常,它必须通过必要的代码处理它们。

 ForkJoin框架有两个核心类:ForkJoinPool和ForkJoinTask,其中,ForkJoinPool负责做实现,包括刚刚提到的工作窃取算法,它管理工作线程和提供任务的状态以及它们的执行信息。而ForkJoinTask主要提供任务中Fork和Join操作的机制。关于这个框架的大概介绍到此结束。

2.接下来,用代码来演示一波该框架的用法:计算1到100之和。

import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.Future;
import java.util.concurrent.RecursiveTask;
@Slf4j
public class ForkJoinTaskExample extends RecursiveTask<Integer> {//这里需要继承一个类RecursiveTask,Recursive是递归的意思,返回值设为整型

    public static final int threshold =2;
    private int start;
    private int end;

    public ForkJoinTaskExample(int start,int end){
        this.start=start;
        this.end=end;
    }

    @Override
    protected Integer compute(){
        int sum = 0;
        //如果任务足够小就计算任务
        boolean canCompute = (end -start)<=threshold;//当end=1 start=2 相差1小于我们上面的threshold=2,说明任务足够小不必拆分
        if(canCompute){
            for(int i=start;i<=end;i++){
                sum+=i;
            }
        }else{//当end=1 start=100 相差99大于我们上面的threshold=2,说明任务可以拆分进行
            //如果任务大于阈值,就分裂成两个子任务计算
            int middle =(start+end)/2;//首先从两端取中间值
            ForkJoinTaskExample leftTask = new ForkJoinTaskExample(start,middle);
            ForkJoinTaskExample rightTask = new ForkJoinTaskExample(middle+1,end);
            //执行子任务
            leftTask.fork();
            rightTask.fork();
            //等待任务执行结束合并其结果
            int leftResult = leftTask.join();
            int rightResult = rightTask.join();
            //合并子任务结果
            sum = leftResult+rightResult;
        }
        return sum;
    }

    public static void main(String[] args) {
        ForkJoinPool forkJoinPool = new ForkJoinPool();
        //生成一个计算任务,计算1+2+3+4...
        ForkJoinTaskExample task = new ForkJoinTaskExample(1,100);//传入start=1 end=100相当于从1加到100
        //执行一个子任务
        Future<Integer> result = forkJoinPool.submit(task);
        try{
            log.info("result:{}",result.get());
        }catch (Exception e){
            log.error("exeception",e);
        }
    }
}

结果:

23:01:16.215 [main] INFO com.practice.aqs.ForkJoinTaskExample - result:5050

Process finished with exit code 0

三、J.U.C-BlockingQueue

 1.BlockingQueue就是阻塞队列,从阻塞这个词看起来,在某些情况下,对阻塞队列的访问可能会造成阻塞。阻塞的情况主要有以下两种:第一种,当队列满了的时候,进行入队列操作;第二种,当队列空着的时候,进行出队列的操作。因此,当一个线程试图对一个满了的队列,进行入队列的操作的时候,它将会阻塞,除非有另一个线程进行了出队列的操作;同样的,当一个线程试图对一个空队列进行出队列操作时,它也将会被阻塞,除非有另一个线程进行入队列操作。通过这种情形,我们知道阻塞队列其实是线程安全的。阻塞队列通常应用于生产者、消费者场景。生产者线程不停生产,直到这个队列塞满,生产线程们阻塞,然后消费者线程消费了,生产者们才能继续生产。

 

2.BlockingQueue队列为我们提供了四套方法

操作类型抛出异常(Throws Exception)返回特殊值(Special Value)阻塞线程(Blocks)超时(Times Out)
插入(Insert) add(e) offer(e) put(e) offer(e, time, unit)
删除(Remove) remove() poll() take() poll(time, unit)
读取/检查(Examine) element() peek() / /

我们看下这四套方法各自的特点

  • 抛出异常(Throws Exception):如果操作不能马上进行,就抛出异常。
  • 返回特殊值(Special Value):如果操作不能马上进行,那么就返回一个特殊的值。
  • 阻塞线程(Blocks):如果操作不能马上进行,操作会被阻塞。
  • 超时(Times Out):如果操作不能马上进行,操作会被阻塞指定的时间,如果指定的时间超过,还没有执行,就会返回一个特殊值,一般是true或者false。

这么多方法就不一一演示了,这里先介绍一些类。

3.首先,介绍几个类

  • ArrayBlockingQueue:一个有界的阻塞队列,它的容量是有限的。我们在其初始化的时候需要指定其容量大小,这个大小一般指定后就不能变了。ArrayBlockingQueue以先进先出的方式存储数据,最先插入的对象在尾部,最先移除的对象在头部。
  • DelayQueue:它阻塞的是内部元素,DelayQueue中的元素必须实现一个接口,是J.U.C里的一个叫做Delay的接口,这个Delay接口继承了Comparable接口,这主要是因为DelayQueue中的元素需要进行排序。一般我们都是现在元素过期的优先级进行排序。这个DelayQueue应用场景还是比较多的,比如:定时关闭连接啊,缓存对象啊,超时处理啊。
  • LinkedBlockingQueue:它的大小配置是可选的,如果初始化的是指定了一个大小,那么它就是有边界的,如果不指定,他就是无边界的。它的内部实现是一个链表,除了内部实现不一样,其他大多数和ArrayBlockingQueue一样。它也是以先进先出的方式存储数据。
  • PriorityBlockingQueue:它是一个有带优先级的阻塞队列,他也是一个没有边界的队列,但是它是有排序规则的。需要注意的是,PriorityBlockingQueue是允许插入NULL的,所有插入PriorityBlockingQueue队列的对象必须实现Comparable接口。
  • SynchronousQueue:这个队列内部仅允许容纳一个元素,当一个线程插入一个元素后就会被阻塞,除非这个元素被另一个线程取走。因此,我们又称之为同步队列,它是一个无界非缓存的队列。

以上简单的介绍,有关这几个类的详细例子等待笔者研究过后后续补充...

 

J.U.C思维导图如下

 

posted @ 2020-03-23 21:45  mcbbss  阅读(211)  评论(0编辑  收藏  举报