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的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执行完成了。于是我们有下面的这个样子:
由于线程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;
于是我们有了下面这个样子:
③线程1执行第二次循环,此时e=7,next=3(为什么这里next=3,这里用的是线程2执行完后对应的指向关系,因为我们操作的链表是在堆中),此时newTable相同下标中已经存在元素3,将7.next=3插入到newTable中,此时链接情况是 7->3->null, newTable[3]=7, 再将变量e指向3,于是我们又有了下面这样一张图:
③线程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。这个无非就是以下三种方式:
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是线程安全的吗?如何实现线程安全?的主要内容,如果未能解决你的问题,请参考以下文章