为什么共享可变性不好?

Geo*_*nat 46 java immutability java-8 java-stream

我正在观看关于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));
Run Code Online (Sandbox Code Playgroud)

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

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

我不明白为什么第一段代码是"坏习惯".对我而言,他们都达到了同样的目标.

Ous*_* D. 36

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

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

//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!
Run Code Online (Sandbox Code Playgroud)

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

这条线:

.forEach(e -> doubleOfEven.add(e)); // Unnecessary use of side-effects!
Run Code Online (Sandbox Code Playgroud)

不必要地使用副作用,并行执行时,非线程安全ArrayList会导致错误的结果.

不久前,我读了一篇由Henrik Eichenhardt撰写的博客,回答为什么共享的可变状态是所有邪恶的根源.

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

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

该等式基本上意味着并行处理和可变状态组合导致非确定性程序行为.如果你只是进行并行处理并且只有不可变状态,一切都很好,并且很容易推理程序.另一方面,如果您想要使用可变数据进行并行处理,则需要同步对可变变量的访问,这实际上使程序的这些部分呈现单线程.这不是什么新鲜事,但我没有看到这个概念表达得如此优雅.一个非确定性的程序被打破了.

这个博客继续推导出内部细节,以确定为什么没有正确同步的并行程序被破坏,你可以在附加的链接中找到.

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

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

这使用a 对此流的元素使用收集减少操作Collector.

这样更安全,更高效,更易于并行化.

  • 这个提取过度简化了这种情况,尽管这是一个很好的经验法则.您可以通过对一个或另一个进行一些限制来实现并行处理和可变状态而不会丢失确定性.例如,使用晶格变量部分地限制了可变性.显然,各种形式的同步或协调限制了并行性而不限制可变性.子程序可以是非确定性的,而整个程序仍然符合确定性规范,并且非确定性可以是规范的一部分,因此程序不会由于非确定性而固有地被破坏. (4认同)

Eug*_*ene 14

问题是讲座同时有点 不对劲.他提供的示例使用forEach,记录为:

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

你可以使用:

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

而且你总会得到相同的保证结果.

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

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

最后一个注意,并行和顺序不影响遭遇顺序,它是应用于那个操作的操作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);
Run Code Online (Sandbox Code Playgroud)

collected此时可能是[0,3,0,0]OR [0,0,3,0]或其他.


gna*_*729 6

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

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

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