java 如何线程安全地使用HashMap?

为什么HashMap不是线程安全的?如何正确使用HashMap?

参考:http://yemengying.com/2016/05/07/threadsafe-hashmap/#SynchronizedMap

 

一、为什么HashMap不是线程安全的

(1)JDK1.7中HashMap可能发生的问题

手头上没有JDK1.7,所以只是从源码上研究一下可能遇到的问题,没有实际引发错误的代码。

1.插入导致头结点元素丢失

查看JDK1.7的addEntry方法:

假设现在存在两条线程A、B,如果两条线程同时对同一个数组位置(统一bucket)调用addEntry,那么两条线程将同时获取头结点的位置:

然后其中一条线程(比如线程A)会先行把新元素(线程A中的新元素)插入头结点:

此时bucket的头结点已经变成了线程A中的新元素,但是线程B不会知道这一点,依然会把线程B中的新元素插入头结点,导致线程A中的新元素被覆盖。

2.移除导致元素被覆盖

查看JDK1.7的removeEntryForKey方法

大概思路是找到要移除的元素的位置,然后让后面的元素直接覆盖要移除的元素,保证链表不断。

那么问题来了,假如有两条线程同时操作HashMap对象,一条线程插入元素,另一条线程移除元素,那么后执行的线程必定会覆盖前执行线程的结果,导致某操作被覆盖。

3.resize导致元素丢失

查看JDK1.7的resize方法:

当数组容量不足时,HashMap将调用resize方法对数组进行扩容。

那么问题来了,假如一条线程在插入时遇到数组容量不足的情况,那么将发生扩容。在此时,如果有别的线程正在修改HashMap中的元素,又会发生相互覆盖的问题。

4.死循环问题

HashMap的resize方法可能导致死循环问题。大概原因就是resize导致两个元素next的对象为彼此,形成了环路,在遍历这两个元素所在的bucket时就会进入死循环。

死循环问题的详细分析,可以查看:http://www.importnew.com/25070.html

死循环可由以下代码引发(在JDK 1.8中无此问题):

testHashMapLoop.java

体现为进程一直运行,需要手动结束。

可以使用jps + jstack定位死循环问题。

(2)JDK1.8中HashMap可能发生的问题

实践一下,看看在JDK1.8中还存不在上面的问题。

1.插入导致头结点元素丢失和扩容

并发问题元素少了不容易出效果,元素多了HashMap就resize,单独测试是真的不好测,所以放在一起进行测试。

TestPut.java

结果为:

期待的结果应该是10000 + 10000 * 3 = 40000,很明显插入时出现了并发问题。

2.移除导致元素被覆盖

TestPut.java

结果为:

期待的结果应该是10000 + 10000 – 5000 = 15000,很明显移除时出现了并发问题。

二、如何线程安全地使用HashMap

从上文我们已经知道HashMap不是线程安全的,在并发情况下必须有新的解决方案。

说起这个问题,我就想起很久以前在做验证码的时候,我直接使用了HashMap。后来觉得不对劲,才改用redis。现在想起来,当时简直就是失了智。

(1)解决方案

我个人知道有一下三种解决方案:

1.HashTable

HashTable通过synchronized修饰实现线程安全。

查看HashTable的get方法:

HashTable通过对所有方法都加上synchronized锁的方式保证线程安全。

但是我们都知道,synchronized修饰的方法并发效率较低。所以现在基本上不会使用HashTable了。

2.ConcurrentHashMap

在JDK1.7中,ConcurrentHashMap通过分段锁实现线程安全,但是JDK1.8中ConcurrentHashMap启用了分段锁,采用了别的方式保证线程安全。这个问题之后我会另写文章来研究,在并发条件下,ConcurrentHashMap是较好的解决方案。

改写TestPut.java

结果为:

3.SynchronizedMap

SynchronizedMap是Collections类的一个方法:

具体实现为:

可以这样使用:

(2)性能对比

写一个测试类TestPerformance.java,进行测试:

结果为:

从效率上看,使用ConcurrentHashMap是更好的选择。

三、总结

下次分析ConcurrentHashMap。

发表评论

电子邮件地址不会被公开。 必填项已用*标注