共享可变性为什么不好?

58

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

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


13
将流并行化后,顺序不再被遵守,更糟的是,由于多个线程同时变异它而没有同步,列表可能会受到破坏。在第二个版本中不会出现这种情况。 - JB Nizet
4
@Eugene,如果没有共享,那就只有一个线程。因此,由于这涉及到共享可变性,是关于并行的。即使使用同步集合,将该代码并行化也会使集合的顺序不确定(实际上,即使没有并行化,它也没有保证确定性顺序,尽管在实践中通常如此)。 - JB Nizet
1
我非常确定这应该放在[SoftwareEngineering.SE]上。就记录而言,我不同意一种说法:“可变性是可以的。” 可变性会让你陷入麻烦。它更像是一种必要的恶。 - jpmc26
2
@JBNizet 可能应该重新表述一下。没有顺序是由于 forEach,而是由于应用了 parallel。我的观点是并行/串行不决定顺序;操作才是决定因素。 - Eugene
3
好的,我同意。 - JB Nizet
显示剩余4条评论
4个回答

45

第一个示例片段的解释

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

//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! 

这个操作使用一个收集器对此流的元素进行归约操作。这种方法更加安全、高效,并且更易于并行化。

1
即使在可变的同步列表的情况下,您最终仍会在列表上出现线程争用,从而几乎使并行处理无效。 - fps
4
这篇摘录虽然作为一个经验法则是可以接受的,但它过于简化了情况。通过对其中一种或另一种加以限制,您可以同时拥有并行处理和可变状态而不会失去确定性。例如,使用格变量部分限制了可变性。显然,各种形式的同步或协调会限制并行性而不会限制可变性。子程序可以是非确定性的,而整个程序仍然符合确定性规范,并且非确定性可以成为规范的一部分,因此程序不会由于非确定性而本质上出错。 - Derek Elkins left SE
1
@FedericoPeraltaSchaffner,我认为引文中的陈述要点是:“如果您想使用可变数据进行并行处理,则需要同步访问可变变量,这基本上使程序的这些部分成为单线程。” - java-addict301

16

事实是这个演讲有一点错误。他提供的示例使用了forEach,它的文档中写道:

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

您可以使用:

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

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

另一方面,使用 Collectors.toList 的例子更好,因为 Collectors 尊重遇到的顺序,所以它可以很好地工作。

有趣的一点是 Collectors.toList 底层使用的是 ArrayList,这不是一个线程安全的集合,只是使用了许多这样的集合(用于并行处理)并在最后合并。

最后需要注意的是,并行和串行不会影响遇到的顺序,关键是应用于 Stream 的操作。关于这个问题,可以在这里阅读更多信息。

我们还需要考虑,即使使用线程安全的集合,在流上完全依赖 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);

此时收集到的数据可能是[0,3,0,0]或者[0,0,3,0]或其他内容。


6
假设两个线程同时执行此任务,第二个线程比第一个线程晚执行一条指令。
第一个线程创建doubleOfEven。第二个线程也创建doubleOfEven,第一个线程创建的实例将被垃圾回收。然后,两个线程都将所有偶数的double添加到doubleOfEvent中,因此它将包含0、0、4、4、8、8、12、12等,而不是0、4、8、12等(实际上,这些线程不会完全同步,所以任何可能出错的事情都会发生)。
请注意,第二种解决方案并没有好多少。你会有两个线程设置相同的全局变量。在这种情况下,它们都将其设置为逻辑上相等的值,但如果它们将其设置为两个不同的值,则不知道之后的值是什么。一个线程将无法得到它想要的结果。

0

在第一个例子中,如果您使用了parallel(),则无法保证插入(例如多个线程插入相同元素)。

另一方面,如果以并行方式运行,collect(...)会将工作拆分并在内部收集结果,然后将它们添加到最终列表中,确保顺序和安全性。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接