Java 8 Stream-并行执行-结果不同-为什么?

时间:2018-07-23 20:37:59

标签: java parallel-processing java-8 java-stream

假设我有一个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完成测试。

编辑: 这里没有重复。我了解链接的问题。为什么收集器会产生正确的结果?如果他会产生另一个随机结果,我不会问。

3 个答案:

答案 0 :(得分:10)

如果您通过的collect具有不同的特征,则Collector操作 会产生无序输出。也就是说,如果设置了CONCURRENTUNORDERED标志(请参见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]

您可以看到,从流中读取的每个数据都写入了一个新的累加器,并且已将它们仔细组合以保持顺序。

如果我们设置CONCURRENTUNORDERED特征标记,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

的原因,出现这些顺序的顺序仍然不确定。