并发编程(三):线程的并发工具类

一、Fork-Join

java下多线程的开发可以是我们自己启用多线程,线程池,还可以使用forkjoin,forkjoin 可以让我们不去了解诸如 Thread,Runnable 等相关的知识,只要遵循forkjoin 的开发模式,就可以写出很好的多线程并发程序, forkjoin 在处理分而治之这一类问题时非常的有用。

1. 什么是 Fork-Join

Fork/Join框架是Java7提供的一个用于并行执行任务的分治编程框架,是一个把大任务分割成若干个小任务,最终汇总每个小任务结果后得到大任务结果的框架,这种开发方法也叫分治编程。分治编程可以极大地利用CPU资源,提高任务执行的效率,也是目前与多线程有关的前沿技术。

2. 分治编程会遇到什么问题

分治的原理上面说了,就是切割大任务成小任务来完成。咦,看起来好像也不难实现啊!为什么专门弄一个新的框架呢?我们先看一下,在不使用 Fork-Join 框架时,使用普通的线程池是怎么实现的。我们往一个线程池提交了一个大任务,规定好任务切割的阀值。由池中线程(假设是线程A)执行大任务,发现大任务的大小大于阀值,于是切割成两个子任务,并调用 submit() 提交到线程池,得到返回的子任务的 Future。线程A就调用 返回的 Future 的 get() 方法阻塞等待子任务的执行结果。池中的其他线程(除线程A外,线程A被阻塞)执行两个子任务,然后判断子任务的大小有没有超过阀值,如果超过,则按照步骤2继续切割,否则,才计算并返回结果。嘿,好像一切都很美好。真的吗?别忘了, 每一个切割任务的线程(如线程A)都被阻塞了,直到其子任务完成,才能继续往下运行 。如果任务太大了,需要切割多次,那么就会有多个线程被阻塞,性能将会急速下降。更糟糕的是,如果你的线程池的线程数量是有上限的,极可能会造成池中所有线程被阻塞,线程池无法执行任务。

@ Example1 普通线程池实现分治时阻塞的问题

来看一个例子,体会一下吧!下面的例子是将 1+2+...+10 的任务 分割成相加的个数不能超过3(即两端的差不能大于2)的多个子任务。

//普通线程池下实现的分治效果测试
public class CommonThreadPoolTest {
    //固定大小的线程池,池中线程数量为3
    static ExecutorService fixPoolExcutor = Executors.newFixedThreadPool(3);

    public static void main(String[] args) throws InterruptedException, ExecutionException {
        //计算 1+2+...+10  的结果
        CountTaskCallable task = new CountTaskCallable(1,10);
        //提交主人翁
        Future future = fixPoolExcutor.submit(task);
        System.out.println("计算的结果:"+future.get());
    }
}
class CountTaskCallable implements Callable {

    //设置阀值为2
    private static final int THRESHOLD = 2;
    private int start;
    private int end;

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

    @Override
    public Integer call() throws Exception {
        int sum = 0;
        //判断任务的大小是否超过阀值
        boolean canCompute = (end - start) <= THRESHOLD;
        if (canCompute) {
            for (int i = start; i <= end; i++) {
                sum += i;
            }
        } else {
            System.out.println("切割的任务:"+start+"加到"+end+"   执行此任务的线程是 "+Thread.currentThread().getName());
            int middle = (start + end) / 2;
   
            CountTaskCallable leftTaskCallable = new CountTaskCallable(start, middle);
            CountTaskCallable rightTaskCallable = new CountTaskCallable(middle + 1, end);
            // 将子任务提交到线程池中
            Future leftFuture = CommonThreadPoolTest.fixPoolExcutor.submit(leftTaskCallable);
            Future rightFuture = CommonThreadPoolTest.fixPoolExcutor.submit(rightTaskCallable);
            //阻塞等待子任务的执行结果
            int leftResult = leftFuture.get();
            int rightResult = rightFuture.get();
            // 合并子任务的执行结果
            sum = leftResult + rightResult;
            
        }
        return sum;
    }

}

运行结果

切割的任务:1加到10 执行此任务的线程是 pool-1-thread-1
切割的任务:1加到5 执行此任务的线程是 pool-1-thread-2
切割的任务:6加到10 执行此任务的线程是 pool-1-thread-3

池的线程只有三个,当任务分割了三次后,池中的线程也就都被阻塞了,无法再执行任何任务,一直卡着动不了。

3、Fork-Join 原理

image

4、工作窃取

针对上面的问题,Fork-Join 框架使用了 “工作窃取(work-stealing)”算法。工作窃取(work-stealing)算法是指某个线程从其他队列里窃取任务来执行。看一下《Java 并发编程的艺术》对工作窃取算法的解释:

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

image

Fork-Join 框架使用工作窃取算法对分治编程实现的描述:

下面是 ForkJoin 框架对分治编程实现的过程的描述,增加对工作窃取算法的理解。在下面的内容提供了一个分治的例子,可结合这部分描述一起看。

(1)Fork-Join 框架的线程池ForkJoinPool 的任务分为“外部任务” 和 “内部任务”。
(2)“外部任务”是放在 ForkJoinPool 的全局队列里;
(3)ForkJoinPool 池中的每个线程都维护着一个内部队列,用于存放“内部任务”。
(4)线程切割任务得到的子任务就会作为“内部任务”放到内部队列中。
(5)当此线程要想要拿到子任务的计算结果时,先判断子任务没有完成,如果没有完成,则再判断子任务有没有被其他线程“窃取”,一旦子任务被窃取了则去执行本线程“内部队列”的其他任务,或者扫描其他的任务队列,窃取任务,如果子任务没有被窃取,则由本线程来完成。
(6)最后,当线程完成了其“内部任务”,处于空闲的状态时,就会去扫描其他的任务队列,窃取任务,尽可能地
总之,ForkJoin线程在等待一个任务的完成时,要么自己来完成这个任务,或者在其他线程窃取了这个任务的情况下,去执行其他任务,是不会阻塞等待,从而避免浪费资源,除非是所有任务队列都为空。

工作窃取算法的优点:

(1)线程是不会因为等待某个子任务的完成或者没有内部任务要执行而被阻塞等待、挂起,而是会扫描所有的队列,窃取任务,直到所有队列都为空时,才会被挂起。 就如上面所说的。
(2)Fork-Join 框架在多CPU的环境下,能提供很好的并行性能。在使用普通线程池的情况下,当CPU不再是性能瓶颈时,能并行地运行多个线程,然而却因为要互斥访问一个任务队列而导致性能提高不上去。而 Fork-Join 框架为每个线程为维护着一个内部任务队列,以及一个全局的任务队列,而且任务队列都是双向队列,可从首尾两端来获取任务,极大地减少了竞争的可能性,提高并行的性能。

5、Fork/Join 实战

1)Fork/Join 使用的标准范式

我们要使用 ForkJoin 框架,必须首先创建一个 ForkJoin 任务。它提供在任务中执行 fork 和 join 的操作机制,通常我们不直接继承 ForkjoinTask 类,只需要直接继承其子类。

(1)RecursiveAction,用于没有返回结果的任务

(2) RecursiveTask,用于有返回值的任务

task 要通过 ForkJoinPool 来执行,使用 submit 或 invoke 提交,两者的区别是:invoke 是同步执行,调用之后需要等待任务完成,才能执行后面的代码;submit 是异步执行。

join()和 get 方法当任务完成的时候返回计算结果。

在我们自己实现的 compute 方法里,首先需要判断任务是否足够小,如果足够小就直接执行任务。如果不足够小,就必须分割成两个子任务,每个子任务在调用 invokeAll 方法时,又会进入 compute 方法,看看当前子任务是否需要继续分割成孙任务,如果不需要继续分割,则执行当前子任务并返回结果。使用 join方法会等待子任务执行完并得到其结果。

2)、Fork/Join 的同步用法和异步用法

同步示例

/**
 * forkjoin实现的归并排序
 */
public class FkSort {
    private static class SumTask extends RecursiveTask{

        private final static int THRESHOLD = 2;
        private int[] src;

        public SumTask(int[] src) {
            this.src = src;
        }

        @Override
        protected int[] compute() {
            if(src.length<=THRESHOLD){
                return InsertionSort.sort(src);
            }else{
                //fromIndex....mid.....toIndex
                int mid = src.length / 2;
                SumTask leftTask = new SumTask(Arrays.copyOfRange(src, 0, mid));
                SumTask rightTask = new SumTask(Arrays.copyOfRange(src, mid, src.length));
                invokeAll(leftTask,rightTask);
                int[] leftResult = leftTask.join();
                int[] rightResult = rightTask.join();
                return MergeSort.merge(leftResult,rightResult);
            }
        }
    }


    public static void main(String[] args) {

        ForkJoinPool pool = new ForkJoinPool();
        int[] src = MakeArray.makeArray();

        SumTask innerFind = new SumTask(src);

        long start = System.currentTimeMillis();

        /*同步提交*/
        int[] invoke = pool.invoke(innerFind);
//        for(int number:invoke){
//            System.out.println(number);
//        }
        System.out.println(" spend time:"+(System.currentTimeMillis()-start)+"ms");

    }
}

异步示例

/**
 *类说明:遍历指定目录(含子目录)找寻指定类型文件
 */
public class FindDirsFiles extends RecursiveAction {

    private File path;

    public FindDirsFiles(File path) {
        this.path = path;
    }

    @Override
    protected void compute() {
        List subTasks = new ArrayList<>();

        File[] files = path.listFiles();
        if (files!=null){
            for (File file : files) {
                if (file.isDirectory()) {
                    // 对每个子目录都新建一个子任务。
                    subTasks.add(new FindDirsFiles(file));
                } else {
                    // 遇到文件,检查。
                    if (file.getAbsolutePath().endsWith("txt")){
                        System.out.println("文件:" + file.getAbsolutePath());
                    }
                }
            }
            if (!subTasks.isEmpty()) {
                // 在当前的 ForkJoinPool 上调度所有的子任务。
                for (FindDirsFiles subTask : invokeAll(subTasks)) {
                    subTask.join();
                }
            }
        }
    }

    public static void main(String [] args){
        try {
            // 用一个 ForkJoinPool 实例调度总任务
            ForkJoinPool pool = new ForkJoinPool();
            FindDirsFiles task = new FindDirsFiles(new File("F:/"));

            /*异步提交*/
            pool.execute(task);

            /*主线程做自己的业务工作*/
            System.out.println("Task is Running......");
            Thread.sleep(1);
            int otherWork = 0;
            for(int i=0;i<100;i++){
                otherWork = otherWork+i;
            }
            System.out.println("Main Thread done sth......,otherWork="
                    +otherWork);
            //task.join();//阻塞方法
            System.out.println("Task end");
        } catch (Exception e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
    }
}

二、CountDownLatch

闭锁,CountDownLatch 这个类能够使一个线程等待其他线程完成各自的工作后再执行。例如,应用程序的主线程希望在负责启动框架服务的线程已经启动所有的框架服务之后再执行。

CountDownLatch 是通过一个计数器来实现的,计数器的初始值为初始任务的数量。每当完成了一个任务后,计数器的值就会减 1(CountDownLatch.countDown()方法)。当计数器值到达 0 时,它表示所有的已经完成了任务,然后在闭锁上等待 CountDownLatch.await()方法的线程就可以恢复执行任务。

应用场景:

实现最大的并行性:有时我们想同时启动多个线程,实现最大程度的并行性。例如,我们想测试一个单例类。如果我们创建一个初始计数为 1 的CountDownLatch,并让所有线程都在这个锁上等待,那么我们可以很轻松地完成测试。我们只需调用 一次 countDown()方法就可以让所有的等待线程同时恢复执行。

开始执行前等待 n 个线程完成各自任务:例如应用程序启动类要确保在处理用户请求前,所有 N 个外部系统已经启动和运行了,例如处理 excel 中多个表单。

image

参见代码包 cn.enjoyedu.ch2.tools 下

三、CyclicBarrier

CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续运行。CyclicBarrier 默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用 await 方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。

CyclicBarrier 还提供一个更高级的构造函数 CyclicBarrie(r int parties,RunnablebarrierAction),用于在线程到达屏障时,优先执行 barrierAction,方便处理更复杂的业务场景。

CyclicBarrier 可以用于多线程计算数据,最后合并计算结果的场景。

参见代码包 cn.enjoyedu.ch2.tools 下

四、CountDownLatch 和 CyclicBarrier 辨析

CountDownLatch 的计数器只能使用一次,而 CyclicBarrier 的计数器可以反复使用。

CountDownLatch.await 一般阻塞工作线程,所有的进行预备工作的线程执行countDown,而 CyclicBarrier 通过工作线程调用 await 从而自行阻塞,直到所有工作线程达到指定屏障,再大家一起往下走。

在控制多个线程同时运行上,CountDownLatch 可以不限线程数量,而CyclicBarrier 是固定线程数。同时,CyclicBarrier 还可以提供一个 barrierAction,合并多线程计算结果。

五、Semaphore

Semaphore(信号量)是用来控制同时访问特定资源的线程数量,它通过协调各个线程,以保证合理的使用公共资源。应用场景 Semaphore 可以用于做流量控制,特别是公用资源有限的应用场景,比如数据库连接。假如有一个需求,要读取几万个文件的数据,因为都是 IO 密集型任务,我们可以启动几十个线程并发地读取,但是如果读到内存后,还需要存储到数据库中,而数据库的连接数只有 10 个,这时我们必须控制只有 10 个线程同时获取数据库连接保存数据,否则会报错无法获取数据库连接。这个时候,就可以使用 Semaphore 来做流量控制。。Semaphore 的构造方法 Semaphore(int permits)接受一个整型的数字,表示可用的许可证数量。Semaphore 的用法也很简单,首先线程使用 Semaphore的 acquire()方法获取一个许可证,使用完之后调用 release()方法归还许可证。还可以用 tryAcquire()方法尝试获取许可证。Semaphore 还提供一些其他方法,具体如下。

•intavailablePermits():返回此信号量中当前可用的许可证数。

•intgetQueueLength():返回正在等待获取许可证的线程数。

•booleanhasQueuedThreads():是否有线程正在等待获取许可证。

•void reducePermit(s int reduction):减少 reduction 个许可证,是个 protected方法。

•Collection getQueuedThreads():返回所有等待获取许可证的线程集合,是个 protected 方法。

1、用 Semaphore 实现数据库连接池

参见代码,包 cn.enjoyedu.ch2.tools.semaphore 下

2、Semaphore 注意事项

参见代码类 cn.enjoyedu.ch2.tools.semaphore. DBPoolNoUseless 下

六、Exchange

Exchanger(交换者)是一个用于线程间协作的工具类。Exchanger 用于进行线程间的数据交换。它提供一个同步点,在这个同步点,两个线程可以交换彼此的数据。这两个线程通过 exchange 方法交换数据,如果第一个线程先执行exchange()方法,它会一直等待第二个线程也执行 exchange 方法,当两个线程都到达同步点时,这两个线程就可以交换数据,将本线程生产出来的数据传递给对方。

参见代码包 cn.enjoyedu.ch2.tools 下

七、Callable、Future 和 FutureTask

Runnable 是一个接口,在它里面只声明了一个 run()方法,由于 run()方法返回值为 void 类型,所以在执行完任务之后无法返回任何结果。

Callable 位于 java.util.concurrent 包下,它也是一个接口,在它里面也只声明了一个方法,只不过这个方法叫做 call(),这是一个泛型接口,call()函数返回的类型就是传递进来的 V 类型。

Future 就是对于具体的 Runnable 或者 Callable 任务的执行结果进行取消、查询是否完成、获取结果。必要时可以通过 get 方法获取执行结果,该方法会阻塞直到任务返回结果。

image

因为 Future 只是一个接口,所以是无法直接用来创建对象使用的,因此就有了下面的 FutureTask。

image

FutureTask 类实现了 RunnableFuture 接口,RunnableFuture 继承了 Runnable接口和 Future 接口,而 FutureTask 实现了 RunnableFuture 接口。所以它既可以作为 Runnable 被线程执行,又可以作为 Future 得到 Callable 的返回值。

image

因此我们通过一个线程运行 Callable,但是 Thread 不支持构造方法中传递Callable 的实例,所以我们需要通过 FutureTask 把一个 Callable 包装成 Runnable,然后再通过这个 FutureTask 拿到Callable 运行后的返回值。

要 new 一个 FutureTask 的实例,有两种方法

image

参见代码包 cn.enjoyedu.ch2.tools 下

你可能感兴趣的:(并发编程(三):线程的并发工具类)