并行数据的处理(了解)
文章目录
在Java 7之前,并行处理数据集合非常麻烦。
- 第一,你得明确地把包含数据的数据结构分成若干子部分。
- 第二,你要给每个子部分分配一个独立的线程。
- 第三,你需要在恰当的时候对它们进行同步来避免不希望出现的竞争条件,等待所有线程完成,最后把这些部分结果合并起来。
Java 7引入了一个叫作分支/合并的框架,让这些操作更稳定、更不易出错。
并行流
可以通过对收集源调用Stream接口parallelStream方法来把集合转换为并行流。
并行流就是一个把内容分成多个数据块,并用不同的线程分别处理每个数据块的流。
举个例子
假设你需要写一个方法,接受数字n作为参数,并返回从1到给定参数的所有数字的和。
/**
* 接受数字n作为参数,并返回从1到给定参数的所有数字的和。
*
* @param n 数字n
* @return 返回从1到给定参数的所有数字的和
*/
public static long sequentialSum(long n) {
long res = 0;
for (long i = 1L; i <= n; i++) {
res += i;
}
return res;
}
// 还可以使用迭代器生成的方式规约结果
public static long sequentialSum02(long n) {
return Stream.iterate(1L, i -> i + 1) // 从1L开始创建无限流
.limit(n) // 规定规约的个数是前n个
.reduce(0L, Long::sum); // reduce sum
}
将顺序流转换为并行流
public static long parallelSum(long n) {
return Stream.iterate(1L, i -> i + 1)
.limit(n)
.parallel()
.reduce(0L, Long::sum);
}
Stream在内部分成了几块。因此可以对不同的块独立并行进行归纳操作。
最后,同一个归纳操作会将各个子流的部分归纳结果合并起来,得到整个原始流的归纳结果。

类似地,你只需要对并行流调用sequential()方法就可以把它变成顺序流。
🎉 你可能以为把这两个方法结合起来,就可以更细化地控制在遍历流时哪些操作要并行执行,哪些要顺序执行。
stream.parallel()
.filter(...) // 并行过滤
.sequential()
.map(...) // 顺序map
.parallel()
.reduce(); // 并行规约
⚠️ 最后一次parallel或sequential调用会影响整个流水线
并行流内部使用了默认的ForkJoinPool,它默认的线程数量就是你的处理器数量,这个值是由Runtime.getRuntime().availableProcessors()得到的。
但是你可以通过系统属性java.util.concurrent.ForkJoinPool.common.parallelism来改变线程池大小,如下所示:
// 一般而言,让ForkJoinPool的大小等于处理器数量是个不错的默认值
// 除非你有很好的理由,否则我们强烈建议不要修改它。
System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism","12");
使用更有针对性的方法
一个叫LongStream.rangeClosed的方法。这个方法与iterate相比有两个优点。
- LongStream.rangeClosed直接产生原始类型的long数字,没有装箱拆箱的开销。
- LongStream.rangeClosed会生成数字范围,很容易拆分为独立的小块。
public static long rangedSum(long n) {
return LongStream.rangeClosed(1, n)
.reduce(0L, Long::sum);
}
public static long parallelRangedSum(long n) {
return LongStream.rangeClosed(1, n)
.parallel()
.reduce(0L, Long::sum);
}
请记住,并行化并不是没有代价的。并行化过程本身需要对流做递归划分,把每个子流的归纳操作分配到不同的线程,然后把这些操作的结果合并成一个值。
但在多个内核之间移动数据的代价也可能比你想的要大,所以很重要的一点是要保证在内核中并行执行工作的时间比在内核之间传输数据的时间长。
总而言之,很多情况下不可能或不方便并行化。然而,在使用并行Stream加速代码之前,你必须确保用得对;如果结果错了,算得快就毫无意义了。
高效使用并行流建议
一般而言,想给出任何关于什么时候该用并行流的定量建议都是不可能也毫无意义的,因为任何类似于“仅当至少有一千个元素的时候才用并行流”的建议对于某台特定机器上的某个特定操作可能是对的,但在略有差异的另一种情况下可能就是大错特错。尽管如此,我们至少可以提出一些定性意见,帮你决定某个特定情况下是否有必要使用并行流。
-
如果有疑问,测量。把顺序流转成并行流轻而易举,但却不一定是好事。并行流并不总是比顺序流快。此外,并行流有时候会和你的直觉不一致,所以在考虑选择顺序流还是并行流时,第一个也是最重要的建议就是用适当的基准来检查其性能。
-
留意装箱。自动装箱和拆箱操作会大大降低性能。Java 8中有原始类型流(IntStream、LongStream、DoubleStream)来避免这种操作,但凡有可能都应该用这些流。
-
有些操作本身在并行流上的性能就比顺序流差。特别是limit和findFirst等依赖于元素顺序的操作,它们在并行流上执行的代价非常大。例如findAny会比findFirst性能好,因为它不一定要按顺序来执行。你总是可以调用unordered方法来把有序流变成无序流。那么,如果你需要流中的n个元素而不是专门要前n个的话,对无序并行流调用limit可能会比单个有序流(比如数据源是一个List)更高效。
-
还要考虑流的操作流水线的总计算成本。设N是要处理的元素的总数,Q是一个元素通过流水线的大致处理成本,则N*Q就是这个对成本的一个粗略的定性估计。Q值较高就意味着使用并行流时性能好的可能性比较大。
-
对于较小的数据量,选择并行流几乎从来都不是一个好的决定。并行处理少数几个元素的好处还抵不上并行化造成的额外开销。
-
要考虑流背后的数据结构是否易于分解。例如,ArrayList的拆分效率比LinkedList高得多,因为前者用不着遍历就可以平均拆分,而后者则必须遍历。另外,用range工厂方法创建的原始类型流也可以快速分解。
| 源 | 可分解性 |
|---|---|
| ArrayList | 极佳 |
| LinkedList | 差 |
| IntStream.range | 极佳 |
| Stream.iterate | 差 |
| HashSet | 好 |
| TreeSet | 好 |
-
流自身的特点,以及流水线中的中间操作修改流的方式,都可能会改变分解过程的性能。例如,一个SIZED流可以分成大小相等的两部分,这样每个部分都可以比较高效地并行处理,但筛选操作可能丢弃的元素个数却无法预测,导致流本身的大小未知。
-
还要考虑终端操作中合并步骤的代价是大是小(例如Collector中的combiner方法)。如果这一步代价很大,那么组合每个子流产生的部分结果所付出的代价就可能会超出通过并行流得到的性能提升。
并行流背后使用的基础架构是Java 7中引入的分支/合并框架。
分支/合并框架
分支/合并框架的目的是以递归方式将可以并行的任务拆分成更小的任务,然后将每个子任务的结果合并起来生成整体结果。它是ExecutorService接口的一个实现,它把子任务分配给线程池(称为ForkJoinPool)中的工作线程。首先来看看如何定义任务和子任务。
使用RecursiveTask
要把任务提交到这个池,必须创建RecursiveTask的一个子类,其中R是并行化任务(以及所有子任务)产生的结果类型,或者如果任务不返回结果,则是RecursiveAction类型(当然它可能会更新其他非局部机构)。
要定义RecursiveTask,只需实现它唯一的抽象方法compute:
protected abstract R compute();
这个方法同时定义了将任务拆分成子任务的逻辑,以及无法再拆分或不方便再拆分时,生成单个子任务结果的逻辑。正由于此,这个方法的实现类似于下面的伪代码:
if (任务足够小或不可分) {
顺序计算该任务
} else {
将任务分成两个子任务
递归调用本方法,拆分每个子任务,等待所有子任务完成
合并每个子任务的结果
}

请注意在实际应用时,使用多个ForkJoinPool是没有什么意义的。
使用分支/合并框架的最佳做法
虽然分支/合并框架还算简单易用,不幸的是它也很容易被误用。以下是几个有效使用它的最佳做法。
-
对一个任务调用join方法会阻塞调用方,直到该任务做出结果。因此,有必要在两个子任务的计算都开始之后再调用它。否则,你得到的版本会比原始的顺序算法更慢更复杂,因为每个子任务都必须等待另一个子任务完成才能启动。
-
不应该在RecursiveTask内部使用ForkJoinPool的invoke方法。相反,你应该始终直接调用compute或fork方法,只有顺序代码才应该用invoke来启动并行计算。
-
对子任务调用fork方法可以把它排进ForkJoinPool。同时对左边和右边的子任务调用它似乎很自然,但这样做的效率要比直接对其中一个调用compute低。这样做你可以为其中一个子任务重用同一线程,从而避免在线程池中多分配一个任务造成的开销。
-
调试使用分支/合并框架的并行计算可能有点棘手。特别是你平常都在你喜欢的IDE里面看栈跟踪(stack trace)来找问题,但放在分支合并计算上就不行了,因为调用compute的线程并不是概念上的调用方,后者是调用fork的那个。
-
和并行流一样,你不应理所当然地认为在多核处理器上使用分支/合并框架就比顺序计算快。我们已经说过,一个任务可以分解成多个独立的子任务,才能让性能在并行化时有所提升。所有这些子任务的运行时间都应该比分出新任务所花的时间长;一个惯用方法是把输入/输出放在一个子任务里,计算放在另一个里,这样计算就可以和输入/输出同时进行。此外,在比较同一算法的顺序和并行版本的性能时还有别的因素要考虑。就像任何其他Java代码一样,分支/合并框架需要“预热”或者说要执行几遍才会被JIT编译器优化。这就是为什么在测量性能之前跑几遍程序很重要,我们的测试框架就是这么做的。同时还要知道,编译器内置的优化可能会为顺序版本带来一些优势(例如执行死码分析——删去从未被使用的计算)。
对于分支/合并拆分策略还有最后一点补充:你必须选择一个标准,来决定任务是要进一步拆分还是已小到可以顺序求值。
Spliterator
Spliterator是Java 8中加入的另一个新接口;这个名字代表“可分迭代器”(splitable iterator)。
和Iterator一样,Spliterator也用于遍历数据源中的元素,但它是为了并行执行而设计的。
虽然在实践中可能用不着自己开发Spliterator,但了解一下它的实现方式会让你对并行流的工作原理有更深入的了解。
Java 8已经为集合框架中包含的所有数据结构提供了一个默认的Spliterator实现
// T是Spliterator遍历的元素的类型
public interface Spliterator<T> {
// tryAdvance 会按顺序一个一个使用Spliterator中的元素,并且如果还有其他元素要遍历就返回true
boolean tryAdvance(Consumer<? super T> action);
// 把一些元素划出去分给第二个Spliterator(由该方法返回),让它们两个并行处理
Spliterator<T> trySplit();
// 估计还剩下多少元素要遍历,因为即使不那么确切,能快速算出来是一个值也有助于让拆分均匀一点
long estimateSize();
int characteristics();
}
- T是Spliterator遍历的元素的类型。
- tryAdvance方法的行为类似于普通的Iterator,因为它会按顺序一个一个使用Spliterator中的元素,并且如果还有其他元素要遍历就返回true。
- trySplit是专为Spliterator接口设计的,因为它可以把一些元素划出去分给第二个Spliterator(由该方法返回),让它们两个并行处理。
- Spliterator还可通过estimateSize方法估计还剩下多少元素要遍历,因为即使不那么确切,能快速算出来是一个值也有助于让拆分均匀一点。
拆分过程
将Stream拆分成多个部分的算法是一个递归过程,如下图:

Spliterator的特性
Spliterator接口声明的最后一个抽象方法是characteristics,它将返回一个int,代表Spliterator本身特性集的编码。
使用Spliterator的客户可以用这些特性来更好地控制和优化它的使用。
| 特性 | 含义 |
|---|---|
| ORDERED | 元素有既定的顺序(例如List),因此Spliterator在遍历和划分时也会遵循这一顺序 |
| DISTINCT | 对于任意一对遍历过的元素x和y,x.equals(y)返回false |
| SORTED | 遍历的元素按照一个预定义的顺序排序 |
| SIZED | 该Spliterator由一个已知大小的源建立(例如Set),因此estimatedSize()返回的是准确值 |
| NONNULL | 保证遍历的元素不会为null |
| IMMUTABLE | Spliterator的数据源不能修改。这意味着在遍历时不能添加、删除或修改任何元素 |
| CONCURRENT | 该Spliterator的数据源可以被其他线程同时修改而无需同步 |
| SUBSIZED | 该Spliterator和所有从它拆分出来的Spliterator都是SIZED |
3941

被折叠的 条评论
为什么被折叠?



