开篇
为什么要写这篇文章
JDK1.7 的 HashMap 并发扩容时为什么会产生死循环,这个可以说是 java 面试中老生常谈的一个问题了。网上有很多文章对这个问题进行分析,我看了一下,大多数都是介绍扩容的一个过程,以及扩容过程中元素的移动,感觉并没有把根本原因清楚地表达出来。
而且有些文章居然说在JDK1.8当中,HashMap在扩容时由链表转为红黑树来解决死循环的问题,我也是醉了,树化的意义是在于提高查询性能,当链表过长时,查询的时间复杂度会退化为O(n)。而且树化也是要满足条件的,那就是链表长度必须大于8,但是死循环问题就算链表只有三个元素也会产生。所以大家在看相关文章解释的时候也要结合自己的思考。
因此我想通过这篇文章,把自己关于这个问题的理解表达出来。
开门见山
- 并不是所有的 HashMap 都会在并发扩容的时候产生死循环!
- 产生死循环的条件必须要满足以下几点:必须同时有两个或者两个以上的线程同时对同一个HashMap进行扩容。(这是前提)这个HashMap中的必须要有两个或两个以上的元素在扩容前后都会处在同一个链表上(这是外部因素)JDK1.7的HashMap用的是头插法,头插法会造成链表回路(这是内在因素)链表回路就会产生死循环吗?并不是的,只有在你调用hashMap的get方法去获取一个不存在的数据,而且该数据正好被hash到产生了链表回路的链表上。
这里我稍微解释一下什么叫头插法和链表回路。
头插法
我们都知道JDK1.7的HashMap是以拉链法来组织其数据结构的,HashMap的table数组的每一个元素table[i]都是一个链表的起始节点,当我们要往该链表中插入数据,该插入的数据就会成为该链表的起始节点,即每次生成一个新结点都是插到第一个结点的位置.
举个例子:
- table数组的table[i]位置是没有元素的. 即table[i]=null
- 往hashMap中插入一个元素a, a需要放置的位置刚好就是table[i]。此时,table[i]=a,a.next=null。
- 再往hashMap中插入一个元素b,而且b需要放置的位置刚好就是table[i]。此时,next = table[i] (即a),table[i]=b,b.netx=next
链表回路
链表 链表回路其实很好理解,就是a.next=b,b.next=a。
链表回路的形成原因分析
hashMap关键源码
其实这里的源码大家可以先不用看,直接看下面的图,需要对照源码的时候再回过来查看分析。
Put方法:
public V put(K key, V value) { ...... // 计算 Hash 值 int hash = hash(key.hashCode()); int i = indexFor(hash, table.length); // 如果该 key 已被插入,则替换掉旧的 value(链接操作) for (Entry e = table[i]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } modCount++; // 该 key 不存在,需要增加一个结点 addEntry(hash, key, value, i); return null;}
检查容量是否超标
void addEntry(int hash, K key, V value, int bucketIndex) { Entry e = table[bucketIndex]; table[bucketIndex] = new Entry(hash, key, value, e); //查看当前的 size 是否超过了设定的阈值 threshold,如果超过,需要 resize if (size++ >= threshold) resize(2 * table.length);}
resize
void resize(int newCapacity) { Entry[] oldTable = table; int oldCapacity = oldTable.length; ...... // 创建一个新的 Hash Table Entry[] newTable = new Entry[newCapacity]; // 将 Old Hash Table 上的数据迁移到 New Hash Table 上 transfer(newTable); table = newTable; threshold = (int)(newCapacity * loadFactor);}
transfer
void transfer(Entry[] newTable) { Entry[] src = table; int newCapacity = newTable.length; //下面这段代码的意思是: // 从OldTable里摘一个元素出来,然后放到NewTable中 for (int j = 0; j < src.length; j++) { Entry e = src[j]; if (e != null) { src[j] = null; do { Entry next = e.next; int i = indexFor(e.hash, newCapacity); e.next = newTable[i]; newTable[i] = e; e = next; } while (e != null); } }}
为了方便理解,假定以下场景。
- HashMap 的初始大小为2,不考虑负载因子
- mod除计算新插入元素的位置
- 依次插入三个key,分别为:5,7,3。value随意,比如:v5,v7,v3
正常的单线程扩容
- HashMap的初始状态
- 插入key为5的元素
- 插入key为7的元素
- 插入key为3的元素
对比上面新增元素的代码可知,插入完key为3的元素后HashMap会扩容rehash。
在resize的过程中,一定要关注链表中当前节点e和next的变化。
- 扩容第一步,将key为3的元素放到新table
e和next的变化(指的是扩容前的HashMap):
- 往新table放入3前:e=3,next=7
- 往新table放入3后:e=7,next=5
- 扩容第二步,将key为7的元素放到新table
e和next的变化(指的是扩容前的HashMap):
- 往新table放入7前:e=7,next=5
- 往新table放入7后:e=5,next=null
- 扩容第三步,将key为5的元素放到新table
e和next的变化(指的是扩容前的HashMap):
- 往新table放入5前:e=5,next=null
并发下的HashMap扩容
大家要注意的是,a,b两个线程在执行transfer方法的时候,有一些细节大家一定要理解清楚,否则可能会很难理解,我写一下注释。
//newTable:每个线程都会创建一个新的newTable,在把旧table复制完后会执行赋值table = newTable;void transfer(Entry[] newTable) { //table是每个线程都共享 Entry[] src = table; int newCapacity = newTable.length; // 从OldTable里摘一个元素出来,然后放到NewTable中 for (int j = 0; j < src.length; j++) { Entry e = src[j]; if (e != null) { src[j] = null; do { //拿到next,让链表的后续元素transfer继续 Entry next = e.next; int i = indexFor(e.hash, newCapacity); //因为是头插法,所以每次插入的节点的next都是当前链表的第一个元素 e.next = newTable[i]; newTable[i] = e; e = next; } while (e != null); } }}
假定条件:
- 两个线程a,b在并发扩容
- 在transfer方法里,线程a执行完Entry next = e.next;这里就被调度挂起了,注意是执行完
- 线程b正常执行完扩容的过程。
需要注意的是:
线程a执行完Entry next = e.next; 此时对于线程a来说,e=3,next=7。对于a线程来说,如果只有它自己在扩容,那正常的情况是7.next=5,但是线程b已经扩容完成,已经把7.next变成3了!!!这是造成链表回路的关键原因。e代表正在要移动到新table的元素,next代表下一个。线程b已经执行完了,所以相应元素的next也已经改变了,具体可参照下图。此时一,二两个线程的状态图分别为
线程二执行完扩容后,轮到线程a继续往下执行。
对于线程一来说会有下面的几个过程
- 将e=3移到新table
在移动元素3之前,早在线程a被挂起的时候,线程a已经获取到了next=7,这点很关键。
这里可能有朋友会问,为什么next是null,其实大家回去看一下扩容的源码就会发现在next的赋值时是执行这句代码e.next = newTable[i];。刚开始newTable没有元素,那么next就为null啦。
移动之后,e=next=7,7.next=3,newTable第一个元素为3。大家发现问题了没有!!!
- 将e=7移到新table
在移动元素7之前,获取到了7.next=3,赋值next=3(注意,这里是重新去读取,因为线程b已经扩容完成了,所以7.next=3!!!),这点很关键。
移动之后,第一个元素table[i]=7,e=next=3。
- 将e=3移到新table
在移动元素3之前,获取到了3.next=null,赋值next=null,因为next=null,所以这次是最后一次循环,扩容结束
前提e=3(注意要看新table),next=null。将e移到新table,移动之后3.next=table[i]=7,e=next=null。此时链表回路正式形成!!!
- 因为e=null,所以循环结束
死循环出现
对于上述的链表回路,如果我们要从hashMap中获取key为11的元素,此时死循环就出现了。
JDK1.8改进
如果大家对jdk1.8如何解决链表回路感兴趣的话请留言,后面会写文章解释。