为什么共享可变性不好?
Why is shared mutability bad?
我正在看关于 Java 的演讲,有一次,讲师说:
"Mutability is OK, sharing is nice, shared mutability is devil's work."
他指的是下面的一段代码,他认为这是 "extremely bad habit":
//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());
我不明白为什么第一段代码是"bad habit"。对我来说,他们都实现了相同的目标。
对第一个示例片段的解释
执行并行处理时出现问题。
//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.
这是关于为什么共享可变性不好的简短推理;摘自博客。
non-determinism = parallel processing + mutable state
This equation basically means that both parallel processing and
mutable state combined result in non-deterministic program behaviour.
If you just do parallel processing and have only immutable state
everything is fine and it is easy to reason about programs. On the
other hand if you want to do parallel processing with mutable data you
need to synchronize the access to the mutable variables which
essentially renders these sections of the program single threaded. This is not really new but I haven't seen this concept expressed so elegantly. A non-deterministic program is broken.
此博客继续推导了有关为什么没有正确同步的并行程序被破坏的内部细节,您可以在附加的 link 中找到这些细节。
对第二个示例片段的解释
List<Integer> doubleOfEven2 =
numbers.stream()
.filter(e -> e % 2 == 0)
.map(e -> e * 2)
.collect(toList()); // No side-effects!
这使用 Collector
.
对此流的元素使用收集 reduction 操作
这更安全、更高效,更易于并行化。
假设两个线程同时执行这个任务,第二个线程比第一个线程落后一条指令。
第一个线程创建doubleOfEven。第二个线程创建 doubleOfEven,第一个线程创建的实例将被垃圾回收。然后两个线程都会将所有偶数的双精度值添加到 doubleOfEvent,因此它将包含 0, 0, 4, 4, 8, 8, 12, 12, ... 而不是 0, 4, 8, 12...(实际上,这些线程不会完全同步,所以任何可能出错的地方都会出错)。
并不是说第二种解决方案好得多。您将有两个线程设置相同的全局。在这种情况下,他们将其设置为逻辑上相等的值,但如果他们将其设置为两个不同的值,那么您将不知道之后拥有哪个值。一个线程将不会得到它想要的结果。
问题是讲座同时错误。他提供的示例使用forEach
,记录为:
The behavior of this operation is explicitly nondeterministic. For parallel stream pipelines, this operation does not guarantee to respect the encounter order of the stream, as doing so would sacrifice the benefit of parallelism...
您可以使用:
numbers.stream()
.filter(e -> e % 2 == 0)
.map(e -> e * 2)
.parallel()
.forEachOrdered(e -> doubleOfEven.add(e));
而且您将始终获得相同的保证结果。
另一方面,使用 Collectors.toList
的例子更好,因为收藏家尊重 encounter order
,所以它工作得很好。
有趣的一点是 Collectors.toList
在下面使用 ArrayList
不是线程安全的 collection。只是使用了其中的许多(用于并行处理)并在最后合并。
最后请注意,并行和顺序不影响 相遇顺序 ,它是应用于 Stream
的操作。优秀阅读 here.
我们还需要考虑即使使用线程安全 collection 对于 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]
或其他。
在第一个示例中,如果您要使用 parallel(),则无法保证插入(例如,多个线程插入相同的元素)。
collect(...) 另一方面,当运行并行时,拆分工作并在中间步骤内部收集结果,然后将它们添加到最终列表中,确保顺序和安全.
我正在看关于 Java 的演讲,有一次,讲师说:
"Mutability is OK, sharing is nice, shared mutability is devil's work."
他指的是下面的一段代码,他认为这是 "extremely bad habit":
//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());
我不明白为什么第一段代码是"bad habit"。对我来说,他们都实现了相同的目标。
对第一个示例片段的解释
执行并行处理时出现问题。
//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.
这是关于为什么共享可变性不好的简短推理;摘自博客。
non-determinism = parallel processing + mutable state
This equation basically means that both parallel processing and mutable state combined result in non-deterministic program behaviour. If you just do parallel processing and have only immutable state everything is fine and it is easy to reason about programs. On the other hand if you want to do parallel processing with mutable data you need to synchronize the access to the mutable variables which essentially renders these sections of the program single threaded. This is not really new but I haven't seen this concept expressed so elegantly. A non-deterministic program is broken.
此博客继续推导了有关为什么没有正确同步的并行程序被破坏的内部细节,您可以在附加的 link 中找到这些细节。
对第二个示例片段的解释
List<Integer> doubleOfEven2 =
numbers.stream()
.filter(e -> e % 2 == 0)
.map(e -> e * 2)
.collect(toList()); // No side-effects!
这使用 Collector
.
这更安全、更高效,更易于并行化。
假设两个线程同时执行这个任务,第二个线程比第一个线程落后一条指令。
第一个线程创建doubleOfEven。第二个线程创建 doubleOfEven,第一个线程创建的实例将被垃圾回收。然后两个线程都会将所有偶数的双精度值添加到 doubleOfEvent,因此它将包含 0, 0, 4, 4, 8, 8, 12, 12, ... 而不是 0, 4, 8, 12...(实际上,这些线程不会完全同步,所以任何可能出错的地方都会出错)。
并不是说第二种解决方案好得多。您将有两个线程设置相同的全局。在这种情况下,他们将其设置为逻辑上相等的值,但如果他们将其设置为两个不同的值,那么您将不知道之后拥有哪个值。一个线程将不会得到它想要的结果。
问题是讲座同时错误。他提供的示例使用forEach
,记录为:
The behavior of this operation is explicitly nondeterministic. For parallel stream pipelines, this operation does not guarantee to respect the encounter order of the stream, as doing so would sacrifice the benefit of parallelism...
您可以使用:
numbers.stream()
.filter(e -> e % 2 == 0)
.map(e -> e * 2)
.parallel()
.forEachOrdered(e -> doubleOfEven.add(e));
而且您将始终获得相同的保证结果。
另一方面,使用 Collectors.toList
的例子更好,因为收藏家尊重 encounter order
,所以它工作得很好。
有趣的一点是 Collectors.toList
在下面使用 ArrayList
不是线程安全的 collection。只是使用了其中的许多(用于并行处理)并在最后合并。
最后请注意,并行和顺序不影响 相遇顺序 ,它是应用于 Stream
的操作。优秀阅读 here.
我们还需要考虑即使使用线程安全 collection 对于 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]
或其他。
在第一个示例中,如果您要使用 parallel(),则无法保证插入(例如,多个线程插入相同的元素)。
collect(...) 另一方面,当运行并行时,拆分工作并在中间步骤内部收集结果,然后将它们添加到最终列表中,确保顺序和安全.