Java8实战-总结30
- 并行数据处理与性能
- 并行流
- 正确使用并行流
- 高效使用并行流
- 小结
并行数据处理与性能
并行流
正确使用并行流
错用并行流而产生错误的首要原因,就是使用的算法改变了某些共享状态。下面是另一种实现对前n
个自然数求和的方法,但这会改变一个共享累加器:
public static long sideEffectSum(long n) {Accumulator accumulator = new Accumulator();LongStream.rangeClosed(1, n).forEach(accumulator::add);return accumulator.total;
}public class Accumulator {public long total = 0;public void add(long value) { total += value; }
}
这种代码非常普遍,特别是对那些熟悉指令式编程范式的程序员来说。这段代码和指令式迭代数字列表的方式很像:初始化一个累加器,一个个遍历列表中的元素,把它们和累加器相加。
那这种代码它在本质上就是顺序的。每次访问total
都会出现数据竞争。如果你尝试用同步来修复,那就完全失去并行的意义了。为了说明这一点,让我们试着把Stream
变成并行的:
public static long sideEffectParallelSum(long n) {Accumulator accumulator = new Accumulator();LongStream.rangeClosed(1, n).parallel().forEach(accumulator::add);return accumulator.total;
}
用测试框架来执行这个方法,并打印每次执行的结果:
System.out.println("SideEffect parallel sum done in:" + measurePerf(ParallelStreams::sideEffectParallelSum, 10_000_000L) + " msecs");
你可能会得到类似于下面这种输出:
Result: 5959989000692
Result:7425264100768
Result: 6827235020033
Result:7192970417739
Result: 6714157975331
Result:7497810541907
Result: 6435348440385
Result:6999349840672
Result:7435914379978
Result:7715125932481
SideEffect parallel sum done in: 49 msecs
这回方法的性能无关紧要了,唯一要紧的是每次执行都会返回不同的结果,都离正确值50000005000000
差很远。这是由于多个线程在同时访问累加器,执行total += value
,而这一句虽然看似简单,却不是一个原子操作。问题的根源在于,forEach
中调用的方法有副作用,它会改变多个线程共享的对象的可变状态。要是你想用并行Stream
又不想引发类似的意外,就必须避免这种情况。
共享可变状态会影响并行流以及并行计算。记住要避免共享可变状态,确保并行Stream
得到正确的结果。接下来,会提供一些实用建议,你可以由此判断什么时候可以利用并行流来提升性能。
高效使用并行流
一般而言,想给出任何关于什么时候该用并行流的定量建议都是不可能也毫无意义的,因为任何类似于“仅当至少有一千个(或一百万个或随便什么数字)元素的时候才用并行流)”的建议对于某台特定机器上的某个特定操作可能是对的,但在略有差异的另一种情况下可能就是大错特错。尽管如此,至少可以提出一些定性意见,帮你决定某个特定情况下是否有必要使用并行流。
- 如果有疑问,测量。把顺序流转成并行流轻而易举,但却不一定是好事。我们已经指出,并行流并不总是比顺序流快。此外,并行流有时候会和你的直觉不一致,所以在考虑选择顺序流还是并行流时,第一个也是最重要的建议就是用适当的基准来检查其性能。
- 留意装箱。自动装箱和拆箱操作会大大降低性能。
Java 8
中有原始类型流(IntStream
、LongStream
、DoubleStream
)来避免这种操作,但凡有可能都应该用这些流。 - 有些操作本身在并行流上的性能就比顺序流差。特别是
limit
和findFirst
等依赖于元素顺序的操作,它们在并行流上执行的代价非常大。例如,findAny
会比findFirst
性能好,因为它不一定要按顺序来执行。你总是可以调用unordered
方法来把有序流变成无序流。那么,如果你需要流中的n
个元素而不是专门要前n
个的话,对无序并行流调用limit
可能会比单个有序流(比如数据源是一个List
)更高效。 - 还要考虑流的操作流水线的总计算成本。设
N
是要处理的元素的总数,Q
是一个元素通过流水线的大致处理成本,则N*Q
就是这个对成本的一个粗略的定性估计。Q
值较高就意味着使用并行流时性能好的可能性比较大。 - 对于较小的数据量,选择并行流几乎从来都不是一个好的决定。并行处理少数几个元素的好处还抵不上并行化造成的额外开销。
- 要考虑流背后的数据结构是否易于分解。例如,
ArrayList
的拆分效率比LinkedList
高得多,因为前者用不着遍历就可以平均拆分,而后者则必须遍历。另外,用range
工厂方法创建的原始类型流也可以快速分解。 - 流自身的特点,以及流水线中的中间操作修改流的方式,都可能会改变分解过程的性能。例如,一个
SIZED
流可以分成大小相等的两部分,这样每个部分都可以比较高效地并行处理,但筛选操作可能丢弃的元素个数却无法预测,导致流本身的大小未知。 - 还要考虑终端操作中合并步骤的代价是大是小(例如
Collector
中的combiner
方法)。如果这一步代价很大,那么组合每个子流产生的部分结果所付出的代价就可能会超出通过并行流得到的性能提升。
下表按照可分解性总结了一些流数据源适不适于并行。 流的数据源和可分解性:
小结
- 内部迭代让你可以并行处理一个流,而无需在代码中显式使用和协调不同的线程。
- 虽然并行处理一个流很容易,却不能保证程序在所有情况下都运行得更快。并行软件的行为和性能有时是违反直觉的,因此一定要测量,确保你并没有把程序拖得更慢。
- 像并行流那样对一个数据集并行执行操作可以提升性能,特别是要处理的元素数量庞大,或处理单个元素特别耗时的时候。
- 从性能角度来看,使用正确的数据结构,如尽可能利用原始流而不是一般化的流,几乎总是比尝试并行化某些操作更为重要。