在 JDK7 版本下,不少人都知道 HashMap 会有链表成环的问题,但大多数人只知道,是多线程引发的,至于具体细节的缘由,和 JDK8 中如何解决这个问题,不多有人说的清楚,百度也几乎看不懂,本文就和你们聊清楚两个问题:1:JDK7 中 HashMap 成环缘由,2:JDK8 中是如何解决的。java
1:HashMap 扩容时。
2:多线程环境下。面试
在扩容的 transfer 方法里面,有三行关键的代码,以下:数组
void transfer(Entry[] newTable, boolean rehash) { int newCapacity = newTable.length; for (Entry<K,V> e : table) { //e为空时循环结束 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]; newTable[i] = e; e = next; } } }
假设原来在数组 1 的下标位置有个链表,链表元素是 a->b->null,如今有两个线程同时执行这个方法,咱们先来根据线程 1 的执行状况来分别分析下这三行代码:安全
e.next = newTable[i];
newTable 表示新的数组,newTable[i] 表示新数组下标为 i 的值,第一次循环的时候为 null,e 表示原来链表位置的头一个元素,是 a,e.next 是 b,
e.next = newTable[i] 的意思就是拿出 a 来,而且使 a 的后一个节点是 null,以下图 1 的位置:
数据结构
newTable[i] = e;
就是把 a 赋值给新数组下标为 1 的地方,以下图 2 的位置:
多线程
e = next;
next 的值在 while 循环一开始就有了,为:Entry<K,V> next = e.next; 在此处 next 的值就是 b,把 b 赋值给 e,接着下一轮循环。线程
从 b 开始下一轮循环,重复 一、二、3,注意此时 e 是 b 了,而 newTable[i] 的值已经不是空了,已是 a 了,因此 1,2,3 行代码执行下来,b 就会插入到 a 的前面,以下图 3 的位置:
这个就是线程 1 的插入节奏。3d
重点来了,假设线程 1 执行到如今的时候,线程 2 也开始执行,线程 2 是从 a 开始执行 一、二、三、4 步,此时数组上面链表已经造成了 b->a->null,线程 2 拿出 a 再次执行 一、二、三、4,就会把 a 放到 b 的前面,你们能够想象一下,结果是以下图的:
从图中能够看出,有两个相同的 a 和两个相同的 b,这就是你们说的成环,本身通过不断 next 最终指向本身。code
注意!!!这种解释看似好像颇有道理,但其实是不正确的,网上不少这种解释,这种解释最致命的地方在于 newTable 不是共享的,线程 2 是没法在线程 1 newTable 的基础上再进行迁移数据的,一、二、3 都没有问题,但 4 有问题,最后的结论也是有问题的blog
由于 newTable 是在扩容方法中新建的局部变量,方法的局部变量线程之间确定是没法共享的,因此以上解释是有问题的,是错误的。
那么真正的问题出如今那里呢,其实线程 1 完成 一、二、三、4 步后就出现问题了,以下图:
总结一下产生这个问题的缘由:
接下来咱们来看下 JDK8 是怎么解决这个问题。
JDK 8 中扩容时,已经没有 JDK7 中的 transfer 方法了,而是本身从新写了扩容方法,叫作 resize,链表从老数组拷贝到新数组时的代码以下:
//规避了8版本如下的成环问题 else { // preserve order // loHead 表示老值,老值的意思是扩容后,该链表中计算出索引位置不变的元素 // hiHead 表示新值,新值的意思是扩容后,计算出索引位置发生变化的元素 // 举个例子,数组大小是 8 ,在数组索引位置是 1 的地方挂着一个链表,链表有两个值,两个值的 hashcode 分别是是9和33。 // 当数组发生扩容时,新数组的大小是 16,此时 hashcode 是 33 的值计算出来的数组索引位置仍然是 1,咱们称为老值 // hashcode 是 9 的值计算出来的数组索引位置是 9,就发生了变化,咱们称为新值。 Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; // java 7 是在 while 循环里面,单个计算好数组索引位置后,单个的插入数组中,在多线程状况下,会有成环问题 // java 8 是等链表整个 while 循环结束后,才给数组赋值,因此多线程状况下,也不会成环 do { next = e.next; // (e.hash & oldCap) == 0 表示老值链表 if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } // (e.hash & oldCap) == 0 表示新值链表 else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); // 老值链表赋值给原来的数组索引位置 if (loTail != null) { loTail.next = null; newTab[j] = loHead; } // 新值链表赋值到新的数组索引位置 if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } }
解决办法其实代码中的注释已经说的很清楚了,咱们总结一下:
JDK8 是等链表整个 while 循环结束后,才给数组赋值,此时使用局部变量 loHead 和 hiHead 来保存链表的值,由于是局部变量,因此多线程的状况下,确定是没有问题的。
为何有 loHead 和 hiHead 两个新老值来保存链表呢,主要是由于扩容后,链表中的元素的索引位置是可能发生变化的,代码注释中举了一个例子:
数组大小是 8 ,在数组索引位置是 1 的地方挂着一个链表,链表有两个值,两个值的 hashcode 分别是是 9 和 33。当数组发生扩容时,新数组的大小是 16,此时 hashcode 是 33 的值计算出来的数组索引位置仍然是 1,咱们称为老值(loHead),而 hashcode 是 9 的值计算出来的数组索引位置倒是 9,不是 1 了,索引位置就发生了变化,咱们称为新值(hiHead)。
你们能够仔细看一下这几行代码,很是巧妙。
本文主要分析了 HashMap 链表成环的缘由和解决方案,你学会了吗?
想知道 HashMap 底层数据结构是什么样的么?想了解 ConcurrentHashMap 是如何保证线程安全的么?想阅读更多 JUC 的源码么,请关注个人新课:面试官系统精讲Java源码及大厂真题,带你一块儿阅读 Java 核心源码,了解更多使用场景,为升职加薪作好准备。