一、HashMap在JDK1.7中的并发问题
在JDK1.7中的HashMap是以数组+链表组成的,我们先来看一下每次的put操作,可以分为以下三步:
- 寻址
- 1.根据key的hashcode()方法计算原始哈希值
- 2.哈希值进一步和自身高16位做异或操作,得到更随机的hashcode
- 3.hashcode再和数组长度-1做与操作,得到数组的下标
- 判断是否扩容
- 如果map中的数组元素个数超过了 数组长度 * 负载因子(0.75),则需要扩容
- 比较并存放
- 通过equals比较每个链表节点的值并赋值
- 通过equals比较每个链表节点的值并赋值
寻址和存放都不会出现并发问题,问题就出现在扩容部分,接下来我们详细展开看一下扩容部分的源码:
- resize方法源码:
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
// 创建2倍大小的新数组
Entry[] newTable = new Entry[newCapacity];
// 将旧数组的链表转移到新数组,就是这个方法导致的hashMap不安全,等下我们进去看一眼
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
// 重新计算扩容阈值(容量*加载因子)
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
出问题的就是这个transfer方法,其作用是把旧节点转移到扩容后的新map中,从1.7源码中可以看出它使用的是头插法来插入新节点,问题也正是出现在了这里
- transfer方法源码:
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
// 遍历旧数组
for (Entry<K,V> e : table) {
// 遍历链表
while(null != e) {
//获取下一个元素,记录到一个临时变量,以便后面使用
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
// 计算节点在新数组中的下标
int i = indexFor(e.hash, newCapacity);
// 将旧节点插入到新节点的头部
e.next = newTable[i];
//这行才是真正把数据插入新数组中,前面那行代码只是设置当前节点的next
//这两行代码决定了倒序插入
//比如:以前同一个位置上是:3,7,后面可能变成了:7、3
newTable[i] = e;
//将下一个元素赋值给当前元素,以便遍历下一个元素
e = next;
}
}
}
二、死链如何产生?
假设初始状态的HashMap数据如下图
假设此时再调用下一个put方法时,发生了扩容,会new一个容量翻倍的Hashmap,如果此时有两个线程,则会同时进入扩容方法
此时t2线程率先完成transfer方法,由于是头插法,所以元素会呈现倒序状态
此时线程t1开始进入transfer方法,每次用头插法赋值前,会先记录下旧map中该节点的next节点,以便进入下一次循环
- t1线程挂key:3的节点
- t1线程挂key:7的节点,注意:在此间线程t2完成了所有新节点的赋值,所以此时7.next已经变成了3了
- 由于节点不为null,会再次进入循环,此时再次挂key:3,next节点又为7,从而产生了死链
通过时序图可以更清楚的了解整个并发问题的产生
三、如何解决HashMap并发问题
为了解决这个问题,JDK1.8把扩容的复制到新数组的算法从头插法改成了尾插法,并引入了红黑树。HashMap是非线程安全的,在多线程环境下还是推荐使用ConcurrentHashMap