为什么共享可变性不好?对第一个示例代码段的说明对第二个示例代码段的说明

2022-08-31 17:40:10

我正在看一个关于Java的演讲,有一次,讲师说:

“可变性是可以的,共享是好的,共享的可变性是魔鬼的工作。

他所指的就是以下代码,他认为这是一个“极其糟糕的习惯”:

//double the even values and put that into a list.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 1, 2, 3, 4, 5);
List<Integer> doubleOfEven = new ArrayList<>();

numbers.stream()
       .filter(e -> e % 2 == 0)
       .map(e -> e * 2)
       .forEach(e -> doubleOfEven.add(e));

然后,他继续编写应该使用的代码,即:

List<Integer> doubleOfEven2 =
      numbers.stream()
             .filter(e -> e % 2 == 0)
             .map(e -> e * 2)
             .collect(toList());

我不明白为什么第一段代码是“坏习惯”。对我来说,他们都实现了相同的目标。


答案 1

对第一个示例代码段的说明

执行并行处理时,问题就会发挥作用。

//double the even values and put that into a list.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 1, 2, 3, 4, 5);
List<Integer> doubleOfEven = new ArrayList<>();

numbers.stream()
       .filter(e -> e % 2 == 0)
       .map(e -> e * 2)
       .forEach(e -> doubleOfEven.add(e)); // <--- Unnecessary use of side-effects!

这不必要地使用了副作用,而如果正确使用,并非所有副作用都是坏的,当涉及到使用流时,必须提供在输入的不同部分上同时执行的行为。即编写不访问共享可变数据以完成其工作的代码。

该行:

.forEach(e -> doubleOfEven.add(e)); // Unnecessary use of side-effects!

不必要地使用副作用,并且当并行执行时,非线程安全性将导致不正确的结果。ArrayList

不久前,我读了亨里克·艾肯哈特(Henrik Eichenhardt)的一篇博客,回答了为什么共享的可变状态是所有邪恶的根源。

这是一个简短的推理,说明为什么共享可变性不好;摘自博客。

非确定性 = 并行处理 + 可变状态

这个方程基本上意味着并行处理和可变状态组合导致非确定性程序行为。如果你只是做并行处理,只有不可变的状态,一切都很好,很容易推理程序。另一方面,如果要对可变数据进行并行处理,则需要同步对可变变量的访问,这实质上是使程序的这些部分成为单线程的。这并不是什么新鲜事,但我还没有看到这个概念表达得如此优雅。非确定性程序已损坏

此博客继续推导内部详细信息,说明为什么没有正确同步的并行程序会被破坏,您可以在附加的链接中找到这些详细信息。

对第二个示例代码段的说明

List<Integer> doubleOfEven2 =
      numbers.stream()
             .filter(e -> e % 2 == 0)
             .map(e -> e * 2)
             .collect(toList()); // No side-effects! 

这将使用 .Collector

更安全、更高效,也更适合并行化。


答案 2

问题是,同时讲座略有错误。他提供的示例使用 ,记录如下:forEach

此操作的行为是显式非确定性的。对于并行流管道,此操作不保证遵循流的遭遇顺序,因为这样做会牺牲并行性的好处...

您可以使用:

 numbers.stream()
            .filter(e -> e % 2 == 0)
            .map(e -> e * 2)
            .parallel()
            .forEachOrdered(e -> doubleOfEven.add(e));

而且您将始终拥有相同的保证结果。

另一方面,使用的示例更好,因为收藏家尊重,所以它工作得很好。Collectors.toListencounter order

有趣的一点是,下面的使用不是线程安全集合。只是使用其中的许多(用于并行处理)并在最后合并。Collectors.toListArrayList

最后要注意的是,并行和顺序不影响遭遇顺序,这是应用于该做的操作。在这里阅读得很好。Stream

我们还需要考虑,即使使用线程安全集合,Streams仍然不完全安全,尤其是当您依赖 .side-effects

 List<Integer> numbers = Arrays.asList(1, 3, 3, 5);
    Set<Integer> seen = Collections.synchronizedSet(new HashSet<>());
    List<Integer> collected = numbers.stream()
            .parallel()
            .map(e -> {
                if (seen.add(e)) {
                    return 0;
                } else {
                    return e;
                }
            })
            .collect(Collectors.toList());

    System.out.println(collected);

collected此时可以是 OR 或其他东西。[0,3,0,0][0,0,3,0]