HashMap是线程安全的吗?如何实现线程安全?

Posted Java蚂蚁

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了HashMap是线程安全的吗?如何实现线程安全?相关的知识,希望对你有一定的参考价值。

面试官:请问HashMap是线程安全的吗?

应聘者:HashMap是线程不安全的。

面试官:那么如何实现多线程下的线程安全?

应聘者: 

  • 通过Collections.synchronizedMap()来封装所有不安全的HashMap的方法,就连toString, hashCode都进行了封装,就是为每一个方法添加了synchronized关键字进行修饰。使用的是的synchronized方法,是一种悲观锁.在进入之前需要获得锁,确保独享当前对象,然后做相应的修改/读取。方式简单粗暴,但是效率低;

  • 使用ConcurrentHashMap。只有在需要修改对象时,比较和之前的值是否被人修改了,如果被其他线程修改了,那么就会返回失败,是一种无锁的实现。基于CAS实现,类似于乐观锁机制。ConcurrentHashMap采用了"锁分段"策略,ConcurrentHashMap的主干是一个一个Segment组,在ConcurrentHashMap中,一个Segment就是一个子哈希表,Segment里维护了一个HashEntry数组,并发环境下,对于不同Segment的数据进行操作是不用考虑锁竞争的,对于同一个Segment的操作才需考虑线程同步。理论上就允许16个线程并发执行。


引言

在分析高并发场景之前,我们需要先得搞清楚ReHash这个概念,Rehash是HashMap在扩容时候的一个步骤,HashMap的容量是有限的。当经过多次元素插入,使得HashMap达到一定饱和度时,Key映射位置发生冲突的几率会逐渐提高。这时候,HashMap需要扩展它的长度,也就是进行Resize。

影响发生Resize的因素有两个:

  • Capacity:HashMap的当前长度

  • LoadFactor:HashMap负载因子,默认值为0.75f

衡量HashMap是否进行Resize的条件如下:HashMap.Size >= Capacity * LoadFactor


HashMap的Resize方法不是简单的把长度扩大,它会创建一个新的Entry空数组,长度是原数组的2倍。遍历原Entry数组,把所有的Entry重新Hash到新数组。

那为什么要重新Hash呢?因为长度扩大以后,Hash的规则也随之改变

让我们回顾一下hash公式:index = key & (length - 1)

假如哈希桶数组table的size=2, 所以key = 3、7、5,put顺序依次为 5、7、3。在对2取余以后都冲突在table[1]这里了。这里假设负载因子 loadFactor=1,即当键值对的实际大小size 大于 table的实际大小时进行扩容。接下来的三个步骤是哈希桶数组resize成4,然后所有的Node重新rehash的过程。

Resize前的HashMap:

Resize后的HashMap:

HashMap是线程安全的吗?如何实现线程安全?

如上,在单线程情况下执行并没有什么问题,但是在多线程下HashMap并非线程安全的,下面我来演示一下,在多线程环境中,HashMap的Rehash操作可能带来什么样的问题?


场景分析

假设我们有两个线程,线程1和线程2,我们回头看一下我们的 transfer代码中的这个细节:

 1void transfer(Entry[] newTable) {  
2    Entry[] src = table;  
3    int newCapacity = newTable.length;  
4    for (int j = 0; j < src.length; j++) {  
5        Entry<K,V> e = src[j];  
6        if (e != null) {  
7            src[j] = null;  
8            do {  
9                Entry<K,V> next = e.next;  
10                int i = indexFor(e.hash, newCapacity);  
11                e.next = newTable[i];  
12                newTable[i] = e;  
13                e = next;  
14            } while (e != null);  
15        }  
16    }  
17}  

这个方法的目的是将原链表数据的数组拷到新的链表数组中,拷贝过程中这段代码就是造成环链的罪魁祸首。


理解下面流程之前,要先明白线程1和线程2都new了一个新的数组(即newTable),而这些数组在线程栈上是隔离的,所以相当于两个容器都在操作同一份数据,但是他们操作的链表是线程共享的,是在堆中生成的,只是在rehash的过程中本身的存储位置或者其next指向发生变化。这句话对于理解下面为什么会产生环状链的理解很有帮助。


①如上代码假设线程1执行到第9行这里就被调度挂起了,而我们的线程2执行完成了。于是我们有下面的这个样子:

HashMap是线程安全的吗?如何实现线程安全?

由于线程2已经执行完,所以目前的连接情况是 7->3,这个时候线程1被调度回来执行


②线程1执行第一次循环,我们知道在线程1停顿的时候,e=3,next=7,值已经赋好了。这时候将e(3)插入到空的newTable中,并且代码e.next = newTable[i],将3.next置空为null(因为此时newTable中所有元素为空),此时的链接情况是 3 -> null, newTable[3] = 3, 再将变量e指向7,对着下面代码慢慢看,慢慢理解:

1Entry<K,V> next = e.next;  
2int i = indexFor(e.hash, newCapacity);  
3e.next = newTable[i];  
4newTable[i] = e;  
5e = next;  

于是我们有了下面这个样子:

HashMap是线程安全的吗?如何实现线程安全?

线程1执行第二次循环,此时e=7,next=3(为什么这里next=3,这里用的是线程2执行完后对应的指向关系,因为我们操作的链表是在堆中),此时newTable相同下标中已经存在元素3,将7.next=3插入到newTable中,此时链接情况是 7->3->null, newTable[3]=7, 再将变量e指向3,于是我们又有了下面这样一张图:

HashMap是线程安全的吗?如何实现线程安全?

线程1执行第三次循环,此时e=3,next=null,计算3的hash值并将3放到newTable[3]中,此时newTable[3]=7,则将3.next=7,此时的链接情况为 3->7->3….,环形链表出现,但由于此将循环next为空,e=next,e也为空,退出循环。

HashMap是线程安全的吗?如何实现线程安全?

解决办法

了解了 HashMap 为什么线程不安全,那现在看看如何线程安全的使用 HashMap。这个无非就是以下三种方式:

  • Hashtable 

  • synchronizedMap()

  • ConcurrentHashMap(暂时不讲,单独一节说明)

Hashtable 

先说说Hashtable,Hashtable源码中是使用 synchronized 来保证线程安全的,比如下面的 get 方法和 put 方法:

1public synchronized V get(Object key) {
2       // 省略实现
3}
4public synchronized V put(K key, V value) {
5    // 省略实现
6}

所以当一个线程访问 HashTable 的同步方法时,其他线程如果也要访问同步方法,会被阻塞住


Collections.synchronizedMap()

查看源码,发现synchronizedMap()的实现还是比较简单的

 1public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m) {
2        return new SynchronizedMap<>(m);
3    }
4
5    private static class SynchronizedMap<K,V>
6        implements Map<K,V>, Serializable 
{
7        private static final long serialVersionUID = 1978198479659022715L;
8
9        private final Map<K,V> m;     // Backing Map
10        final Object      mutex;        // Object on which to synchronize
11
12        SynchronizedMap(Map<K,V> m) {
13            if (m==null)
14                throw new NullPointerException();
15            this.m = m;
16            mutex = this;
17        }
18
19        SynchronizedMap(Map<K,V> m, Object mutex) {
20            this.m = m;
21            this.mutex = mutex;
22        }
23
24        public int size() {
25            synchronized (mutex) {return m.size();}
26        }
27        public boolean isEmpty() {
28            synchronized (mutex) {return m.isEmpty();}
29        }
30        public boolean containsKey(Object key) {
31            synchronized (mutex) {return m.containsKey(key);}
32        }
33        public boolean containsValue(Object value) {
34            synchronized (mutex) {return m.containsValue(value);}
35        }
36        public V get(Object key) {
37            synchronized (mutex) {return m.get(key);}
38        }
39
40        public V put(K key, V value) {
41            synchronized (mutex) {return m.put(key, value);}
42        }
43        public V remove(Object key) {
44            synchronized (mutex) {return m.remove(key);}
45        }
46        public void putAll(Map<? extends K, ? extends V> map) {
47            synchronized (mutex) {m.putAll(map);}
48        }

从源码中可以看出调用 synchronizedMap() 方法后会返回一个 SynchronizedMap 类的对象,而在 SynchronizedMap 类中使用了 synchronized 同步关键字来保证对 Map 的操作是线程安全的,使用效果跟HashTable差不多。


推荐阅读


▼如果你喜欢我 ,就长按二维码关注我哟

以上是关于HashMap是线程安全的吗?如何实现线程安全?的主要内容,如果未能解决你的问题,请参考以下文章

HashMap 对于不同的键是线程安全的吗?

什么是线程安全,实现线程安全都有哪些方法

java是线程安全的吗

JAVA中线程安全的map都有哪些?

编程实践用 go 语言实现线程安全的 hashmap

拜托!别再问我hashmap是否线程安全