Java并发——执行框架与线程池

2,843 阅读19分钟

       大多数并发应用程序都是围绕“任务执行(Task Execution)”来构造的:任务通常是一些抽象的且离散的工作单元。通过把应用程序的工作分解到多个任务中,可以简化程序的组织结构

Executor任务执行框架

        通过围绕任务执行来设计应用程序,可以简化开发过程,并有助于实现并发。Executor 框架将任务提交与执行策略解耦开来,同时还支持多种不同类型的执行策略。当需要创建线程来执行任务时,可以考虑使用Executor。 要想在将应用程序分解为不同的任务时获得最大的好处,必须定义清晰的任务边界。某些应用程序中存在着比较明显的任务边界,而在其他一些程序中则需要进一步分析才能揭示出粒度更细的并行性。

介绍  

     任务是一组逻辑工作单元,而线程则是使任务异步执行的机制。在Java类库中,任务执行的主要抽象不是Thread,而是Executor。

       Executor是一个接口,它是Executor框架的基础,它将任务的提交与任务的执行分离开 来。该框架能支持多种不同类型的任务执行策略。它提供了一种标准的方法将任务的提交过程与执行过程解耦开来,并用Runnable来表示任务。Executor的实现还提供了对生命周期的支持,以及统计信息收集、应用程序管理机制和性能监视等机制。

 Executor框架结构

 Executor框架主要由3大部分组成如下:

  • 任务。包括被执行任务需要实现的接口:Runnable接口或Callable接口。
  • 任务的执行。包括任务执行机制的核心接口Executor,以及继承自Executor的 ExecutorService接口
  • 异步计算的结果。包括接口Future和实现Future接口的FutureTask类。

 Executor框架执行策略

       通过将任务的提交与执行解耦开来,从而无须太大的困难就可以为某种类型的任务指定和修改执行策略。

       各种执行策略都是一种资源管理工具,最佳策略取决于可用的计算资源以及对服务质量的需求。通过限制并发任务的数量,可以确保应用程序不会由于资源耗尽而失败,或者由于在稀缺资源上发生竞争而严重影响性能。通过将任务的提交与任务的执行策略分离开来,有助于在部署阶段选择与可用硬件资源最匹配的执行策略。 每当看到下面这种形式的代码时:

 new Thread(runnable).start()

并且你希望获得一种更灵活的执行策略时,请考虑使用Executor来代替Thread。

Executor的生命周期

       我们已经知道如何创建一个Executor,但并没有讨论如何关闭它。Executor的实现通常会创建线程来执行任务。但JVM只有在所有(非守护)线程全部终止后才会退出。因此,如果无法正确地关闭Executor,那么JVM将无法结束。

       由于Executor以异步方式来执行任务,因此在任何时刻,之前提交任务的状态不是立即可见的。有些任务可能已经完成,有些可能正在运行,而其他的任务可能在队列中等待执行。当关闭应用程序时,可能采用最平缓的关闭形式,可能采用最粗暴的关闭形式。 为了解决执行服务的生命周期问题,Executor 扩展了ExecutorService接口,添加了一些用于生命周期管理的方法(同时还有一些用于任务提交的便利方法)。

ExecutorService的生命周期有3种状态: 运行、关闭和已终止。

ExecutorService在初始创建时处于运行状态。

  • shutdown()方法将执行平缓的关闭过程:不再接受新的任务,同时等待已经提交的任务执行完成——包括那些还未开始执行的任务。
  • shutdownNow()方法将执行粗暴的关闭过程:它将尝试取消所有运行中的任务,并且不再启动队列中尚未开始执行的任务。 

等所有任务都完成后,ExecutorService将转入终止状态。

  • awaitTermination()方法等待ExecutorService到达终止状态。
  • isTerminated()方法来轮询ExecutorService是否已经终止。

通常在调用awaitTermination()之后会立即调用shutdown(),从而产生同步地关闭ExecutorService的效果。

关闭ExecutorService

           ExecutorService提供了两种关闭方法:使用shutdown正常关闭,以及使用shutdownNow强行关闭。在进行强行关闭时,shutdownNow首先关闭当前正在执行的任务,然后返回所有尚未启动的任务清单。 这两种关闭方式的差别在于各自的安全性和响应性:强行关闭的速度更快,但风险也更大,因为任务很可能在执行到一半时被结束;而正常关闭虽然速度慢,但却更安全,因为ExecutorService会一直等到队列中的所有任务都执行完成后才关闭。在其他拥有线程的服务中也应该考虑提供类似的关闭方式以供选择。

Callable接口和Runnable接口

       Executor 框架使用Runnable作为其基本的任务表示形式。Runnable是一种有很大局限,虽然run能写入到日志文件或者将结果放入某个共享的数据结构,但它不能返回一个值或抛出一个受检查的异常。

       许多任务实际上都是存在延迟的计算——执行数据库查询,从网络上获取资源,或者计算某个复杂的功能。对于这些任务,Callable是一种更好的抽象:它可以将返回一个值(要使用Callable来表示无返回值的任务,可使用Callable),并可能抛出一个异常。

Executors工具类提供了一系列方法用于将Runnable转化成Callable,下面列出两种:

  • Executors.callable(runnable, result) 返回一个Callable对象,当被调用时,它运行给定的任务并返回给定的结果。
  • Executors.callable(runnable) 返回一个Callable对象,当被调用时,它运行给定的任务并返回null 。

Runnable和Callable描述的都是抽象的计算任务。这些任务通常是有范围的,即都有一个明确的起始点,并且最终会结束。Executor执行的任务有4个生命周期阶段:创建、提交、开始和完成。由于有些任务可能要执行很长的时间,因此通常希望能够取消这些任务。在Executor框架中,已提交但尚未开始的任务可以取消,但对于那些已经开始执行的任务,只有当它们能响应中断时,才能取消。取消一个已经完成的任务不会有任何影响。

                       run()方法返回值为void类型,所以在执行完任务之后无法返回任何结果

                               call()函数返回的类型就是传递进来的V类型

Future

      Future表示一个可能还没有完成的异步任务的结果,针对这个结果可以添加Callback以便在任务执行成功或失败后作出相应的操作。 Future表示一个任务的生命周期,并提供了相应的方法来判断是否已经完成或取消,以及获取任务的结果和取消任务等。在Future规范中包含的隐含意义是,任务的生命周期只能前进,不能后退,就像ExecutorService的生命周期一样。当某个任务完成后,它就永远停留在“完成”状态上。 get方法的行为取决于任务的状态(尚未开始、正在运行、已完成)。如果任务已经完成,那么get会立即返回或者抛出一个Exception,如果任务没有完成,那么get将阻塞并直到任务完成。如果任务抛出了异常,那么get将该异常封装为ExecutionException并重新抛出。如果任务被取消,那么get将抛出CancellationException。如果get 抛出了ExecutionException,那么可以通过getCause来获得被封装的初始异常。

        可以通过许多种方法创建一个Future来描述任务。ExecutorService中的所有submit方法都将返回一个Future,从而将一个Runnable或Callable提交给Executor,并得到一个Future用来获得任务的执行结果或者取消任务。

 public static void main(String[] args) {
        ExecutorService executor = Executors.newCachedThreadPool();
        Future<Integer> future = executor.submit(new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                for (int i = 0; i <5 ; i++) {
                    System.out.println(i);
                }
                return 10;
            }
        });
        try{

            Integer result = future.get();
            System.out.println(result);
        }catch (Exception ex){
            ex.printStackTrace();
        }
        executor.shutdown();
  }

通过Future实现取消

       Future拥有一个cancel方法,该方法带有一个boolean类型的参数mayInterruptlfRunning,表示取消操作是否成功。(这只是表示任务是否能够接收中断,而不是表示任务是否能检测并处理中断。)如果mayInterruptIfRunning为true并且任务当前正在某个线程中运行,那么这个线程能被中断。如果这个参数为false,那么意味着“若任务还没有启动,就不要运行它”,这种方式应该用于那些不处理中断的任务中。除非你清楚线程的中断策略,否则不要中断线程,那么在什么情况下调用cancel可以将参数指定为true?执行任务的线程是由标准的Executor创建的,它实现了一种中断策略使得任务可以通过中断被取消,所以如果任务在标准Executor中运行,并通过它们的Future来取消任务,那么可以设置mayInterruptlfRunning。当尝试取消某个任务时,不宜直接中断线程池,因为你并不知道当中断请求到达时正在运行什么任务一—只能通过任务的Future来实现取消。 这也是在编写任务时要将中断视为一个取消请求的另一个理由:可以通过任务的Future来取消它们。

FutureTask

         FutureTask除了实现Future接口外,还实现了Runnable接口。因此,FutureTask可以交给 Executor执行,也可以由调用线程直接执行(FutureTask.run())。当一个线程需要等待另一个线程把某个任务执行完后它才能继续执行,此时可以使用 FutureTask。

         FutureTask表示的计算是通过 Callable来实现的,相当于一种可生成结果的Runnable,并且可以处于以下3种状态:

  • 未启动状态: FutureTask.run()方法还没有被执行之前,FutureTask处于未启动状态。当创建一 个FutureTask,且没有执行FutureTask.run()方法之前,这个FutureTask处于未启动状态。
  • 已启动: FutureTask.run()方法被执行的过程中,FutureTask处于已启动状态。
  • 运行完成(Completed):  “执行完成”表示计算的所有可能结束方式,包括正常结束、取消而结束和异常而结束等。当 FutureTask进入完成状态后,它会永远停止在这个状态上。FutureTask将计算结果从执行计算的线程传递到获取这个结果的线程,而FutureTask的规范确保了这种传递过程能实现结果的安全发布。

CompletionService

          如果向 Executor 提交了一组计算任务,并且希望在计算完成后获得结果,那么可以保留与每个任务关联的Future,然后反复使用get 方法,同时将参数timeout指定为0,从而通过轮询来判断任务是否完成。这种方法虽然可行,但却有些繁琐。幸运的是,还有一种更好的方法: 完成服务(CompletionService)。

        CompletionService 将 Executor 和BlockingQueue 的功能融合在一起。你可以将 Callable任务提交给它来执行,然后使用类似于队列操作的take 和poll等方法来获得已完成的结果,而这些结果会在完成时将被封装为Future. ExecutorCompletionService实现了CompletionService,并将计算部分委托给一个Executor。 

         ExecutorCompletionService 的实现非常简单。在构造函数中创建一个 BlockingQueue来保存计算完成的结果。当计算完成时,调用Future-Task中的 done方法。当提交某个任务时,该任务将首先包装为一个QueueingFuture,这是FutureTask的一个子类,然后再改写子类的 done方法,并将结果放入 BlockingQueue中,take 和 poll 方法委托给了BlockingQueue,这些方法会在得出结果之前阻塞。

线程池

           线程池,从字面含义来看,是指管理一组同构工作线程的资源池。线程池是与工作队列(Work Queue)密切相关的,其中在工作队列中保存了所有等待执行的任务。工作者线程(Worker Thread)的任务很简单:从工作队列中获取一个任务,执行任务,然后返回线程池并等待下一个任务。

     当需要创建大量的线程时:

线程生命周期的开销非常高。线程的创建与销毁并不是没有代价的。根据平台的不同,实际的开销也有所不同,但线程的创建过程都会需要时间,延迟处理的请求,并且需要JVM和操作系统提供一些辅助操作。

资源消耗。活跃的线程会消耗系统资源,尤其是内存。如果可运行的线程数量多于可用处理器的数量,那么有些线程将闲置。大量空闲的线程会占用许多内存,给垃圾回收器带来压力,而且大量线程在竞争CPU资源时还将产生其他的性能开销。如果你已经拥有足够多的线程使所有CPU保持忙碌状态,那么再创建更多的线程反而会降低性能。

稳定性。在可创建线程的数量上存在一个限制。这个限制值将随着平台的不同而不同,并且受多个因素制约,包括JVM的启动参数、Thread构造函数中请求的栈大小,以及底层操系统对线程的限制等。如果破坏了这些限制,那么很可能抛出OutOfMemoryError异常,简单的办法对应用程序可以创建的线程数量进行限制。

线程池优势

  • 通过重用现有的线程而不是创建新线程,可以在处理多个请求时分摊在线程创建和销毁过程中产生的巨大开销。
  • 当请求到达时,工作线程通常已经存在,因此不会由于等待创建线程而延迟任务的执行,从而提高了响应性。
  • 通过适当调整线程池的大小,可以创建足够多的线程以便使处理器保持忙碌状态,同时还可以防止过多线程相互竞争资源而使应用程序耗尽内存或失败。

线程池的处理流程

  1. 线程池判断核心线程池里的线程是否都在执行任务。如果不是,则创建一个新的工作线程来执行任务。如果核心线程池里的线程都在执行任务,则进入下个流程。 
  2. 线程池判断工作队列是否已经满。如果工作队列没有满,则将新提交的任务存储在这个工作队列里。如果工作队列满了,则进入下个流程。
  3. 线程池判断线程池的线程是否都处于工作状态。如果没有,则创建一个新的工作线程来执行任务。如果已经满了,则交给饱和策略来处理这个任务。


线程池的主要处理流程 

线程池的工作原理

ThreadPoolExecutor执行execute方法分下面4种情况: 

  1. 如果当前运行的线程少于corePoolSize,则创建新线程来执行任务(注意,执行这一步骤 需要获取全局锁)。 
  2. 如果运行的线程等于或多于corePoolSize,则将任务加入BlockingQueue。 
  3. 如果无法将任务加入BlockingQueue(队列已满),则创建新的线程来处理任务(注意,执行这一步骤需要获取全局锁)。
  4. 如果创建新线程将使当前运行的线程超出maximumPoolSize,任务将被拒绝,并调用 RejectedExecutionHandler.rejectedExecution()方法。 

ThreadPoolExecutor采取上述步骤的总体设计思路,是为了在执行execute()方法时,尽可能 地避免获取全局锁(那将会是一个严重的可伸缩瓶颈)。在ThreadPoolExecutor完成预热之后 (当前运行的线程数大于等于corePoolSize),几乎所有的execute()方法调用都是执行步骤2,而步骤2不需要获取全局锁。


ThreadPoolExecutor执行示意图

//execute源码
public void execute(Runnable command) {
	if (command == null)
	int c = ctl.get();
	//如果线程数小于基本线程数
	if (workerCountOf(c) < corePoolSize) {
		//则创建线程并执行当前任务
		if (addWorker(command, true))
			return;
		c = ctl.get();
	}
	 //如线程数大于等于基本线程数,则将当前任务放到工作队列中。
	if (isRunning(c) && workQueue.offer(command)) {
		int recheck = ctl.get();
		if (! isRunning(recheck) && remove(command))
			reject(command);
		else if (workerCountOf(recheck) == 0)
			addWorker(null, false);
	}
	//如果线程池不处于运行中或任务无法放入队列,并且当前线程数量小于最大允许的线程数量,
       //则创建一个线程执行任务。
	else if (!addWorker(command, false))
        //抛出RejectedExecutionException异常
		reject(command);
}

线程池的创建

线程池创建一般两种:

    第一种是:调用Executors中的静态工厂方法之一来创建一个线程池 

  • newFixedThreadPool:将创建一个固定长度的线程池,每当提交一个任务时就创建一个线程,直到达到线程池的最大数量,这时线程池的规模将不再变化(如果某个线程由于发生了未预期的Exception而结束,那么线程池会补充一个新的线程)。
  • newCachedThreadPool:将创建一个可缓存的线程池,如果线程池的当前规模超过了处理需求时,那么将回收空闲的线程,而当需求增加时,则可以添加新的线程,线程池的规模不存在任何限制。 
  • newSingleThreadExecutor:是一个单线程的Executor,它创建单个工作者线程来执行任务,如果这个线程异常结束,会创建另一个线程来替代。newSingleThreadExecutor能确保依照任务在队列中的顺序来串行执行(例如FIFO、LIFO、优先级)。
  • newScheduledThreadPool:创建了一个固定长度的线程池,而且以延迟或定时的方式来执行任务


但是《阿里巴巴Java开发手册》建议不采用Executors创建线程池,也是让写的同学更加明确线程池的运行规则,规避资源耗尽的风险,其实Executors类创建线程池的时候实际就是调用ThreadPoolExecutor类的构造方法来创建。

第二种:通过ThreadPoolExecutor类来创建

new  ThreadPoolExecutor(int corePoolSize,
        int maximumPoolSize,
        long keepAliveTime,
        TimeUnit unit,
        BlockingQueue<Runnable> workQueue,
        ThreadFactory threadFactory,
        RejectedExecutionHandler handler) 

corePoolSize(线程池的基本大小):当提交一个任务到线程池时,线程池会创建一个线程来执行任务,即使其他空闲的基本线程能够执行新任务也会创建线程,等到需要执行的任务数大于线程池基本大小时就不再创建。如果调用了线程池的prestartAllCoreThreads()方法, 线程池会提前创建并启动所有基本线程。

maximumPoolSize(线程池最大数量):线程池允许创建的最大线程数。如果队列满了,并 且已创建的线程数小于最大线程数,则线程池会再创建新的线程执行任务。值得注意的是,如 果使用了无界的任务队列这个参数就没什么效果。

keepAliveTime(线程活动保持时间):线程池的工作线程空闲后,保持存活的时间。所以, 如果任务很多,并且每个任务执行的时间比较短,可以调大时间,提高线程的利用率。

TimeUnit(线程活动保持时间的单位):可选的单位有天(DAYS)、小时(HOURS)等等。

workQueue(任务队列):用于保存等待执行的任务的阻塞队列。可以选择以下几个阻塞队列。 

  • ArrayBlockingQueue:是一个基于数组结构的有界阻塞队列,此队列按FIFO(先进先出)原 则对元素进行排序。 
  • LinkedBlockingQueue:一个基于链表结构的阻塞队列,此队列按FIFO排序元素,吞吐量通 常要高于ArrayBlockingQueue。静态工厂方法Executors.newFixedThreadPool()使用了这个队列。 
  • SynchronousQueue:一个不存储元素的阻塞队列。每个插入操作必须等到另一个线程调用 移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于Linked-BlockingQueue,静态工 厂方法Executors.newCachedThreadPool使用了这个队列。
  •  PriorityBlockingQueue:一个具有优先级的无限阻塞队列。

ThreadFactory:用于设置创建线程的工厂,可以通过线程工厂给每个创建出来的线程设 置更有意义的名字。

RejectedExecutionHandler(饱和策略):当队列和线程池都满了,说明线程池处于饱和状 态,那么必须采取一种策略处理提交的新任务。这个策略默认情况下是AbortPolicy,表示无法 处理新任务时抛出异常。可以实现RejectedExecutionHandler接口自定义策略

  • AbortPolicy:默认的饱和策略,该策略将抛出未检查的RejectedExecution-Exception。调用者可以捕获这个异常,然后根据需求编写自己的处理代码。
  • CallerRunsPolicy:实现了一种调节机制,该策略既不会抛弃任务,也不会抛出异常。它不会在线程池的某个线程中执行新提交的任务,而是在一个调用了execute的主线程中执行该任务。
  • DiscardOldestPolicy:会抛弃下一个将被执行的任务,然后尝试重新提交新的任务。(如果工作队列是一个优先队列,那么“抛弃最旧的”策略将导致抛弃优先级最高的任务,因此最好不要将“抛弃最旧的”饱和策略和优先级队列放在一起使用。
  • DiscardPolicy:不处理,丢弃掉。

设置线程池大小

       那最大线程设置成多少才算是合适呢?

   我们先来了解下利特尔法则(Little’s Law):在一个稳定的系统(L)中,长期的平均顾客人数,等于长期的有效抵达率(λ),乘以顾客在这个系统中平均的等待时间(W);或者,我们可以用一个代数式来表达:L = λW

   根据上面的法则,我们很容易得出

队列中平均任务数 = 平均任务抵达率 * 平均任务处理时间

   如果每秒 10000 个请求到达,平均处理一个请求需要 1 秒,那么服务器在每个时刻都有 10000 个请求在处理 。因此可以总结出一个公式:

线程池大小 = 每秒请求数 × 平均请求处理时间  

   这是理想的情况。实际上任务在执行中,线程不可避免会发生阻塞,比如阻塞在 I/O 等待上,等待数据库或者下游服务的数据返回 ,所以我们还需要考虑 CPU 时间

平均请求处理时间   =  线程 I/O 阻塞时间 + 实际 CPU 时间 ​线程池大小 = ((线程 I/O 阻塞时间 + 实际 CPU 时间 )/ 实际 CPU 时间 ) * CPU核数

      如果一个4核处理器的服务器平均处理一个请求需要 1 秒,线程阻塞时间 0.2 秒 ,时间线程时间 0.8 秒 ,线程池大小 = (0.8+0.2) / 0.8 * 4 = 5 ,

      线程阻塞时间 0.5 秒 ,时间线程时间 0.5 秒 ,线程池大小 = (0.5+0.5) / 0.5 * 4 = 8 ,

     要想合理地配置线程池,就必须首先分析任务特性,可以从以下几个角度来分析:

  • CPU密集型任务:CPU密集型任务应配置尽可能小的线程,如配置N cpu +1个线程的线程池;

  • IO密集型任务: IO密集型任务线程并不是一直在执行任务,则应配置尽可能多的线程,如2*N cpu;

  • 混合型的任务:如果可以拆分,将其拆分成一个CPU密集型任务 和一个IO密集型任务,只要这两个任务执行的时间相差不是太大,那么分解后执行的吞吐量将高于串行执行的吞吐量。如果这两个任务执行时间相差太大,则没必要进行分解。

    可以通过 Runtime.getRuntime().availableProcessors()方法获得当前设备的CPU个数。

      线程池的理想大小取决于被提交任务的类型以及所部署系统的特性。在代码中通常不会固定线程池的大小, 如果线程池过大,那么大量的线程将在相对很少的CPU和内存资源上发生竞争,这不仅会导致更高的内存使用量,而且还可能耗尽资源。如果线程池过小,那么将导致许多空闲的处理器无法执行工作,从而降低吞吐率。

       当任务需要某种通过资源池来管理的资源时,例如数据库连接,那么线程池和资源池的大小将会相互影响。如果每个任务都需要一个数据库连接,那么连接池的大小就限制了线程池的大小。同样,当线程池中的任务是数据库连接的唯一使用者时,那么线程池的大小又将限制连接池的大小。

扩展ThreadPoolExecutor

ThreadPoolExecutor是可扩展的,它提供了几个可以在子类化中改写的方法:

  • beforeExecute(): 在给定的线程中执行给定的Runnable之前调用方法。
  • afterExecute(): 完成指定Runnable的执行后调用方法。
  • terminated(): 执行程序已终止时调用方法

这些方法可以用于扩展ThreadPoolExecutor的行为。 在执行任务的线程中将调用beforeExecute和afterExecute等方法,在这些方法中还可以添加日志、计时、监视或统计信息收集的功能。无论任务是从run中正常返回,还是抛出一个异常而返回,afterExecute都会被调用。(如果任务在完成后带有一个Error,那么就不会调用afterExecute。)如果beforeExecute抛出一个RuntimeException,那么任务将不被执行,并且afterExecute也不会被调用。 在线程池完成关闭操作时调用terminated,也就是在所有任务都已经完成并且所有工作者线程也已经关闭后。terminated可以用来释放Executor在其生命周期里分配的各种资源,此外还可以执行发送通知、记录日志或者收集finalize统计信息等操作。

关闭线程池

       可以通过调用线程池的shutdown或shutdownNow方法来关闭线程池。它们的原理是遍历线程池中的工作线程,然后逐个调用线程的interrupt方法来中断线程,所以无法响应中断的任务可能永远无法终止。但是它们存在一定的区别,shutdownNow首先将线程池的状态设置成 STOP,然后尝试停止所有的正在执行或暂停任务的线程,并返回等待执行任务的列表,而 shutdown只是将线程池的状态设置成SHUTDOWN状态,然后中断所有没有正在执行任务的线程。

       只要调用了这两个关闭方法中的任意一个,isShutdown方法就会返回true。当所有的任务都已关闭后,才表示线程池关闭成功,这时调用isTerminaed方法会返回true。至于应该调用哪一种方法来关闭线程池,应该由提交到线程池的任务特性决定,通常调用shutdown方法来关闭线程池,如果任务不一定要执行完,则可以调用shutdownNow方法。

Fork/Join框架

      分支/合并框架的目的是以递归方式将可以并行的任务拆分成更小的任务,然后将每个子任 务的结果合并起来生成整体结果。它是ExecutorService接口的一个实现,它把子任务分配给线程池(称为ForkJoinPool)中的工作线程。

        Fork就是把一个大任务切分为若干子任务并行的执行,Join就是合并这些子任务的执行结果,最后得到这个大任务的结 果。比如计算1+2+…+10000,可以分割成10个子任务,每个子任务分别对1000个数进行求和, 最终汇总这10个子任务的结果。

Fork/Join框架的设计

Fork/Join使用两个类来完成两件事情。

  • 分割任务。首先我们需要有一个fork类来把大任务分割成子任务,有可能子任务还是很大,所以还需要不停地分割,直到分割出的子任务足够小。 
  • 执行任务并合并结果。分割的子任务分别放在双端队列里,然后几个启动线程分 别从双端队列里获取任务执行。子任务执行完的结果都统一放在一个队列里,启动一个线程从队列里拿数据,然后合并这些数据。 

ForkJoinTask:使用ForkJoin框架,必须首先创建一个ForkJoin任务。它提供在任务 、中执行fork()和join()操作的机制。通常情况下,我们不需要直接继承ForkJoinTask类,只需要继 、承它的子类,Fork/Join框架提供了以下两个子类。 

  • RecursiveAction:用于没有返回结果的任务。
  • RecursiveTask:用于有返回结果的任务。

ForkJoinPool:ForkJoinTask需要通过ForkJoinPool来执行。 任务分割出的子任务会添加到当前工作线程所维护的双端队列中,进入队列的头部。当 一个工作线程的队列里暂时没有任务时,它会随机从其他工作线程的队列的尾部获取一个任务。

使用Fork/Join框架

使用Fork/Join框架,需求是:计算1+2+3+4的结果

public class CountTask extends RecursiveTask<Integer> {
    private static final int THRESHOLD = 2;// 阈值
    private int start;
    private int end;
    public CountTask(int start, int end) {
        this.start = start;
        this.end = end;
    }
    @Override
    protected Integer compute() {
        int sum = 0;
      // 如果任务足够小就计算任务
        boolean canCompute = (end - start) <= THRESHOLD;
        if (canCompute) {
            for (int i = start; i <= end; i++) {
                sum += i;
            }
        } else {
          // 如果任务大于阈值,就分裂成两个子任务计算
            int middle = (start + end) / 2;
            CountTask leftTask = new CountTask(start, middle);
            CountTask rightTask = new CountTask(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
        CountTask task = new CountTask(1, 4);
        // 执行一个任务
        Future<Integer> result = forkJoinPool.submit(task);
        try {
            System.out.println(result.get());
        } catch (InterruptedException e) {
        } catch (ExecutionException e) {
        }
    }
}

ForkJoinTask与一般任务的主要区别在于它 需要实现compute方法,在这个方法里,首先需要判断任务是否足够小,如果足够小就直接执 行任务。如果不足够小,就必须分割成两个子任务,每个子任务在调用fork方法时,又会进入 compute方法,看看当前子任务是否需要继续分割成子任务,如果不需要继续分割,则执行当 前子任务并返回结果。使用join方法会等待子任务执行完并得到其结果。

Fork/Join框架的异常处理

         ForkJoinTask在执行的时候可能会抛出异常,但是我们没办法在主线程里直接捕获异常, 所以ForkJoinTask提供了isCompletedAbnormally()方法来检查任务是否已经抛出异常或已经被 取消了,并且可以通过ForkJoinTask的getException方法获取异常。

if(task.isCompletedAbnormally()){
    System.out.println(task.getException());
}

getException方法返回Throwable对象,如果任务被取消了则返回CancellationException。如 果任务没有完成或者没有抛出异常则返回null。

对于并发执行的任务,Executor框架是一种强大且灵活的框架。它提供了大量可调节的选项,例如创建线程和关闭线程的策略,处理队列任务的策略,处理过多任务的策略,并且提供了自定义方法来扩展它的行为。然而,与大多数功能强大的框架一样,其中有些设置参数并不能很好地工作,某些类型的任务需要特定的执行策略,而一些参数组合则可能产生奇怪的结果。

参考

Java并发编程实战
Java并发编程艺术
阿里巴巴Java开发手册
JDK8API