在Java 8中使用parallelstream()填充Map是否安全?

21

我有一个包含100万个对象的列表,需要将其填充到Map中。现在,我希望通过使用Java 8的parallelstream()来缩短填充时间,代码如下:

List<Person> list = new LinkedList<>();
Map<String, String> map = new HashMap<>();
list.parallelStream().forEach(person ->{
    map.put(person.getName(), person.getAge());
});

我想问一下,通过并行线程填充Map是否安全。难道没有可能出现并发问题,导致Map中的数据丢失吗?


4
HashMap 不是线程安全的,如果在同一个 Map 中使用多个线程进行写操作,可能会产生并发问题。我认为你应该使用 ConcurrentHashMap。 - Cédric O.
2
这里有一篇关于在并行中使用HashMap的危险的好文章。显然,其中描述的竞争条件可能在Java的后续版本中不存在,但是其一般信息仍然有效。 - biziclop
2
除了竞争条件之外,总的来说,我认为这并不是从并行化中受益的东西。顺序填充地图需要多长时间(我想你已经测量过了?)你需要它有多快? - NPE
3
请查看 Collectors.toConcurrentMap(或 groupingByConcurrent)。 - GPI
3
你的瓶颈可能在其他地方,可能是在生成输入数据或者equalshashCode方法上。唯一可能导致map操作成为瓶颈的解释是恶意的不良hashcode(例如总是返回1)。再次强调,这不是HashMap的问题。 - Marko Topolnik
显示剩余6条评论
2个回答

28
使用parallelStream()将数据收集HashMap中非常安全。但是,使用parallelStream()forEach和一个向HashMap添加内容的消费者是不安全的。 HashMap不是一个同步类,尝试并发地向其中放置元素将不能正常工作。这就是forEach所做的,它将从多个线程中调用给定的消费者,该消费者会把元素放入HashMap中,可能同时进行。如果您想要一个简单的演示问题的代码:
List<Integer> list = IntStream.range(0, 10000).boxed().collect(Collectors.toList());
Map<Integer, Integer> map = new HashMap<>();
list.parallelStream().forEach(i -> {
    map.put(i, i);
});
System.out.println(list.size());
System.out.println(map.size());

请确保运行几次。并发的乐趣在于,操作后打印出来的地图大小很有可能不是10000(列表的大小),而是稍微小一些。

解决方案,一如既往,不是使用forEach,而是使用可变规约方法和内置的collect方法以及toMap:

Map<Integer, Integer> map = list.parallelStream().collect(Collectors.toMap(i -> i, i -> i));

使用上面示例代码中的那行代码,您可以放心地确保地图大小始终为10000。Stream API 确保即使在并行情况下收集到非线程安全容器也是安全的(链接1)。这也意味着您不需要使用toConcurrentMap来确保安全,此收集器仅在您特别需要ConcurrentMap作为结果而不是一般Map时才需要;但就涉及到与collect相关的线程安全性而言,两者都可以使用。

1
是的,打印地图大小很可能不是10000,但也有可能遇到无限循环或出现虚假异常。最糟糕的情况是,它可能会在这个特定的测试运行中表现得正确... - Holger

9

HashMap不是线程安全的,但是使用ConcurrentHashMap可以解决问题。

Map<String, String> map = new ConcurrentHashMap<>();

在JVM热身后,使用并行流和中位数时间,当元素个数为1M时,forEach()版本始终比toMap()版本快2-3倍。

对于所有唯一元素、25%重复元素和100%重复元素的输入,结果都是一致的。


3
当你打印性能比较时,应该同时发布你所比较的“内容”。最值得注意的是,使用“25%重复项”的原始toMap会导致异常而非产生可比较结果。这表明你使用了未指定的合并函数,显然这不是forEach方法所做的。此外,toMaptoConcurrentMap之间有根本性的差别... - Holger
2
@Bohemian:仍然需要使用Map.merge而不是Map.put,这是有区别的。此外,forEach是无序操作,因此您可以使用.unordered().collect(toMap(…))来实现类似的效果。但是,正如所说的,toMap是一种根本不同的操作,与toConcurrentMap不同。如果您没有任何性能相关的上游操作,但仍希望进行并行操作,则toConcurrentMap是更好的选择(就像forEachConcurrentMap方法一样),尽管在这种情况下单线程操作很可能更有效。 - Holger
2
@assylias: toMaptoList类似,从并行操作中无法获得任何好处,因为合并成本与先前的并行处理所带来的潜在好处一样高。它们仅在并行流中有用,当上游操作受益于无争用的并行处理时。对于仅由收集操作组成且没有任何有用的流处理的基准测试,它们将始终失败。 - Holger
2
@Bohemian请确保在您的基准测试中包含简单的for循环变体作为基线,因为在我使用“空”管道(仅将整数列表收集到映射中)进行的基准测试中,for循环比所有其他解决方案组合更快(实际上,forEach + ConcurrentMap也比collect(toMap())稍微快一些)。这实际上取决于在收集之前发生的操作。 - Tunaki
3
因为在进行一些工作之前(对输入整数进行字符串操作等,尝试欺骗JIT,也许失败了,但是嘿),我使用了简单的方法进行第二次基准测试,随后使用collect(toMap())的速度比使用forEach方法更快。无论如何,我认为可以说,如果没有完整的管道进行测试,这并不是最终结论。(在最近的Windows 10 x64上使用JDK 1.8.0_102运行所有这些)。 - Tunaki
显示剩余7条评论

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