目录
问题描述
在JDK1.7版本下,HashMap在多线程并发下会出现死循环
原因剖析
首先在jdk1.7中,HashMap的底层是使用数组+链表实现的,而链表使用的是头插法插入数据
多线程并发时,在扩容的时候,新的数组中的某个位置链表,使用头插法插入数据的过程中,指针指向可能会出现问题,造成循环链表
JDK1.7HashMap扩容过程
源码
//扩容后将数据填充进新桶
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);//定位Hash桶,计算的结果可能为:初始位置 或者 初始位置 + 扩容量
e.next = newTable[i];//扩容插入也会采用头插法,与旧的hash桶相比链表的顺序可能会被颠倒,并且分散到 初始位置 和 初始位置 + 扩容量两个位置中
newTable[i] = e;
e = next;
}
}
}
图解
- 假设现在hashmap数组的初始长度为3,有一个键值对a已经存在
- 依次插入数据b、c,假设b、c都和a发生哈希碰撞,即这三个键值对的hash值是一样的,那么这个位置存储的就是一个链表,使用头插法插入结点,即在链表头部插入新数据
- 单线程扩容
- 开辟新数组,让next指向e结点的下一个结点:int newCapacity = newTable.length;
- 获取有数据的位置的旧数组下标,并通过该数据计算新数组下标,即在新数组中的位置:int i = indexFor(e.hash, newCapacity);
- 让头结点指向新数组中的位置:e.next = newTable[i],这里假设这三个数据在新数组中还是会发生哈希碰撞
- 将e结点赋给新数组对应位置:newTable[i] = e;
- 让e指向nexte = next;
- e结点不为空:while(null != e) { /**循环体**/ },继续循环
- 最终结果(因为使用头插法,所以扩容后链表会反过来):
- 开辟新数组,让next指向e结点的下一个结点:int newCapacity = newTable.length;
多线程并发下,扩容出现循环的图解
同时有个多个线程触发了扩容方法,但是线程1在执行完Entry<K,V> next = e.next后由于某些不可抗因素冻结线程,此时Thread获取到CPU资源,开始扩容,线程2正常执行
线程1这时重新获得cpu资源,继续运行,线程2已扩容完毕,这时线程1扩容的就是扩容后的数组了,但是e和next的指向在线程冻结前存储过了,于是有
继续运行,e.next指向新数组中对应的位置
将e结点放置在新数组
e指向next
第一次循环结束e不指向null,循环继续
这时候e.next指向新数组,其实是不变的
将e结点放置到新数组
e指向next
e不指向空,继续循环
这时候,执行e.next = newTable[i],a又指向了b,这时候头插出现了循环,导致死锁问题
总结
就是JDK1.7的头插法造成了循环问题,因此在JDK1.8中对HashMap的扩容方法进行了改进,从头插法改成了尾插法