一篇文章深入理解 JDK8 HashMap
笔者在上一篇文章《深入理解JDK7 HashMap》中详细解析了HashMap在JDK7中的实现原理,主要是围绕其put、get、resize、transfer等方法,本文将继续解析HashMap在JDK8中的具体实现,首先也将从put、get、resize等方法出发,着重解析HashMap在JDK7和JDK8中的具体区别,最后回答并解析一些常见的HashMap问题。在阅读本篇文章之前,建议阅读上一篇文章作为基础。
一、HashMap在JDK8中的结构
上一篇文章提到,HashMap在JDK7或者JDK8中采用的基本存储结构都是数组+链表形式,可能有人会提出疑问,HashMap在JDK8中不是数组+链表+红黑树吗?本文的回答是。至于为什么JDK8在一定条件下将链表转换为红黑树,我相信很多人都会回答:为了提高查询效率。基本答案可以说是这样的,JDK7中的HashMap对着Entry节点增多,哈希碰撞的概率在慢慢变大,这就直接导致哈希表中的单链表越来越长,这就大大降低了HashMap的查询能力,且时间复杂度可能会退化到O(n)。针对这种情况,JDK8做出了优化,就是在一定的条件下,链表会被转换为红黑树,提升查询效率。
HashMap在JDK8中基本结构示意图如下所示:
在上面的示意图可以看出,与JDK7的最大区别就是哈希表中不仅有链表,还有可能存在红黑树这种结构。那么看着图,可以提出两个问题:
何时链表会转换成红黑树?
为什么需要转换为红黑树?
那么带着这两个问题,跟随我的脚步,我们一起去研究HashMap在JDK8中的具体实现。
二、HashMap在JDK8中的具体实现
2.1 理解HashMap的成员变量
JDK8中的HashMap也有多个成员属性,如下所示:
由于《深入理解JDK7 HashMap》已经详细介绍了上述部分成员属性,这里仅仅介绍一下JDK8特有的属性:
TREEIFY_THRESHOLD:链表转换为红黑树的阈值,当链表长度大于等于
8
的时候,链表可能会被转换为红黑树,这里之所以说是可能,是因为还要满足另外一个条件:哈希表长度大于等于64
,否则哈希表会尝试扩容。UNTREEIFY_THRESHOLD:红黑树退化成链表的阈值,当红黑树节点小于等于
6
的时候,红黑树会转换成普通的链表。MINTREEIFYCAPACITY:链表转换为红黑树的第二个条件,哈希表长度大于等于
64
的时候,且链表长度达到8
才会转换为红黑树,否则将会扩容。
在JDK7中,Key和Value的存储是利用的Entry节点,JDK8中使用的是Node节点,前者是JDK7中HashMap的内部类,实现了Map.Entry接口,后者是JDK8中HashMap的内部类,实现的也是Map.Entry接口,两者的成员属性也是一致的的,具体代码比较如下所示:
JDK7中Entry节点
JDK8中Node节点
两者其实是一致的,这里不做过多解释。
2.2 理解HashMap的构造方法
相比于JDK7,JDK8的HashMap构造方法和JDK7几乎一致,这里需要说一点区别,JDK7中HashMap的构造方法如下所示:
JDK8中该构造方法如下所示:
相比较发现,JDK8中使用了tableSizeFor()方法,其实就是计算出了最接近initialCapacity的且大于initialCapacity的2的N次幂的值,比如传入的初始化容量为27,那么最接近27且大于27的2的N次幂是32,此时N = 5
,而在JDK7中是在第一次put中完成的,当然对于threshold的值,未初始化的时候都是承载的是initialCapacity,后续都会重新计算为capacity * loadFactor
。
2.3 理解HashMap的put方法
相比于JDK7,JDK8的put方法貌似要复杂很多,咋一眼看上去有点惶恐,不过没关系,我们一起一行一行来分析,基本上也能啃下这块硬骨头。
分析上面的代码,put基本流程可以总结如下:
第一步:检查哈希表是否为空,如果为空,就进行扩容。
第二步:通过key的hash值以及哈希表长度来确定当前key在哈希表中的索引下标,并获取到同一下标下的链表首节点或者红黑树的根节点。
第三步:如果获取到首节点(或根节点)为null,说明当前哈希表的位置上没有任何链表或者红黑树,此时将key和value封装成Node节点对象存储到首节点位置。
第四步:如果获取到首节点(或根节点)不为null,说明当前哈希表的位置上有链表或者红黑树,这是进一步判断当前key是否和首节点的Node中key一致,如果一致,则将首节点的值替换成新值,否则进行下一步。
第五步:如果当前需要存储的key和首节点的key不一致,那么进一步判断当前首节点是否为TreeNode类型,也就是红黑树类型,如果是红黑树类型,则做树节点插入,否则进行第六步。
第六步:如果需要存储的key和首节点的key不一致,且首节点不是TreeNode类型,那么就到这第六步,第六步主要就是遍历链表,如果链表中包含相同key的Node对象,那么就做值的替换,否则将新建Node对象并插到链表的结尾处。完成结尾处插入之后,就会依据条件binCount >= 7
来判断是否尝试将链表转换为红黑树,这里之所以是遍历次数大于等于7
,这是因为从链表的第二个节点开始的。
第七步:最后判断K-V对数是否超过了扩容阈值threshold,超过了则扩容。
特别说明:上述第六步之所以说是尝试转换成红黑树,这是因为在转换逻辑里面对哈希表的长度还进行了校验,只有哈希表长度大于等于64才转换,否则进行扩容。这里面的原理将在转换红黑树的代码里面详细分析。
为了方便理解put的整个流程,将上述文字描述转换为流程图:
从上面的流程图就可以回答文章开始的第一个疑问:何时链表会转换成红黑树?
转换成红黑树条件是发生哈希冲突,且新节点下标所在位置有链表,且新节点必须插入到链表尾部且插入后长度正好达到8,并且哈希表长度要大于等于64,这些条件都必须满足才会有链表转换成为红黑树的操作。如果没有发生哈希冲突,就不会转换,也就是新节点直接存入哈希表的桶内。如果新节点替换了首节点或者其他节点,那么也不会发生转换。如果插入到链表尾部,且链表长度达到8,且哈希表长度达到64才会发生转换操作。
在put过程中会有将链表转换为红黑树的过程,具体转换代码如下所示:
这里暂不分析链表是如何转换成为红黑树的,红黑树这种数据结构其内容还是比较繁琐的,要求读者具有红黑树数据结构基础,过多的分析将影响本文对HashMap的实现原理分析,后期笔者将专门推出一文来讲解红黑树,届时读者阅读后可以自行查看treeify方法学习内部的实现原理。
那么这里回答一下第二个疑问:为什么需要转换为红黑树?
HashMap在JDK8之后引入了红黑树的概念,表示若哈希表的桶中链表元素超过8
时(默认哈希表长度不小于64),会自动转化成红黑树;若桶中元素小于等于6时,树结构还原成链表形式。红黑树的平均查找长度是log(n),长度为8,查找长度为log(8)=3,链表的平均查长度为n/2,当长度为8时,平均查找长度为8/2=4,这才有转换成树的必要;链表长度如果是小于等于6,6/2=3,虽然速度也很快的,但是转化为树结构和生成树的时间并不会太短。以6和8来作为平衡点是因为,中间有个差值7可以防止链表和树之间频繁的转换。假设,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。概括起来就是:链表:如果元素小于8个,查询成本高,新增成本低,红黑树:如果元素大于8个,查询成本低,新增成本高。
2.4 理解HashMap的resize方法
前一篇文章分析过HashMap在JDK7中的扩容条件:
K-V对数大于等于扩容阈值,也就是
size >= threshold
,并且put过程中要发生哈希碰撞(key为null的碰撞除外),也就是说要存放Entry对象的桶已经存在链表了,这个时候才会扩容,否则仅满足size >= threshold
是不会发生扩容的。put键为null的K-V对的时候永远不会发生扩容。
那么在本文中分析了HashMap在JDK8中的put方法,基本可以总结出HashMap在JDK8中的扩容条件:
哈希表为null或者长度为0。
哈希表中存储的K-V对数超过了threshold。
链表中长度超过了8,并且哈希表长度到达64,此时也会发生扩容。
对比JDK7和JDK8,HashMap的扩容条件也有些差异,这也是这两个JDK版本中HashMap的区别之一了。
再者,HashMap在JDK7和JDK8中的扩容机制其实也是有区别的,在JDK8中,HashMap的扩容机制有了改进,设计的非常巧妙,避免了JDK7中的“再哈希”,提高了扩容性能。接下来,我将使用示意图的形式将展示HashMap在JDK7和JDK8中的扩容基本算法,方便读者理解两个版本之间的差异。
2.4.1 重新理解JDK7 HashMap的resize方法
首先我们将上一篇文章对resize方法(JDK7)进行分析的源码注释拷贝过来,扩容代码如下:
上面的代码展示了HashMap在JDK7中的扩容和数据迁移,基本过程是先创建一个容量是原来数组容量2倍的哈希表,然后遍历老哈希表,将每个桶内链表都遍历一遍,然后对每个key重新进行hash计算,然后将其插入到新的哈希表中,直到所有的Entry对象都转移到了新的哈希表中为止。
这里我们一起举个例子来展示一下HashMap在JDK7中的具体扩容过程。这里假设获取下标的算法就是key的hash值对哈希表长度进行取模运算(也可以是对length - 1
进行取模运算,这里为了方便直接对长度取模运算),且所有的key都是有正整数来表示(因为正整数的hash值就是正整数本身),假设哈希表长度为4,有四个Entry对象,他们key分别是5,9,13,17,他们的值可以是任何同一类型的值,这里使用大写字母A,B,C,D来表示。四个Entry插入顺序按照key来排序就是17,13,9,5,按照本例中假设的下标算法,这四个Entry对象都发生了哈希冲突,且都位于下标为1的bucket桶中,如下图所示:
JDK中向链表中添加节点是采用的头插入法,哈希表长度为4,它的threshold = 3
,当添加第四个节点的时候,发现达到了扩容的条件,那么就需要进行扩容,首先是新建一个容量为8的哈希表,然后将对老哈希表中所有节点再哈希,重新求取所有节点的下标,这里需要注意的是,整个扩容过程是在准备添加key = 5
的Entry节点的时候触发的,那么首先将老哈希表中所有的Entry节点搬迁到新哈希表中,最后再添加key = 5
的Entry节点到新哈希表中。在新的哈希表中插入Entry节点的顺序就变成了9,13,17,5,最后的插入结果如下图所示:
观察上图可以看出,当原链表中的节点key再出现哈希冲突的时候,必然会出现倒挂现象,这里的key=17和key=9的节点就出现了倒挂现象,这也就是HashMap内元素是无序的原因了,读者可以细细体会。
2.4.2 对比理解JDK8 HashMap的resize方法
对比JDK7中HashMap,其实JDK8中,HashMap的扩容机制实现的原理也是类似的,也是遍历链表或者红黑树来完成数据的迁移。只不过两者之间的区别是后者不再重新对所有节点进行再哈希运算了,而是设计了一个更为巧妙的方式来确定节点的下标,那么到底是如何确定的呢?我们一同通过阅读源码来一探究竟。
其实读者对上面的代码读起来并不是很难,逻辑也很清晰,但是对于如何将一个链表拆分为两个链表可能会存在疑问,不知思绪在何方!其实光从链表分割的判断依据(e.hash & oldCap) == 0
很难直接看清楚是如何判断,那么接下来我们就从判断条件出发,使用图文的形式将问题说清楚。
举个例子来说明一下:
我们假设有一个哈希表,表的容量为默认初始容量16,那么length - 1 = 15
,15使用二进制表示就是:0000 0000 0000 0000 0000 0000 0000 1111
,而任何key计算出来的hash值就可以使用二进制来表示,那么(length - 1) & hash
,其实就是取hash值的最低四位,因为15的二进制前28位都为0,我们假设有四个K-V对,如下表所示:
正整数的hash值就是其本身,转换成二进制后对length - 1
进行&
运算,其实就是最后的二进制结果都是0101
,所以下标都是5。也就是说这四个K-V组成的Node节点都在一个bucket内,且组成了单链表。我们假设需要对当前哈希表进行扩容,那么扩容后的容量就是32,那么各个节点的新的索引值就是(32 - 1) & hash
,而31的二进制表示为0000 0000 0000 0000 0000 0000 0001 1111
,其实就是去hash值的最低五位,因为31的二进制最低五位为11111
,那么对hash值取最低五位其实就无非有两种情况:最后四位和hash值一致,第五位要么是1,要么是0,这么一来,上述四个键值对的key计算的下标如下表所示:
从上表中也可以看出来,00101
和原来一样,下标依旧是5,而10101其实就是10101 = 00101 + 10000 = 5 + 16 = j + oldCap
,其中j
就是就是节点在老哈希表中的下标。故虽然数组大小扩大了一倍,但是同一个key在新旧哈希表中对应的下标却存在一定联系:要么一致,要么相差一个 oldCap。
基于这个结论,那么我们只需要看新增的高位是0或者是1即可,这里假设是从16扩容到32的,那么看第五位即可,其实就是hash & 10000
即可,也就是:
上式等价于:
所以这个等式的结果要么是0,要么就是16,也就是新增的高位(第五位)要么是0,要么是1,当为0的时候,那么Node节点下标不变,当为1的时候,Node节点下标为j + oldCap
,这里也就是5 + 16 = 21
,扩容结果如下图所示:
到这里,就基本完成了对HashMap在两个版本中扩容机制的补充说明,通过上面图文的详细讲解,读者肯定对整个扩容机制有了一个更加深刻的认识。
2.5 理解HashMap的get方法
JDK7和JDK8中的get方法其实原理上没有多大区别,仅仅是JDK8中多了红黑树的节点获取,其他的基本一致,这里贴出源码,逐行对源码进行注释分析,至于流程,读者可以参考《深入理解JDK7 HashMap》中对get方法的流程图描述,或者自行画出流程图,这里就不再贴出流程图。
把上述代码总结起来就是以下几个步骤:
1. 检查哈希表数组是否为null和索引位首节点(bucket的第一个节点)是否为null
2. 如果索引节点的hash==key的hash或 key和索引节点的k相同则直接返回(bucket的第一个节点)
3. 如果首节点是红黑色则到红黑树查找key相同的节点
4. 不是首节点,也不是红黑树,那么就开始遍历链表,获取与key相同键的节点
5. 如果都没找到就返回null
三、几个关于HashMap的常见问题
3.1 都说HashMap是线程不安全的,那么到底不安全在哪?
HashMap在多线程环境下,存在数据覆盖的问题。这里以JDK7为代表举一个put的例子,线程1和线程2同时对哈希表中的某个索引位置put一个Entry节点,线程1获取到指定索引位置的头节点,线程2也同时获取到了指定位置的头节点,因此两个线程都同时创建一个新的Entry对象存到指定的索引位置上,并将新的Entry节点的next属性指向老的头节点,这就会产生数据覆盖的问题,假设线程2先完成,那么线程1就会直接覆覆盖掉线程2插入的头节点。
3.2 HashMap的扩容死锁是如何造成的?
HashMap的扩容死锁问题发生在JDK7及以前版本中,这是因为JDK7及以前版本在扩容后的数据迁移采用的头插入法,JDK8以后版本进行了优化,采用的是尾部插入法以及两队链表,避免了该问题。现在就以JDK7的HashMap为例,来分析一下是如何造成扩容死锁的。
在JDK7中,数据的迁移是在如下代码中完成的:
其实就是重新确定链表中的节点的索引下标,然后将其插入到对应的下标的链表内,JDK7采用的是头节点插入,其实这就给了多线程环境下产生扩容死锁机会。接下来我使用图来说明这一现象。
原哈希表如下图所示,为了排版方便,不在统一画出hash、key、value、next等属性,统一使用一个方格表示这四个属性。
原哈希表:
线程1和2各自创建了一个新的哈希表,假设在线程1已做完扩容操作后,线程2才开始扩容。此时对于线程2来说,当前节点e指向A节点,下一个节点e.next仍然指向B节点,而此时在线程1的链表中,已经是C->B->A的顺序。按照头插法,线程2中哈希表的bucket指向A节点,此时A节点成为线程B中链表的头节点,如下图所示:
A节点成为线程2中链表的头节点后,下一个结点e.next为B节点。很显然,下一个节点满足e.next != null
,那么当前节点e就变成了B节点,下一个节点e.next变为A结点。继续执行头插法,将B变为链表的头结点,同时next指针指向旧的头节点A,如下图:
此时,下一个节点e.next为A节点,不为null,继续头插法。指针后移,那么当前节点e就成为了A结点,下一个结点为null。将A节点作为线程2链表中的头结点,并将next指针指向原来的旧头节点B,如下图所示:
此时已经形成了环链表,A和B节点中互有对方引用,这也是HashMap线程不安全的一种表现。
3.3 如何规避HashMap的线程不安全问题?
HashMap是非线程安全的,可以使用Collections.SynchronizedMap()
来包装HashMap,使其具备线程安全的特性。多线程环境下,可以使用ConcurrentHashMap来代替HashMap,ConcurrentHashMap的用法和HashMap基本一致,读者可以完全实现切换零成本。后续笔者将继续推出ConcurrentHashMap的内部原理分析,敬请期待!
了解更多干货,欢迎关注我的微信公众号:爪哇论剑(微信号:itlemon)
版权声明: 本文为 InfoQ 作者【独钓寒江雪】的原创文章。
原文链接:【http://xie.infoq.cn/article/b2878d0aed9bc3f384422de59】。文章转载请联系作者。
评论