一、Fork/Join 框架
“分而治之”一直是一个非常有效地处理大量数据的方法。著名的MapReduce也是采取了分而治之的思想。简单地说,就是如果你要处理1000个数据,但是你并不具备处理1000个数据的能力,那么你可以只处理其中的10个,然后分阶段处理100次,将100次的结果进行合成,就是最终想要的对原始1000个数据的处理结果。
Fork一词的原始含义是吃饭用的叉子,也有分叉的意思。在Linux平台中,方法fork()用来创建子进程,使得系统进程可以多一个执行分支。在Java中也沿用了类似的命名方式。
而join()
方法的含义在之前的章节中已经解释过,这里表示等待。也就是使用fork()方法后系统多了一个执行分支(线程),所以需要等待这个执行分支执行完毕,才有可能得到最终的结果,因此join()方法就表示等待。
在实际使用中,如果毫无顾忌地使用fork()
方法开启线程进行处理,那么很有可能导致系统开启过多的线程而严重影响性能。所以,在JDK中,给出了一个ForkJoinPool线程池,对于fork()
方法并不急着开启线程,而是提交给ForkJoinPool线程池进行处理,以节省系统资源。使用Fork/Join框架进行数据处理时的总体结构如图3.11所示。
由于线程池的优化,提交的任务和线程数量并不是一对一的关系。在绝大多数情况下,一个物理线程实际上是需要处理多个逻辑任务的。因此,每个线程必然需要拥有一个任务队列。因此,在实际执行过程中,可能遇到这么一种情况:线程A已经把自己的任务都执行完了,而线程B还有一堆任务等着处理,此时,线程A就会“帮助”线程B,从线程B的任务队列中拿一个任务过来处理,尽可能地达到平衡。图3.12显示了这种互相帮助的精神。一个值得注意的地方是,当一个线程试图“帮助”其他线程时,总是从任务队列的底部开始获取数据,而线程试图执行自己的任务时,则是从相反的顶部开始获取数据。因此这种行为也十分有利于避免数据竞争。
下面我们来看一下ForkJoinPool线程池的一个重要的接口:
public <T> ForkJoinTask<T> submit(ForkJoinTask<T> task)
你可以向ForkJoinPool
线程池提交一个ForkJoinTask
任务。所谓ForkJoinTask
任务就是支持fork()
方法分解及join()
方法等待的任务。ForkJoinTask
任务有两个重要的子类,RecursiveAction
类和RecursiveTask
类。它们分别表示没有返回值的任务和可以携带返回值的任务。图3.13显示了这两个类的作用和区别。
下面我们简单地展示Fork/Join框架的使用方法,这里用来计算数列求和。
import java.util.ArrayList;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinTask;
import java.util.concurrent.RecursiveTask;
//计算数列的和需要返回值,所以选择 RecursiveTask 作为任务的模型
public class CountTask extends RecursiveTask<Long> {
//设置任务分解的规模,也就是如果需要求和的总数大于THRESHOLD个,那么任务就需要再次分解,否则就可以直接执行
private static final int THRESHOLD = 10000;
private long start;
private long end;
public CountTask(long start, long end) {
this.start = start;
this.end = end;
}
@Override
protected Long compute() {
long sum = 0;
boolean canCompute = (end - start) < THRESHOLD;
//判断是否需要再次进行分解
if (canCompute) {
for (long i = start; i <= end; i++) {
sum += i;
}
} else {
//每次分解将原有任务分成100个等规模的小任务,
long step = (start + end) / 100;
ArrayList<CountTask> subTasks = new ArrayList<CountTask>();
long pos = start;
for (int i = 0; i < 100; i++) {
long lastOne = pos + step;
if (lastOne > end) {
lastOne = end;
}
CountTask subTask = new CountTask(pos, lastOne);
pos += step + 1;
subTasks.add(subTask);
//使用fork() 提交子任务
subTask.fork();
}
//等待所有的子任务结束,将结果再次求和
for (CountTask t : subTasks) {
sum += t.join();
}
}
return sum;
}
public static void main(String[] args) {
//建立 ForkJoinPool 线程池
ForkJoinPool forkJoinPool = new ForkJoinPool();
//构造一个1-200000L 的求和任务
CountTask task = new CountTask(0, 200000L);
//将任务交给线程池
ForkJoinTask<Long> result = forkJoinPool.submit(task);
try {
//通过 get 方法获取最后的结果,如果执行 get 方法时,任务没有结束,则主线程会在get 方法时等待
long res = result.get();
System.out.println("sum=" + res);
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
}
在使用Fork/Join框架时需要注意:如果任务的划分层次很多,一直得不到返回,那么可能出现两种情况。第一,系统内的线程数量越积越多,导致性能严重下降。第二,函数的调用层次变多,最终导致栈溢出。不同版本的JDK内部实现机制可能有差异,从而导致其表现不同。
此外,ForkJoin线程池使用一个无锁的栈来管理空闲线程。如果一个工作线程暂时取不到可用的任务,则可能会被挂起,挂起的线程将会被压入由线程池维护的栈中。待将来有任务可用时,再从栈中唤醒这些线程。
二、Guava 中对线程池的扩展
除JDK内置的线程池以外,Guava对线程池也进行了一定的扩展,主要体现在MoreExecutors工具类中。
1.特殊的DirectExecutor线程池
在MoreExecutors中,提供了一个简单但是非常重要的线程池实现,即DirectExecutor线程池。DirectExecutor线程池很简单,它并没有真的创建或者使用额外线程,它总是将任务在当前线程中直接执行。读者也许会觉得很奇怪,为什么需要这么一个线程池呢?这是软件设计上的需要。
从软件设计的角度上说,抽象是软件设计的根本和精髓。将不同业务的共同属性提取并抽象成模型非常有利于对不同业务的统一处理。我们总是希望并且倾向于使用通用的代码来处理不同的场景,因此,这就需要对不同场景进行统一的抽象和建模。
对于线程池来说,其技术目的是为了复用线程以提高运行效率,但其业务需求却是去异步执行一段业务指令。但是有时候,异步并不是必要的。因此,当我们剥去线程池的技术细节,仅关注其使用场景时便不难发现,任何一个可以运行Runnable实例的模块都可以被视为线程池,即便它没有真正创建线程。这样就可以将异步执行和同步执行进行统一,使用统一的编码风格来处理同步和异步调用,进而简化设计。
上述代码向线程池中执行一个Runnable接口,打印Runnable接口执行所在的线程,其输出如下:
可以看到,这个Runnable接口在主线程中执行。
注入不同的exceutor的实现,例如使用固定大小线程池替代DirectExecutor,无须修改代码便可以使程序拥有不同的行为,这也正是DirectExecutor的用意所在。
2. Daemon线程池
此外,在MoreExecutors中,还提供了将普通线程池转为Daemon线程池的方法。在很多场合,我们并不希望后台线程池阻止程序的退出,当系统执行完成后,即便有线程池存在,依然希望进程结束执行。此时,就可以使用MoreExecutors.getExitingExecutorService()
方法。
上述代码输出“I am running in pool-1-thread-1”后,立即退出程序,若不使用MoreExecutors.getExitingExecutorService()
方法对exceutor线程池进行设置,则该程序无法正常退出,除非手动关闭exceutor线程池。