语言、库和框架形成了我们编写程序的方式。Alonzo Church 早在 1934 年就曾表明,所有已知的计算性框架对于它们所能表示的程序集都是等价的,程序员实际编写的程序集是由特定语言形成的,而编程模型(由语言、库和框架驱动)可以简化这些语言的表达。
另一方面,一个时代的主流硬件平台形成了我们创建语言、库和框架的方法。Java 语言从一开始就能够支持线程和并发性;该语言包括像 synchronized
和volatile
这样的同步原语,而类库包含像Thread
这样的类。然而,1995 年流行的并发原语反映了当时的硬件现状:大多数商用系统根本没有提供并行性,甚至最昂贵的系统也只提供了有限的并行性。当时,线程主要用来表示异步,而不是并发,而这些机制已足够满足当时的需求了。
随着多处理器系统价格降低,更多的应用程序需要使用这些系统提供的硬件并行性。而且程序员们发现,使用 Java 语言提供的低级原语和类库编写并发程序非常困难且容易出错。在 Java 5 中,java.util.concurrent
包被添加到 Java 平台,它提供了一组可用于构建并发应用程序的组件:并发集合、队列、信号量、锁存器(latch)、线程池等等。这些机制非常适合用于粗任务粒度的程序;应用程序只需对工作进行划分,使并发任务的数量不会持续少于可用的处理器数量。通过将对单个请求的处理用作 Web 服务器、邮件服务器或数据库服务器的工作单元,应用程序通常能满足这种需求,因此这些机制能够确保充分利用并行硬件。
技术继续发展,硬件的趋势非常清晰;Moore 定律表明不会出现更高的时钟频率,但是每个芯片上会集成更多的内核。很容易想象让十几个处理器繁忙地处理一个粗粒度的任务范围,比如一个用户请求,但是这项技术不会扩大到数千个处理器 — 在很短一段时间内流量可能会呈指数级增长,但最终硬件趋势将会占上风。当跨入多内核时代时,我们需要找到更细粒度的并行性,否则将面临处理器处于空闲的风险,即使还有许多工作需要处理。如果希望跟上技术发展的脚步,软件平台也必须配合主流硬件平台的转变。最终,Java 7 将会包含一种框架,用于表示某种更细粒度并行算法的类:fork-join 框架。
如今,大多数服务器应用程序将用户请求-响应处理作为一个工作单元。服务器应用程序通常会运行比可用的处理器数量多很多的并发线程或请求。这是因为在大多数服务器应用程序中,对请求的处理包含大量 I/O,这些 I/O 不会占用太多的处理器(所有网络服务器应用程序都会处理许多的套接字 I/O,因为请求是通过套接字接收的;也会处理大量磁盘(或数据库)I/O)。如果每个任务的 90% 的时间用来等待 I/O 完成,您将需要 10 倍于处理器数量的并发任务,才能充分利用所有的处理器。随着处理器数量增加,可能没有足够的并发请求保持所有处理器处于繁忙状态。但是,仍有可能使用并行性来改进另一种性能度量:用户等待获取响应的时间。
一个典型网络服务器应用程序的例子是,考虑一个数据库服务器。当一个请求到达数据库服务器时,需要经过一连串的处理步骤。首先,解析和验证 SQL 语句。然后必须选择一个查询计划;对于复杂查询,数据库服务器将会评估许多不同的候选计划,以最小化预期的 I/O 操作数量。搜索查询计划是一种 CPU 密集型任务;在某种情况下,考虑过多的候选计划将会产生负面影响,但是如果候选计划太少,所需的 I/O 操作肯定比实际数量要多。从磁盘检索到数据之后,也许需要对结果数据集进行更多的处理;查询可能包含聚合操作,比如 SUM、AVERAGE,或者需要对数据集进行排序。然后必须对结果进行编码并返回到请求程序。
就像大多数服务器请求一样,处理 SQL 查询涉及到计算和 I/O。虽然添加额外的 CUP 不会减少完成 I/O 的时间(但是可以使用额外的内存,通过缓存以前的 I/O 操作结果来减少 I/O 数量),但是可以通过并行化来缩短请求处理的 CPU 密集型部分(比如计划评估和排序)的处理时间。在评估候选的查询计划时,可以并行评估不同的计划;在排序数据集时,可以将大数据集分解成更小的数据集,分别进行排序然后再合并。这样做会使用户觉得性能得到了提升,因为会更快收到结果(即使总体上可能需要更多工作来服务请求)。
合并排序是 divide-and-conquer 算法的一个例子,在这种算法中将一个问题递归分解成子问题,再将子问题的解决方案组合得到最终结果。 divide-and-conquer 算法也可用于顺序环境中,但是在并行环境中更加有效,因为可以并行处理子问题。
典型的并行 divide-and-conquer 算法的形式如清单 1 所示:
// PSEUDOCODE Result solve(Problem problem) { if (problem.size < SEQUENTIAL_THRESHOLD) return solveSequentially(problem); else { Result left, right; INVOKE-IN-PARALLEL { left = solve(extractLeftHalf(problem)); right = solve(extractRightHalf(problem)); } return combine(left, right); } }
并行 divide-and-conquer 算法首先对问题进行评估,确定其大小是否更适合使用顺序解决方案;通常,可通过将问题大小与某个阙值进行比较完成。如果问题大到需要并行分解,算法会将其分解成两个或更多子问题,并以并行方式对子问题递归调用算法本身,然后等待子问题的结果,最后合并这些结果。用于选择顺序和并行执行方法的理想阙值是协调并行任务的成本。如果协调成本为 0,更多的更细粒度的任务会提供更好的并行性;在需要转向顺序方法之前,协调成本越低,就可以划分更细粒度的任务。
清单 1 中的示例使用了实际上并不存在的 INVOKE-IN-PARALLEL 操作;它的行为表现为当前任务是暂停的,并行执行两个子任务,而当前任务等待两个子任务的完成。然后就可以将两个子任务的结果进行合并。这种并行分解方法常常称作fork-join,因为执行一个任务将首先分解(fork)为多个子任务,然后再合并(join)(完成后)。
清单 2 显示了一个适合使用 fork-join 解决方案的问题示例:在大型数组中搜索其中的最大元素。虽然这个示例非常简单,但是 fork-join 技术可用于各种各样的搜索、排序和数据分析问题。
public class SelectMaxProblem { private final int[] numbers; private final int start; private final int end; public final int size; // constructors elided public int solveSequentially() { int max = Integer.MIN_VALUE; for (int i=start; i<end; i++) { int n = numbers[i]; if (n > max) max = n; } return max; } public SelectMaxProblem subproblem(int subStart, int subEnd) { return new SelectMaxProblem(numbers, start + subStart, start + subEnd); } }
注意 subproblem()
方法没有复制这些元素;它只是将数组引用和偏移复制到一个现有的数据结构中。这在 fork-join 问题实现中很常见,因为递归分解问题的过程将会创建大量的新Problem
对象。 在这个例子中,搜索任务没有修改被搜索的数据结构,因此不需要维护每个任务的底层数据集的私有副本,也不会因复制而增加额外的开销。
清单 3 演示了使用 fork-join 包的 SelectMaxProblem
解决方案,Java 7 中已计划包含该包。JSR 166 Expert Group 正在公开开发这个包,使用的代码名称为 jsr166y,您可以单独下载它并在 Java 6 或更高版本中使用(它最终将会包含在包java.util.concurrent.forkjoin
中)。invoke-in-parallel
操作是用coInvoke()
方法来实现的,该操作同时调用多个动作并等待所有动作完成。ForkJoinExecutor
跟Executor
类似,因为它也是用来运行任务的,但它是专门针对计算密集型任务而设计的。这种任务不会被阻塞,除非它在等待由相同ForkJoinExecutor
处理的另一个任务。
fork-join 框架支持几种风格的 ForkJoinTasks
,包括那些需要显式完成的,以及需要循环执行的。这里使用的 RecursiveAction
类直接支持 non-result-bearing 任务的并行递归分解风格;RecursiveTask
类解决 result-bearing 任务的相同问题(其他 fork-join 任务类包括CyclicAction
、AsyncAction
和LinkedAsyncAction
;要获得关于如何使用它们的更多细节,请查阅 Javadoc)。
public class MaxWithFJ extends RecursiveAction { private final int threshold; private final SelectMaxProblem problem; public int result; public MaxWithFJ(SelectMaxProblem problem, int threshold) { this.problem = problem; this.threshold = threshold; } protected void compute() { if (problem.size < threshold) result = problem.solveSequentially(); else { int midpoint = problem.size / 2; MaxWithFJ left = new MaxWithFJ(problem.subproblem(0, midpoint), threshold); MaxWithFJ right = new MaxWithFJ(problem.subproblem(midpoint + 1, problem.size), threshold); coInvoke(left, right); result = Math.max(left.result, right.result); } } public static void main(String[] args) { SelectMaxProblem problem = ... int threshold = ... int nThreads = ... MaxWithFJ mfj = new MaxWithFJ(problem, threshold); ForkJoinExecutor fjPool = new ForkJoinPool(nThreads); fjPool.invoke(mfj); int result = mfj.result; } }
表 1 显示了在不同系统上从 500,000 个元素的数组中选择最大元素的结果,以及改变阙值,使顺序方法优于并行方法。对于大多数运行,fork-join 池中的线程数量与可用的硬件线程(内核数乘以每个内核中的线程数)相等。与顺序方法相比,这些线程数量呈现一种加速比(speedup)。
阙值 = 500k | 阙值 = 50k | 阙值 = 5k | 阙值 = 500 | 阙值 = -50 | |
---|---|---|---|---|---|
Pentium-4 HT(2 个线程) | 1.0 | 1.07 | 1.02 | .82 | .2 |
Dual-Xeon HT(4 个线程) | .88 | 3.02 | 3.2 | 2.22 | .43 |
8-way Opteron(8 个线程) | 1.0 | 5.29 | 5.73 | 4.53 | 2.03 |
8-core Niagara(32 个线程) | .98 | 10.46 | 17.21 | 15.34 | 6.49 |
结果很令人振奋,因为它们在选择各种参数时显示了不错的加速比。因此,只要避免为问题和底层硬件选择完全不合理的参数,就会获得不错的结果。使用 chip-multithreading 技术,最优的加速比不太明显;像 Hyperthreading 这样的 CMT 方法所提供的性能要低于等价数量的实际内核提供的性能,但是性能损失取决于许多因素,包括正在执行的代码的缓存丢失率(miss rate)。
此处选择的顺序阙值范围从 500K(数组的大小,表示没有并行性)到 50。在这个例子中,阙值为 50 实在太小,有些不切实际,而且结果显示,顺序阙值太低时,fork-join 任务管理开销将起决定作用。但是表 1 也显示只要避免这种不切实际的过高和过低的参数,就会获得不错的结果。选择Runtime.availableProcessors()
作为工作线程的数量通常会获得与理想结果相近的结果,因为在 fork-join 池中执行的任务都是和 CPU 绑定的,但是,只要避免设置过大或过小的池,这个参数对结果就不会有太大影响。
MaxWithFJ
类中不需要显式同步。它操作的数据对于问题的生命周期来说是不变的,并且 ForkJoinExecutor
中有足够的内部同步可以保证问题数据对于子任务的可视性,也可以保证子任务结果对于跟它们结合的任务的可视性。
可以有很多方法实现 清单 3 中演示的 fork-join 框架。可以使用原始的线程;Thread.start()
和 Thread.join()
提供了所有必要的功能。然而,这种方法需要的线程数可能比 VM 所能支持的数量更多。对于大小为N(假设为一个很小的顺序阙值)的问题,将需要O(N) 个线程来解决问题(问题树深度为 log2N,深度为k 的二进制树有 2k 个节点)。在这些线程中,半数线程会用几乎整个生命周期来等待子任务的完成。创建线程会占用许多内存,这使得这种方法受到限制(尽管这种方法也能工作,但是代码非常复杂,并且需要仔细针对问题大小和硬件进行参数调优)。
使用传统的线程池来实现 fork-join 也具有挑战性,因为 fork-join 任务将线程生命周期的大部分时间花费在等待其他任务上。这种行为会造成线程饥饿死锁(thread starvation deadlock),除非小心选择参数以限制创建的任务数量,或者池本身非常大。传统的线程池是为相互独立的任务设计的,而且设计中也考虑了潜在的阻塞、粗粒度任务 — fork-join 解决方案不会产生这两种情况。对于传统线程池的细粒度任务,也存在所有工作线程共享的任务队列发生争用的情况。
fork-join 框架通过一种称作工作窃取(work stealing) 的技术减少了工作队列的争用情况。每个工作线程都有自己的工作队列,这是使用双端队列(或者叫做deque)来实现的(Java 6 在类库中添加了几种 deque 实现,包括ArrayDeque
和 LinkedBlockingDeque
)。当一个任务划分一个新线程时,它将自己推到 deque 的头部。当一个任务执行与另一个未完成任务的合并操作时,它会将另一个任务推到队列头部并执行,而不会休眠以等待另一任务完成(像Thread.join()
的操作一样)。当线程的任务队列为空,它将尝试从另一个线程的 deque 的尾部 窃取另一个任务。
可以使用标准队列实现工作窃取,但是与标准队列相比,deque 具有两方面的优势:减少争用和窃取。因为只有工作线程会访问自身的 deque 的头部,deque 头部永远不会发生争用;因为只有当一个线程空闲时才会访问 deque 的尾部,所以也很少存在线程的 deque 尾部的争用(在 fork-join 框架中结合 deque 实现会使这些访问模式进一步减少协调成本)。跟传统的基于线程池的方法相比,减少争用会大大降低同步成本。此外,这种方法暗含的后进先出(last-in-first-out,LIFO)任务排队机制意味着最大的任务排在队列的尾部,当另一个线程需要窃取任务时,它将得到一个能够分解成多个小任务的任务,从而避免了在未来窃取任务。因此,工作窃取实现了合理的负载平衡,无需进行协调并且将同步成本降到了最小。
fork-join 方法提供了一种表示可并行化算法的简单方式,而不用提前了解目标系统将提供多大程度的并行性。所有的排序、搜索和数字算法都可以进行并行分解(以后,像Arrays.sort()
这样的标准库机制将会使用 fork-join 框架,允许应用程序免费享有并行分解的益处)。随着处理器数量的增长,我们将需要在程序内部使用更多的并行性,以有效利用这些处理器;对计算密集型操作(比如排序)进行并行分解,使程序能够更容易利用未来的硬件。
在 上一期 Java 理论与实践 中,我们研究了 fork-join 库,这个库将添加到 Java 7 的 java.util.concurrent
包中。fork-join 技术提供了一种表示 divide-and-conquer 并行算法的简单方式,这种方式能够在大量硬件上有效执行,无需修改代码。
随着处理器数量的增加,为了有效利用可用的硬件,我们需要识别并利用程序中更细粒度的并行性。最近几年中,选择粗粒度的任务边界(例如在 Web 应用程序中处理单一请求)和在线程池中执行任务,通常能够提供足够的并行性,实现可接受的硬件利用效率。但是如果要再进一步,就必须深入挖掘更多的并行性,以让硬件全速运转。一个成熟的并行领域就是大数据集中的排序和搜索。用fork-join 可以很容易地表示这类问题,正如您在上一期 中看到的那样。但是由于这些问题非常普遍,所以该类库提供了一种更简单的方法 212;ParallelArra
fork-join 将 divide-and-conquer 技术具体化;它接受一个问题并将其递归分解为子问题,直到子问题小到用顺序方法解决更有效。递归步骤包括将一个问题分解成两个或更多子问题,将子问题排队求解(分叉(fork)步骤),等待子问题的结果(合并(join) 步骤),然后合并结果。这类算法的一个示例是使用fork-join 库进行合并排序,如清单 1 所示:
public class MergeSort extends RecursiveAction { final int[] numbers; final int startPos, endPos; final int[] result; private void merge(MergeSort left, MergeSort right) { int i=0, leftPos=0, rightPos=0, leftSize = left.size(), rightSize = right.size(); while (leftPos < leftSize && rightPos < rightSize) result[i++] = (left.result[leftPos] <= right.result[rightPos]) ? left.result[leftPos++] : right.result[rightPos++]; while (leftPos < leftSize) result[i++] = left.result[leftPos++]; while (rightPos < rightSize) result[i++] = right.result[rightPos++]; } public int size() { return endPos-startPos; } protected void compute() { if (size() < SEQUENTIAL_THRESHOLD) { System.arraycopy(numbers, startPos, result, 0, size()); Arrays.sort(result, 0, size()); } else { int midpoint = size() / 2; MergeSort left = new MergeSort(numbers, startPos, startPos+midpoint); MergeSort right = new MergeSort(numbers, startPos+midpoint, endPos); coInvoke(left, right); merge(left, right); } } } |
合并排序本身并非并行算法,因为它可以顺序执行。当数据集太大,内存无法容纳,必须分片保存的时候,经常使用合并排序。合并排序的最差性能和平均性能为 O(n log n)。但是由于很难在原地进行合并,所以合并排序的内存需求比能够原地进行的排序算法(例如快速排序)更高。但是因为子问题的排序能够并行完成,所以合并排序的并行性比快速排序好。
在处理器数量固定的情况下,并行化并不能将 O(n log n) 问题转变为 O(n) 问题,但是问题越适合并行化,它就越接近O(n) 问题, 从而减少总体运行时)。减少总体运行时间意味着用户能更快得到结果 212; 即使并行执行需要比顺序执行更多的 CPU 周期。
使用 fork-join 技术的主要好处是,它提供了一种编写并行执行的算法的简便方法。程序员无需知道部署中可用的 CPU 数量;运行时能够很好地协调可用 CPU 的工作量、在大范围硬件上产生合理的结果。更细粒度的并行性
在主流服务器应用程序中,最适合更细粒度并行性的地方是数据集的排序、搜索、选择和汇总。其中的每个问题都可以用 divide-and-conquer 轻松地并行化,并能轻松地表示为fork-join 任务。例如,要将对大数据集求平均值的操作并行化,可以递归地将大数据集分解成更小的数据集 212; 就像在合并排序中做的那样 212; 对子集求均值,然后在合并步骤中求出各子集的平均值的加权平均值。
ParallelArray
对于排序和搜索问题,fork-join 库提供了一种表示可以并行化的数据集操作的非常简单的途径:ParallelArray
类。其思路是:用ParallelArray
表示一组结构上类似的数据项,用ParallelArray
上的方法创建一个对分解数据的具体方法的描述。然后用该描述并行地执行数组操作(幕后使用的是fork-join 框架)。这种方法支持声明性地指定数据选择、转换和后处理操作,允许框架计算出合理的并行执行计划,就像数据库系统允许用 SQL 指定数据操作并隐藏操作的实现机制一样。ParallelArray
的一些实现可用于不同的数据类型和大小,包括对象数组和各种原语组成的数组。
清单2 显示的示例使用ParallelArray
对学生成绩进行汇总,演示了选择、处理和汇总的基本操作。 Student
类包含学生的信息(姓名、毕业年份、GPA)。helper 对象isSenior
用来选择今年毕业的学生,healper 对象getGpa
提取指定学生的 GPA 字段。清单开始部分的表达式创建一个ParallelArray
,代表一组学生,然后从今年毕业的学生中选出最高的 GPA。
ParallelArray
选择、处理和汇总数据
ParallelArray<Student> students = new ParallelArray<Student>(fjPool, data); double bestGpa = students.withFilter(isSenior) .withMapping(selectGpa) .max(); public class Student { String name; int graduationYear; double gpa; } static final Ops.Predicate<Student> isSenior = new Ops.Predicate<Student>() { public boolean op(Student s) { return s.graduationYear == Student.THIS_YEAR; } }; static final Ops.ObjectToDouble<Student> selectGpa = new Ops.ObjectToDouble<Student>() { public double op(Student student) { return student.gpa; } }; |
表示并行数组上的操作的代码有点欺骗性。withFilter()
和 withMapping()
方法实际上并不搜索或转换数据;它们只是设置 “查询” 的参数。实际的工作在最后一步进行,在本例中就是对max()
的调用。
ParallelArray
支持以下基本操作:
withFilter()
方法指定。apply()
方法允许对每个选中元素执行一个操作。withMapping()
方法执行,我们将Student
转换为学生的 GPA。其结果是指定选择和映射结果的ParallelArray
。ParallelArray
,可以在其上执行进一步查询。替换的一种情况是排序,将元素替换为不同的元素,从而对其进行排序(内置的sort()
方法可用于此操作)。另一种特殊情况是cumulate()
方法,该方法根据指定的组合操作用累积值替换每个元素。替换操作也可用于组合多个ParallelArray
,例如创建一个ParallelArray
,其元素为对并行数组 a
和b
执行 a[i]+b[i]
操作得到的值。max()
方法。预定义的汇总方法,例如 min()
、sum()
和max()
,是用更通用的reduce()
构建的。常见汇总操作
在 清单 2 中,我们能够计算出任意个学生中的最高 GPA,但是我们想要的结果稍微有点不同 212; 哪个学生的 GPA 最高。这个任务可以通过两次计算完成(一次计算最高 GPA,另一次选择拥有这个 GPA 的学生),但是 ParallelArray
提供了更加简便的方法来实现常用的汇总统计,例如最大值、最小值、总和、平均值,以及最大和最小元素的索引。summary()
方法通过一个并行操作计算这些汇总统计信息。
清单 3 演示了计算汇总信息的 summary()
方法,包括求出最小和最大元素的索引,从而避免反复传递数据:
SummaryStatistics summary = students.withFilter(isSenior) .withMapping(selectGpa) .summary(); System.out.println("Worst student: " + students.get(summary.minIndex()).name; System.out.println("Average GPA: " + summary.getAverage(); |
局限性
ParallelArray
并不是一种通用的内存中数据库,也不是一种指定数据转换和提取的通用机制(例如 .NET 3.0 中的特性 212; 语言集成查询 LinQ);它只是用于简化特定范围的数据选择和转换操作的表达方式,以将这些操作轻松、自动地并行化。所以,它存在一些局限性;例如,必须在映射操作之前指定筛选操作。(允许多个筛选操作,但是将它们组合成一个复合筛选操作通常会更有效)。它的主要目的是使开发人员不用思考如何将工作并行化;如果能够用ParallelArray
提供的操作表示转换,那么就能轻松实现并行化。
性能
为了评估 ParallelArray
的效率,我编写了一个简单的程序,针对各种大小的数组和 fork-join 池运行查询。在运行 Windows 的 Core 2 Quad 系统中进行测试。表 1 显示的是相对于基准情形(1000 个学生,一个线程)的性能对比:
线程 | |||||
1 | 2 | 4 | 8 | ||
学生 | 1000 | 1.00 | 0.30 | 0.35 | 1.20 |
10000 | 2.11 | 2.31 | 1.02 | 1.62 | |
100000 | 9.99 | 5.28 | 3.63 | 5.53 | |
1000000 | 39.34 | 24.67 | 20.94 | 35.11 | |
10000000 | 340.25 | 180.28 | 160.21 | 190.41 |
虽然结果相当混乱(受到多个因素的影响,包括 GC 活动),但是能够看出,不仅通过等于内核数量的线程池大小能够实现最好的结果(如果任务是纯计算型的,这个结果可想而知),而且 4 个内核的性能是 1 个内核的性能的2-3 倍速,这表明不用进行调节,使用高级、便捷的机制也有可能得到不错的并行性。
连接闭包
连接闭包
ParallelArray
提供了一种不错的方法,可用于声明性地指定数据集上的筛选、处理和聚合操作,还方便自动并行化。但是,尽管它的语法比使用原始的 for-join 库更容易表达,但还是有些麻烦;每个筛选器、映射器、reducer 通常被指定为内部类,所以即使像 “查找今年毕业的学生中最高的 GPA” 这样简单的查询,仍然需要十几行代码。Java 7 可能会在 Java 语言中加入闭包;支持闭包的一种说法是:闭包使得小段代码 212; 例如 ParallelArray
中的筛选器、映射器、reducer 212;的表示更加紧凑。
清单 4 显示了使用 BGGA 闭包方案重写的求最高 GPA 的查询。(在使用函数类型扩展的ParallelArray
版本中,withFilter()
的参数类型不是Ops.Predicate<T>
,而是函数类型{ T => boolean }
)。闭包标注取消了与内部类相关的引用,允许更紧凑(重点更突出、更直接)地表示需要的数据操作。现在代码缩减为三行,几乎所有代码都表示了我们试图实现的结果的某个重要方面。
double bestGpa = students.withFilter({Student s => (s.graduationYear == THIS_YEAR) }) .withMapping({ Student s => s.gpa }) .max(); |
结束语
随着可用的处理器数量增加,我们需要发现程序中更细粒度的并行性来源。最有吸引力候选方案之一是聚合数据操作 212; 排序、搜索和汇总。JDK 7 中将引入的fork-join 库提供了一种 “轻松表示” 某类可并行化算法的途径,从而让程序能够在一些硬件平台上有效运行。通过声明性地描述想要执行的操作,然后让ParallelArray
确定具体的执行方法,fork-join 库的ParallelArray
组件使并行聚合操作的表示变得更加简单。
参考:
Java理论与实践: 应用fork-join框架 http://www.ibm.com/developerworks/cn/java/j-jtp11137.html
Java7中的ForkJoin并发框架初探(上)——需求背景和设计原理:www.molotang.com/articles/694.html