假设我有一个List<Integer> ints = new ArrayList<>();
,我想向其中添加值,并使用forEach()
和Collectors.toList()
比较并行执行的结果。
首先,我将来自顺序IntStream和forEach的一些值添加到此列表中:
IntStream.range(0,10).boxed().forEach(ints::add);
我得到正确的结果:
ints ==> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
现在我.clear()
并同时执行相同的操作:
IntStream.range(0,10).parallel().boxed().forEach(ints::add);
现在由于多线程,我得到了不正确的结果:
ints ==> [6, 5, 8, 9, 7, 2, 4, 3, 1, 0]
现在我切换到收集相同的整数流:
IntStream.range(0,10).parallel().boxed().collect(Collectors.toList());
我得到正确的结果:
ints ==> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
问题:
为什么两个并行执行会产生不同的结果,为什么Collector
会产生正确的结果?
如果forEach
产生随机结果,则Collector
也应如此。我没有指定任何排序方式,但我认为他在内部将其添加到列表中,就像我使用forEach
手动进行的那样。由于他是并行执行的,因此他的add
方法应该以未指定的顺序获取值。通过JShell完成测试。
编辑: 这里没有重复。我了解链接的问题。为什么收集器会产生正确的结果?如果他会产生另一个随机结果,我不会问。
答案 0 :(得分:10)
如果您通过的collect
具有不同的特征,则Collector
操作 会产生无序输出。也就是说,如果设置了CONCURRENT
和UNORDERED
标志(请参见Collector.characteristics()
)。
内幕Collectors.toList()
正在构造一个与此大致等效的Collector
:
Collector.of(
// Supplier of accumulators
ArrayList::new,
// Accumulation operation
List::add,
// Combine accumulators
(left, right) -> {
left.addAll(right);
return left;
}
)
一些日志记录表明collect
操作将维护线程安全和流顺序的长度:
Collector.of(
() -> {
System.out.printf("%s supplying\n", Thread.currentThread().getName());
return new ArrayList<>();
},
(l, o) -> {
System.out.printf("%s accumulating %s to %s\n", Thread.currentThread().getName(), o, l);
l.add(o);
},
(l1, l2) -> {
System.out.printf("%s combining %s & %s\n", Thread.currentThread().getName(), l1, l2);
l1.addAll(l2);
return l1;
}
)
日志:
ForkJoinPool-1-worker-1 supplying
ForkJoinPool-1-worker-0 supplying
ForkJoinPool-1-worker-0 accumulating 2 to []
ForkJoinPool-1-worker-1 accumulating 6 to []
ForkJoinPool-1-worker-0 supplying
ForkJoinPool-1-worker-0 accumulating 4 to []
ForkJoinPool-1-worker-1 supplying
ForkJoinPool-1-worker-1 accumulating 5 to []
ForkJoinPool-1-worker-0 supplying
ForkJoinPool-1-worker-0 accumulating 3 to []
ForkJoinPool-1-worker-0 combining [3] & [4]
ForkJoinPool-1-worker-0 combining [2] & [3, 4]
ForkJoinPool-1-worker-1 combining [5] & [6]
ForkJoinPool-1-worker-0 supplying
ForkJoinPool-1-worker-1 supplying
ForkJoinPool-1-worker-0 accumulating 1 to []
ForkJoinPool-1-worker-1 accumulating 8 to []
ForkJoinPool-1-worker-0 supplying
ForkJoinPool-1-worker-1 supplying
ForkJoinPool-1-worker-1 accumulating 9 to []
ForkJoinPool-1-worker-1 combining [8] & [9]
ForkJoinPool-1-worker-1 supplying
ForkJoinPool-1-worker-1 accumulating 7 to []
ForkJoinPool-1-worker-1 combining [7] & [8, 9]
ForkJoinPool-1-worker-1 combining [5, 6] & [7, 8, 9]
ForkJoinPool-1-worker-0 accumulating 0 to []
ForkJoinPool-1-worker-0 combining [0] & [1]
ForkJoinPool-1-worker-0 combining [0, 1] & [2, 3, 4]
ForkJoinPool-1-worker-0 combining [0, 1, 2, 3, 4] & [5, 6, 7, 8, 9]
您可以看到,从流中读取的每个数据都写入了一个新的累加器,并且已将它们仔细组合以保持顺序。
如果我们设置CONCURRENT
和UNORDERED
特征标记,collect方法可以自由使用快捷方式;仅分配了一个累加器,不需要有序组合。
使用:
Collector.of(
() -> {
System.out.printf("%s supplying\n", Thread.currentThread().getName());
return Collections.synchronizedList(new ArrayList<>());
},
(l, o) -> {
System.out.printf("%s accumulating %s to %s\n", Thread.currentThread().getName(), o, l);
l.add(o);
},
(l1, l2) -> {
System.out.printf("%s combining %s & %s\n", Thread.currentThread().getName(), l1, l2);
l1.addAll(l2);
return l1;
},
Characteristics.CONCURRENT,
Characteristics.UNORDERED
)
日志:
ForkJoinPool-1-worker-1 supplying
ForkJoinPool-1-worker-1 accumulating 6 to []
ForkJoinPool-1-worker-0 accumulating 2 to [6]
ForkJoinPool-1-worker-1 accumulating 5 to [6, 2]
ForkJoinPool-1-worker-0 accumulating 4 to [6, 2, 5]
ForkJoinPool-1-worker-0 accumulating 3 to [6, 2, 5, 4]
ForkJoinPool-1-worker-0 accumulating 1 to [6, 2, 5, 4, 3]
ForkJoinPool-1-worker-0 accumulating 0 to [6, 2, 5, 4, 3, 1]
ForkJoinPool-1-worker-1 accumulating 8 to [6, 2, 5, 4, 3, 1, 0]
ForkJoinPool-1-worker-0 accumulating 7 to [6, 2, 5, 4, 3, 1, 0, 8]
ForkJoinPool-1-worker-1 accumulating 9 to [6, 2, 5, 4, 3, 1, 0, 8, 7]
答案 1 :(得分:4)
首先,我建议您通过Why is shared mutability bad? 。
第二,there is an example provided by the authors在“副作用”部分下的作用与您正在做的事情类似:
作为如何转换流水线的示例 不适当地将副作用不适用于没有副作用的情况,如下所示 代码在字符串流中搜索与给定常规匹配的字符串 表达式,然后将匹配项放在列表中。
ArrayList<String> results = new ArrayList<>();
stream.filter(s -> pattern.matcher(s).matches())
.forEach(s -> results.add(s)); // Unnecessary use of side-effects!
如果并行执行,则ArrayList的非线程安全性 会导致错误的结果,而添加所需的同步会导致 竞争,破坏了并行性的好处。此外,使用 这里的副作用是完全没有必要的; forEach()可以简单地 被更安全,更高效的还原操作所取代, 并且更适合并行化:
List<String>results =
stream.filter(s -> pattern.matcher(s).matches())
.collect(Collectors.toList()); // No side-effects!
因此,您仍然可能会问“为什么收集器会产生正确的结果?”。
仅因为作者已经具备处理并行性的功能。
答案 2 :(得分:4)
首先,forEach
记录为:
此操作的行为是明确不确定的
因此,在将来的jdk版本中,即使您的非并行代码也可能产生“不正确”的结果,即乱序的结果。在当前的实现中,只有并行版本会产生这种结果。但这又不能保证,forEach
可以自由地在内部做任何想做的事,例如forEachOrdered
。
是否保留顺序不是不是的顺序或并行属性,它仅取决于是否破坏该顺序;就是这样(例如,显式调用unordered
。
Collectors.toList
是保留订单的终端操作。通常,除非在有关订单的文档中明确显示了终端操作 ,否则它将保留该操作。因此,例如,请参见Stream::generate
:
返回无限连续的无序流。
话虽这么说,一般有两个顺序,即处理 intermediate 操作和 terminal 操作的顺序。第一个未定义,您可以修改示例并检查:
IntStream.range(0,10)
.parallel()
.peek(System.out::println) // out of order printing
.boxed()
.collect(Collectors.toList());
在保留终端操作顺序的情况下。
最后一点是:
....parallel().forEach(ints::add)
您只是很幸运地甚至一开始就看到了所有元素。您正在从不同的线程中将多个元素添加到非线程安全集合(ArrayList
)中;您可能很容易错过元素或在ints
中包含null。我敢打赌,运行几次会证明这一点。
即使您切换为Collections.synchronizedList(yourList)
,由于上述关于forEach