众所周知HashMap不是一个线程安全的Map,并发情况下可能会出现数据丢失,除此之外,JDK1.8之前的HashMap在并发场景下还可能产生死锁。
JDK1.8之前为什么会产生死锁:
想要了解产生死锁的原因是首先要了解JDK1.8之前的HashMap扩容原理,因为死锁正是由多个线程同时进行扩容操作导致的,HashMap底层容器为一个Entry[]数组,扩容就是建立一个目标容量的新数组,并将元素重新放到新数组内,并将新数组赋值为容器即完成了扩容,产生死锁的关键就在于从旧数组转移元素到新数组,JDK1.8之前这个过程通过transfer
方法实现:
// newTable即为新容器,rehash为是否需要重新计算hash值,无需关注
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; // 记录next
if (rehash) { // 必要时重新计算hash
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity); // 算出新的下标
e.next = newTable[i]; // 注意!!! 将新容器的i处节点赋值到当前元素的next上
newTable[i] = e; // 当前元素成为新的头节点
e = next; // 继续下一个节点
}
}
}
JDK1.8之前,HashMap底层是通过数组与链表实现的,JDK1.8才引入红黑树的概念。因此JDK1.7转移源码只针对链表进行操作了:
关键在于e.next = newTable[i];newTable[i] = e;e = next;
这最后三行源码。这三行源码会使得引用关系逆序:假设扩容之前i处有元素A->B->C,扩容之后A,B,C三个元素依然在i处。此时遍历A->B->C的过程为:
可以看到迁移之后的引用关系产生的逆转,那么如果线程T1进行put操作,发现容量不足执行扩容操作,在未扩容时T2也进行put操作,发现容量不足同时进入扩容流程,是不是会发生这种情况呢:
至此死锁产生…
JDK1.8的优化:
JDK1.8 HashMap除了引入了红黑树以加快检索速度外,对于扩容时链表元素的转移也进行了优化避免了死锁问题:
链表元素转移关键代码:
do {
next = e.next;
if ((e.hash & oldCap) == 0) { // 为0说明这个元素在原位,即为低位
if (loTail == null)
loHead = e; // 记录低位抬头
else
loTail.next = e; // 链接到loHead 所在的链表上
loTail = e; // 记录尾部
}
else { // 不为为0说明这个元素在j + oldCap,即为高位
if (hiTail == null)
hiHead = e; // 记录高位抬头
else
hiTail.next = e;// 链接到hiTail所在的链表上
hiTail = e; // 记录尾部
}
} while ((e = next) != null);
if (loTail != null) { // loTail 不为空说明低位有值
loTail.next = null; // 断开loTail.next的链接,因为loTail为尾部,若是loTail.next不为空,那么他会在高位
newTab[j] = loHead; // 赋值到新容器的对应位置
}
if (hiTail != null) { // hiTail 不为空说明高位有值
hiTail.next = null; // 断开hiTail.next的链接,因为hiTail为尾部,若是hiTail.next不为空,那么他会在低位
newTab[j + oldCap] = hiHead;// 赋值到新容器的对应位置
}
JDK1.8通过记录头尾双节点的方式,保证了同位元素引用顺序保持不变,以此避免了元素引用逆序所导致的并发死锁问题。