为什么共享可变性不好?

时间:2017-05-27 17:41:17

标签: java java-8 java-stream immutability

我正在观看关于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());

我不明白为什么第一段代码是&#34;坏习惯&#34;。对我来说,他们都达到了同样的目标。

4 个答案:

答案 0 :(得分:35)

第一个示例代码段

的说明

执行并行处理时,问题就出现了。

//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的博客回答why a shared mutable state is the root of all evil.

这是一个简短的推理,为什么共享可变性好;从博客中提取。

  

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

     

这个等式基本上意味着并行处理和   可变状态组合导致非确定性程序行为。   如果您只是进行并行处理并且只具有不可变状态   一切都很好,很容易推理程序。在   另一方面,如果你想用可变数据进行并行处理   需要同步对可变变量的访问权限   本质上渲染程序的这些部分单线程。这并不是什么新鲜事,但我还没有看到这个概念如此优雅。 非确定性程序被破坏

此博客继续推导内部细节,了解为什么没有正确同步的并行程序会被破坏,您可以在附加的链接中找到它们。

第二个示例代码段

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

这使用Collector对此流的元素使用collect reduction 操作。

这是更安全,更多高效,更适合并行化。

答案 1 :(得分:13)

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

  

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

您可以使用:

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

你总会得到同样的保证结果。

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

有趣的是,Collectors.toList使用ArrayList下面的不是线程安全集合。只是它使用了许多(用于并行处理)并在最后合并。

最后一点注意,并行和顺序不影响遭遇顺序,它是应用于Stream的操作。优秀阅读here

我们还需要认为,即使使用线程安全集合,对于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可能是[0,3,0,0][0,0,3,0]或其他内容。

答案 2 :(得分:6)

假设两个线程同时执行此任务,第二个线程在第一个线程后面执行一条指令。

第一个线程创建doubleOfEven。第二个线程创建doubleOfEven,第一个线程创建的实例将被垃圾收集。然后两个线程将所有偶数的双精度数添加到doubleOfEvent,因此它将包含0,0,4,4,8,8,12,12,...而不是0,4,8,12 ...(...实际上这些线程不会完全同步,所以任何可能出错的东西都会出错)。

不是第二种解决方案好得多。您将有两个线程设置相同的全局。在这种情况下,他们将两者设置为逻辑上相等的值,但如果将它们设置为两个不同的值,那么您不知道之后具有哪个值。一个线程将获得它想要的结果。

答案 3 :(得分:0)

在第一个示例中,如果要使用parallel(),则不能保证插入(例如,多个线程插入相同的元素)。

另一方面,

collect(...)在并行运行时,会拆分工作并在中间步骤中内部收集结果,然后将其添加到最终列表中,以确保顺序和安全性。