ForkJoinPool与ThreadPoolExecutor的对比与选择

·  阅读 3840

除了通用的ThreadPoolExecutor之外,Java还提供了一个有特殊用途的线程池,即ForkJoinPool。这个类跟ThreadPoolExecutor类大体相似,实现了Executor和ExecutorService接口。当使用这些接口的时候,ForkJoinPool会使用一个无界队列来存储任务,这些任务由线程池构造函数中指定的线程数执行。如果没有设置线程数的话,则默认使用当前机器可用CPU数或者Docker容器中配置的CPU数大小的线程数量。

ForkJoinPool往往用于实现分治算法,将一个任务分解成多个具备可加性的子任务,然后可以并行执行这些子任务最后将子任务的运算结果进行一次聚合运算得到最终结果,比如快速排序算法。

对于分治算法的使用,有一点需要注意,就是它往往会创建大量任务,但是你不太可能创建跟任务数量相当的线程来执行它们。举个例子,要对一个1000万个元素的数组进行排序,那么分解子任务的流程是这样子:对数组对半拆分后进行排序,再对两个子数组进行一次合并,这个过程可以递归化,直到子数组长度为奇数或者说长度已经很小的时候。

假设分解直到子数组长度<=47的时候,那么现在有262144个用于对子数组进行排序的任务,有131072个用于合并这些子数组的任务,合并后的子任务还需要额外的65536个任务进行再一次合并,以此类推,最终会产生524287个任务。

不难发现,直到子任务完成之前,其父任务是无法执行的,如果我们用ThreadPoolExecutor来实现该算法,性能会相当差。但是ForkJoinPool中的线程,则不需要在子任务完成之前保持等待,当任务被暂停的时候,它可以去执行其他待处理的任务。

举个简单的例子:现在有一个double类型的数组,需要计算数组中小于0.5的元素的数量,我们使用分治策略来完成这个任务。

public class TestForkJoinPool {
    private static double[] d;
    private class ForkJoinTask extends RecursiveTask<Integer> {
        private int first;
        private int last;
        public ForkJoinTask(int first, int last) {
            this.first = first;
            this.last = last;
        }
        @Override
        protected Integer compute() {
            int subCount = 0;
            if (last - first < 10) {
                for (int i = first; i <= last; i++) {
                    if (d[i] < 0.5) {
                        subCount++;
                    }
                }
                return subCount;
            } else {
                int mid = (first + last) >>> 1;
                ForkJoinTask left = new ForkJoinTask(first, mid);
                left.fork();
                ForkJoinTask right = new ForkJoinTask(mid + 1, last); 
               right.fork(); 
               subCount = left.join(); 
               subCount += right.join();
            }
            return subCount;
        }
    }
复制代码

这里的fork()和join()方法是关键,使用ThreadPoolExecutor的话是无法实现这种递归的。这两个方法使用一系列内部的,每线程队列来执行任务,以及实现线程所执行的任务的切换。这些细节对开发者来说是透明的。那么,实际应用中ForkJoinPool和ThreadPoolExecutor类要如何做选择呢?

首先,fork/join方法具备暂停执行中的任务的作用,这使得所有的任务只需要几个线程就能运行。如果以上代码中传入一个200万元素的数组,会产生多达400万个任务,但是要运行它们,却只需要几个线程甚至是一个。如果使用ThreadPoolExecutor运行类似任务,则需要400万个线程,因为每个线程都必须等待其子任务完成,而这些子任务只有在线程池中有额外线程可用的时候才可以完成。所以fork/join的暂停可以让我们使用原本不能使用的算法,这是性能上的一大优势。

当然,示例中的使用场景在实际生产中并不多见,实际上更多应用于以下场景:

  • 合并结果集(非示例中的简单累加)。
  • 算法设计可以很好的限制任务数量时。

在其他情况下,将数组分割成多个然后用ThreadPoolExecutor开多个线程遍历子数组会更简单,例如使用一个核心线程数和最大线程数均为4,以LinkedBlockingQueue为任务队列的线程池,将数组均分成4个,使用4个线程对这4个子数组进行遍历,这样子也不至于创建过多的任务,性能也会更可观。下面是一个测试对比:

线程数量ForkJoinPoolThreadPoolExecutor
1285±15ms         5ms
486±20ms         1ms

造成如此大差距的主要原因是分治算法生成了大量的任务对象,管理这些任务对象的开销阻碍了ForkJoinPool的性能,在GC上也有影响。因此如果有其他可替代方案的话,应当避免这种情况。

工作窃取

如上所述,使用ForkJoinPool的第一个原则是确保任务拆分的合理性。它除了暂停任务之外还有另一个更强大的特性,就是它实现了工作窃取。它的池中的每一个线程,都有着自己的专属任务队列,线程会优先处理自己队列中的任务,如果队列是空的,就会去其他线程的队列中寻找任务。因此,即便400万个任务当中,某个任务执行时间很长,ForkJoinPool中的其他线程也可以完成其余的任务。而ThreadPoolExecutor就做不到这点了,如果这种情况发生在它身上,其他线程无法接手额外的任务。

接下来改造下原先的例子,使得数组中的元素值会根据其下标发生变化。

for (int i = first; i <= last; i++) {
    if (d[i] < 0.5) {
        subCount++;
    }
    for (int j = 0; j < i; j++) {
        d[i] += j;
    }
}
复制代码

由于外循环是基于元素在数组中的位置的,所以计算的时长会与元素位置成正比,比如计算d[0]的值会非常快,但是计算d[d.length-1]就需要更多的时间。

在这个场景下,如果使用ThreadPoolExecutor,并将数组均分为4份去计算的话,计算第四个子数组(假设顺序切分)的时长要远超过计算第一个子数组的时长。一旦计算第一个子数组的线程完成任务后,它将会进入空闲状态。

而改用ForkJoinPool实现的话,虽然有一个线程会卡在第四个数组的计算上,但是其他的线程依然可以保持工作状态,而不会无所事事。以下是测试对比的结果:

线程数量ForkJoinPoolThreadPoolExecutor
131±3s30±3s
46±1s10±2s

只用一个线程的话,两者的结果是基本一致的。当线程数量达到4个的时候,ForkJoinPool就占据了一定的优势。当一系列任务中有些任务耗时会比其他任务更长的时候,会导致不平衡的情况,由此可以得出结论:当任务能被分割成一个执行效率平衡的集合时,分割并使用ThreadPoolExecutor会得到更好的性能,反之则是ForkJoinPool更适合

这里其实还可以做更进一步的性能调优,但是这就偏向于算法层面了:就是想清楚何时结束递归。在以上的例子中,是在数组大小小于10的时候结束递归。但是在执行效率平衡的情况下,在500000的时候结束是更合适的。

然而,在不平衡的情况下,更小的子数组则会获得更好的性能,还是沿用上述的数组中的元素值会根据其下标发生变化的例子,下面是测试结果(为了节约时间减少到20万个元素):

子数组大小ForkJoinPool
10000017988±100ms
5000010613±100ms
100004964±100ms
10003940±100ms
1003735±100ms
103687±100ms

这种对叶值的调整在这类算法中是很常见的。Java的快速排序实现,叶值为47。

自动并行

Java具备自动并行化某些类型代码的能力,而这个能力依赖于ForkJoinPool。JVM会为此创建一个通用的fork-join线程池,它是ForkJoinPool类的一个静态对象,大小默认为机器上的可用处理器数量。

在Arrays类的方法当中,这种自动并行很多见,比如使用快速排序算法对数组进行排序,对数组中的每个元素进行操作的方法等。在流处理当中也有用到,借此可以对集合中的每个元素进行操作(串行或者并行)。

下面是一个例子,创建一个用户对象的集合,然后计算每个用户的活跃系数:

List<User> users= ...;
Stream<User> stream = users.parallelStream();
stream.forEach(u -> {
    int val=calculate(u);
    ...
});
复制代码

foreach()方法会为每个用户对象创建一个任务,然后每个任务会交由JVM中通用的ForkJoinPool处理。

调整公共ForkJoinPool大小和调整其他线程池大小一样重要。默认情况下,公共线程池的线程数与机器的可用CPU一样多。如果一台机器上运行了多个JVM,往往就需要考虑限制线程数量,这样JVM之间就不会抢夺资源。同理,如果一台服务器要并行执行其他的请求,但是你又想确保有足够的CPU资源去做这个事情,就可以考虑减少公共线程池的线程数量。当然,如果公共池中的任务常常阻塞等待IO,就可能会需要增加公共池的大小。

要调整公共池大小的话,可以通过修改Java系统属性Djava.util.concurrent.ForkJoinPool.common.parallelism=N来实现。这个跟版本有一定的关系,Java8的192版本之前,都需要去手动设置,你可以通过以下方法

ForkJoinPool.commonPool().getParallelism()
复制代码

来查看当前公共池的大小,注意,运行过程中用这个方法调整是没用的,你必须在ForkJoinPool类被加载之前进行修改。

System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism","20");
复制代码

这里还有一点需要额外注意,foreach()方法会同时使用执行语句中的线程和公共池中的线程来处理流中的元素。因此,如果在使用并行流或者其他自动并行化方法,且需要调整公共池大小的时候,可以把期望值减少1。

                                                                   参考资料:《OReilly.Java Performance》

分类:
后端
标签:
分类:
后端
标签:
收藏成功!
已添加到「」, 点击更改